├── .gitignore ├── .rspec ├── .rubocop.yml ├── .tool-versions ├── .travis.yml ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── MIT-LICENSE ├── README.md ├── README.rdoc ├── Rakefile ├── file_validators.gemspec ├── gemfiles ├── activemodel_3.2.gemfile ├── activemodel_4.0.gemfile ├── activemodel_5.0.gemfile ├── activemodel_6.0.gemfile └── activemodel_6.1.gemfile ├── lib ├── file_validators.rb └── file_validators │ ├── error.rb │ ├── locale │ └── en.yml │ ├── mime_type_analyzer.rb │ ├── validators │ ├── file_content_type_validator.rb │ └── file_size_validator.rb │ └── version.rb └── spec ├── fixtures ├── chubby_bubble.jpg ├── chubby_cute.png ├── cute.jpg ├── sample.txt └── spoofed.jpg ├── integration ├── combined_validators_integration_spec.rb ├── file_content_type_validation_integration_spec.rb └── file_size_validator_integration_spec.rb ├── lib └── file_validators │ ├── mime_type_analyzer_spec.rb │ └── validators │ ├── file_content_type_validator_spec.rb │ └── file_size_validator_spec.rb ├── locale └── en.yml ├── spec_helper.rb └── support ├── fakeio.rb ├── helpers.rb └── matchers ├── allow_content_type.rb └── allow_file_size.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | log/*.log 3 | pkg/ 4 | spec/dummy/db/*.sqlite3 5 | spec/dummy/db/*.sqlite3-journal 6 | spec/dummy/log/*.log 7 | spec/dummy/tmp/ 8 | spec/dummy/.sass-cache 9 | Gemfile.lock 10 | gemfiles/*.lock 11 | coverage/ 12 | /.idea 13 | .ruby-version 14 | .agignore 15 | tags 16 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Bundler/OrderedGems: 2 | Enabled: false 3 | 4 | Style/Documentation: 5 | Enabled: false 6 | 7 | Style/MissingRespondToMissing: 8 | Enabled: false 9 | 10 | Style/CaseEquality: 11 | Enabled: false 12 | 13 | Style/GuardClause: 14 | Enabled: false 15 | 16 | Style/RegexpLiteral: 17 | Enabled: false 18 | 19 | Style/Next: 20 | Enabled: false 21 | 22 | Metrics/LineLength: 23 | Max: 110 24 | 25 | Metrics/ModuleLength: 26 | Enabled: false 27 | 28 | Metrics/BlockLength: 29 | Enabled: false 30 | 31 | Metrics/MethodLength: 32 | Enabled: false 33 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 2.3.1 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | rvm: 4 | - 2.2.3 5 | - 2.5.0 6 | - 3.0.0 7 | - ruby-head 8 | - jruby-9.1.7.0 9 | - jruby-9.2.13.0 10 | 11 | gemfile: 12 | - gemfiles/activemodel_6.1.gemfile 13 | - gemfiles/activemodel_6.0.gemfile 14 | - gemfiles/activemodel_5.0.gemfile 15 | - gemfiles/activemodel_4.0.gemfile 16 | - gemfiles/activemodel_3.2.gemfile 17 | 18 | matrix: 19 | exclude: 20 | - rvm: 2.2.3 21 | gemfile: gemfiles/activemodel_6.0.gemfile 22 | - rvm: 2.2.3 23 | gemfile: gemfiles/activemodel_6.1.gemfile 24 | 25 | - rvm: 2.5.0 26 | gemfile: gemfiles/activemodel_3.2.gemfile 27 | - rvm: 2.5.0 28 | gemfile: gemfiles/activemodel_4.0.gemfile 29 | 30 | - rvm: 3.0.0 31 | gemfile: gemfiles/activemodel_3.2.gemfile 32 | - rvm: 3.0.0 33 | gemfile: gemfiles/activemodel_4.0.gemfile 34 | - rvm: 3.0.0 35 | gemfile: gemfiles/activemodel_5.0.gemfile 36 | 37 | - rvm: ruby-head 38 | gemfile: gemfiles/activemodel_3.2.gemfile 39 | - rvm: ruby-head 40 | gemfile: gemfiles/activemodel_4.0.gemfile 41 | - rvm: ruby-head 42 | gemfile: gemfiles/activemodel_5.0.gemfile 43 | 44 | - rvm: jruby-9.1.7.0 45 | gemfile: gemfiles/activemodel_6.0.gemfile 46 | - rvm: jruby-9.1.7.0 47 | gemfile: gemfiles/activemodel_6.1.gemfile 48 | 49 | - rvm: jruby-9.2.13.0 50 | gemfile: gemfiles/activemodel_3.2.gemfile 51 | - rvm: jruby-9.2.13.0 52 | gemfile: gemfiles/activemodel_4.0.gemfile 53 | 54 | allow_failures: 55 | - rvm: ruby-head 56 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise 'activemodel-3.2' do 4 | gem 'activemodel', '3.2.22.5' 5 | gem 'rack', '1.6.5' 6 | end 7 | 8 | appraise 'activemodel-4.0' do 9 | gem 'activemodel', '4.0.13' 10 | gem 'rack', '1.6.5' 11 | end 12 | 13 | appraise 'activemodel-5.0' do 14 | gem 'activemodel', '5.0.1' 15 | end 16 | 17 | appraise 'activemodel-6.0' do 18 | gem 'activemodel', '6.0.3' 19 | end 20 | 21 | appraise 'activemodel-6.1' do 22 | gem 'activemodel', '6.1.0' 23 | end 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 3.0.0 2 | 3 | * [#32](https://github.com/musaffa/file_validators/pull/32) Removed cocaine/terrapin. Added options for choosing MIME type analyzers with `:tool` option. 4 | * [#40](https://github.com/musaffa/file_validators/pull/40) Added Support for Ruby 3. 5 | * Rubocop style guide 6 | 7 | # 3.0.0.beta2 8 | 9 | * [#32](https://github.com/musaffa/file_validators/pull/32) Removed terrapin. Added options for choosing MIME type analyzers with `:tool` option. 10 | 11 | # 3.0.0.beta1 12 | 13 | * [#29](https://github.com/musaffa/file_validators/pull/29) Upgrade cocaine to terrapin 14 | * Rubocop style guide 15 | 16 | # 2.3.0 17 | 18 | * [#19](https://github.com/musaffa/file_validators/pull/19) Return false with blank size 19 | * [#27](https://github.com/musaffa/file_validators/pull/27) Fix file size validator for ActiveStorage 20 | 21 | # 2.2.0-beta.1 22 | 23 | * [#17](https://github.com/musaffa/file_validators/pull/17) Now Supports multiple file uploads 24 | * As activemodel 3.0 and 3.1 doesn't support `added?` method on the Errors class, the support for both of them have been deprecated in this release. 25 | 26 | # 2.1.0 27 | 28 | * Use autoload for lazy loading of libraries. 29 | * Media type spoof valiation is moved to content type detector. 30 | * `spoofed_file_media_type` message isn't needed anymore. 31 | * Logger info and warning is added. 32 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Declare your gem's dependencies in file_validators.gemspec. 6 | # Bundler will treat runtime dependencies like base dependencies, and 7 | # development dependencies will be added by default to the :development group. 8 | gemspec 9 | 10 | # Declare any dependencies that are still in development here instead of in 11 | # your gemspec. These might include edge Rails or gems from your path or 12 | # Git. Remember to move these dependencies to your gemspec before releasing 13 | # your gem to rubygems.org. 14 | 15 | # To use debugger 16 | # gem 'debugger' 17 | gem 'appraisal' 18 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2014 Ahmad Musaffa Chowdhury 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # File Validators 2 | 3 | [![Gem Version](https://badge.fury.io/rb/file_validators.svg)](http://badge.fury.io/rb/file_validators) 4 | [![Build Status](https://travis-ci.org/musaffa/file_validators.svg)](https://travis-ci.org/musaffa/file_validators) 5 | [![Coverage Status](https://coveralls.io/repos/musaffa/file_validators/badge.png)](https://coveralls.io/r/musaffa/file_validators) 6 | [![Code Climate](https://codeclimate.com/github/musaffa/file_validators/badges/gpa.svg)](https://codeclimate.com/github/musaffa/file_validators) 7 | [![Inline docs](http://inch-ci.org/github/musaffa/file_validators.svg)](http://inch-ci.org/github/musaffa/file_validators) 8 | 9 | File Validators gem adds file size and content type validations to ActiveModel. 10 | Any module that uses ActiveModel, for example ActiveRecord, can use these file validators. 11 | 12 | ## Support 13 | 14 | * ActiveModel versions: 3.2, 4, 5 and 6. 15 | * Rails versions: 3.2, 4, 5 and 6. 16 | 17 | As of version `2.2`, activemodel 3.0 and 3.1 will no longer be supported. 18 | For activemodel 3.0 and 3.1, please use file_validators version `<= 2.1`. 19 | 20 | It has been tested to work with Carrierwave, Paperclip, Dragonfly, Refile etc file uploading solutions. 21 | Validations works both before and after uploads. 22 | 23 | ## Installation 24 | 25 | Add the following to your Gemfile: 26 | 27 | ```ruby 28 | gem 'file_validators' 29 | ``` 30 | 31 | ## Examples 32 | 33 | ActiveModel example: 34 | 35 | ```ruby 36 | class Profile 37 | include ActiveModel::Validations 38 | 39 | attr_accessor :avatar 40 | validates :avatar, file_size: { less_than_or_equal_to: 100.kilobytes }, 41 | file_content_type: { allow: ['image/jpeg', 'image/png'] } 42 | end 43 | ``` 44 | ActiveRecord example: 45 | 46 | ```ruby 47 | class Profile < ActiveRecord::Base 48 | validates :avatar, file_size: { less_than_or_equal_to: 100.kilobytes }, 49 | file_content_type: { allow: ['image/jpeg', 'image/png'] } 50 | end 51 | ``` 52 | 53 | You can also use `:validates_file_size` and `:validates_file_content_type` idioms. 54 | 55 | ## API 56 | 57 | ### File Size Validator: 58 | 59 | * `in`: A range of bytes or a proc that returns a range 60 | ```ruby 61 | validates :avatar, file_size: { in: 100.kilobytes..1.megabyte } 62 | ``` 63 | * `less_than`: Less than a number in bytes or a proc that returns a number 64 | ```ruby 65 | validates :avatar, file_size: { less_than: 2.gigabytes } 66 | ``` 67 | * `less_than_or_equal_to`: Less than or equal to a number in bytes or a proc that returns a number 68 | ```ruby 69 | validates :avatar, file_size: { less_than_or_equal_to: 50.bytes } 70 | ``` 71 | * `greater_than`: greater than a number in bytes or a proc that returns a number 72 | ```ruby 73 | validates :avatar, file_size: { greater_than: 1.byte } 74 | ``` 75 | * `greater_than_or_equal_to`: Greater than or equal to a number in bytes or a proc that returns a number 76 | ```ruby 77 | validates :avatar, file_size: { greater_than_or_equal_to: 50.bytes } 78 | ``` 79 | * `message`: Error message to display. With all the options above except `:in`, you will get `count` as a replacement. 80 | With `:in` you will get `min` and `max` as replacements. 81 | `count`, `min` and `max` each will have its value and unit together. 82 | You can write error messages without using any replacement. 83 | ```ruby 84 | validates :avatar, file_size: { less_than: 100.kilobytes, 85 | message: 'avatar should be less than %{count}' } 86 | ``` 87 | ```ruby 88 | validates :document, file_size: { in: 1.kilobyte..1.megabyte, 89 | message: 'must be within %{min} and %{max}' } 90 | ``` 91 | * `if`: A lambda or name of an instance method. Validation will only be run if this lambda or method returns true. 92 | * `unless`: Same as `if` but validates if lambda or method returns false. 93 | 94 | You can combine different options. 95 | ```ruby 96 | validates :avatar, file_size: { less_than: 1.megabyte, 97 | greater_than_or_equal_to: 20.kilobytes } 98 | ``` 99 | The following two examples are equivalent: 100 | ```ruby 101 | validates :avatar, file_size: { greater_than_or_equal_to: 500.kilobytes, 102 | less_than_or_equal_to: 3.megabytes } 103 | ``` 104 | ```ruby 105 | validates :avatar, file_size: { in: 500.kilobytes..3.megabytes } 106 | ``` 107 | Options can also take `Proc`/`lambda`: 108 | 109 | ```ruby 110 | validates :avatar, file_size: { less_than: lambda { |record| record.size_in_bytes } } 111 | ``` 112 | 113 | ### File Content Type Validator 114 | 115 | * `allow`: Allowed content types. Can be a single content type or an array. Each type can be a String or a Regexp. It also accepts `proc`. Allows all by default. 116 | ```ruby 117 | # string 118 | validates :avatar, file_content_type: { allow: 'image/jpeg' } 119 | ``` 120 | ```ruby 121 | # array of strings 122 | validates :attachment, file_content_type: { allow: ['image/jpeg', 'text/plain'] } 123 | ``` 124 | ```ruby 125 | # regexp 126 | validates :avatar, file_content_type: { allow: /^image\/.*/ } 127 | ``` 128 | ```ruby 129 | # array of regexps 130 | validates :attachment, file_content_type: { allow: [/^image\/.*/, /^text\/.*/] } 131 | ``` 132 | ```ruby 133 | # array of regexps and strings 134 | validates :attachment, file_content_type: { allow: [/^image\/.*/, 'video/mp4'] } 135 | ``` 136 | ```ruby 137 | # proc/lambda example 138 | validates :video, file_content_type: { allow: lambda { |record| record.content_types } } 139 | ``` 140 | * `exclude`: Forbidden content types. Can be a single content type or an array. Each type 141 | can be a String or a Regexp. It also accepts `proc`. See `:allow` options examples. 142 | * `mode`: `:strict` or `:relaxed`. `:strict` mode can detect content type based on the contents 143 | of the files. It also detects media type spoofing (see more in [security](#security)). 144 | `:file` analyzer is used in `:strict` mode. `:relaxed` mode uses file name to detect 145 | the content type. `mime_types` analyzer is used in `relaxed` mode. If mode option is not 146 | set then the validator uses form supplied content type. 147 | * `tool`: `:file`, `:fastimage`, `:filemagic`, `:mimemagic`, `:marcel`, `:mime_types`, `:mini_mime`. 148 | You can choose one of these built-in MIME type analyzers. You have to install the analyzer gem you choose. 149 | By default supplied content type is used to determine the MIME type. This option takes precedence 150 | over `mode` option. 151 | ```ruby 152 | validates :avatar, file_content_type: { allow: 'image/jpeg', mode: :strict } 153 | validates :avatar, file_content_type: { allow: 'image/jpeg', mode: :relaxed } 154 | ``` 155 | * `message`: The message to display when the uploaded file has an invalid content type. 156 | You will get `types` as a replacement. You can write error messages without using any replacement. 157 | ```ruby 158 | validates :avatar, file_content_type: { allow: ['image/jpeg', 'image/gif'], 159 | message: 'only %{types} are allowed' } 160 | ``` 161 | ```ruby 162 | validates :avatar, file_content_type: { allow: ['image/jpeg', 'image/gif'], 163 | message: 'Avatar only allows jpeg and gif' } 164 | ``` 165 | * `if`: A lambda or name of an instance method. Validation will only be run is this lambda or method returns true. 166 | * `unless`: Same as `if` but validates if lambda or method returns false. 167 | 168 | You can combine `:allow` and `:exclude`: 169 | ```ruby 170 | # this will allow all the image types except png and gif 171 | validates :avatar, file_content_type: { allow: /^image\/.*/, exclude: ['image/png', 'image/gif'] } 172 | ``` 173 | 174 | ## Security 175 | 176 | This gem can use Unix file command to get the content type based on the content of the file rather 177 | than the extension. This prevents fake content types inserted in the request header. 178 | 179 | It also prevents file media type spoofing. For example, user may upload a .html document as 180 | a part of the EXIF header of a valid JPEG file. Content type validator will identify its content type 181 | as `image/jpeg` and, without spoof detection, it may pass the validation and be saved as .html document 182 | thus exposing your application to a security vulnerability. Media type spoof detector wont let that happen. 183 | It will not allow a file having `image/jpeg` content type to be saved as `text/plain`. It checks only media 184 | type mismatch, for example `text` of `text/plain` and `image` of `image/jpeg`. So it will not prevent 185 | `image/jpeg` from saving as `image/png` as both have the same `image` media type. 186 | 187 | **note**: This security feature is disabled by default. To enable it, add `mode: :strict` option 188 | in [content type validations](#file-content-type-validator). 189 | `:strict` mode may not work in direct file uploading systems as the file is not passed along with the form. 190 | 191 | ## i18n Translations 192 | 193 | File Size Errors 194 | * `file_size_is_in`: takes `min` and `max` as replacements 195 | * `file_size_is_less_than`: takes `count` as replacement 196 | * `file_size_is_less_than_or_equal_to`: takes `count` as replacement 197 | * `file_size_is_greater_than`: takes `count` as replacement 198 | * `file_size_is_greater_than_or_equal_to`: takes `count` as replacement 199 | 200 | Content Type Errors 201 | * `allowed_file_content_types`: generated when you have specified allowed types but the content type 202 | of the file doesn't match. takes `types` as replacement. 203 | * `excluded_file_content_types`: generated when you have specified excluded types and the content type 204 | of the file matches anyone of them. takes `types` as replacement. 205 | 206 | This gem provides `en` translations for this errors under `errors.messages` namespace. 207 | If you want to override and/or create other locales, you can 208 | check [this](https://github.com/musaffa/file_validators/blob/master/lib/file_validators/locale/en.yml) out to see how translations are done. 209 | 210 | You can override all of them with the `:message` option. 211 | 212 | For unit format, it will use `number.human.storage_units.format` from your locale. 213 | For unit translation, `number.human.storage_units` is used. 214 | Rails applications already have these translations either in ActiveSupport's locale (Rails 4) or in ActionView's locale (Rails 3). 215 | In case your setup doesn't have the translations, here's an example for `en`: 216 | 217 | ```yml 218 | en: 219 | number: 220 | human: 221 | storage_units: 222 | format: "%n %u" 223 | units: 224 | byte: 225 | one: "Byte" 226 | other: "Bytes" 227 | kb: "KB" 228 | mb: "MB" 229 | gb: "GB" 230 | tb: "TB" 231 | ``` 232 | 233 | ## Further Instructions 234 | 235 | If you are using `:strict` or `:relaxed` mode, for content types which are not supported 236 | by mime-types gem, you need to register those content types. For example, you can register 237 | `.docx` in the initializer: 238 | ```Ruby 239 | # config/initializers/mime_types.rb 240 | Mime::Type.register "application/vnd.openxmlformats-officedocument.wordprocessingml.document", :docx 241 | ``` 242 | 243 | If you want to see what content type `:strict` mode returns, run this command in the shell: 244 | ```Shell 245 | $ file -b --mime-type your-file.xxx 246 | ``` 247 | 248 | ## Issues 249 | 250 | **Carrierwave** - You are adding file validators to a model, then you are recommended to keep extension_white_list &/ 251 | extension_black_list in the uploaders (in case you don't have, add that method). 252 | As of this writing (see [issue](https://github.com/carrierwaveuploader/carrierwave/issues/361)), Carrierwave 253 | uploaders start processing a file immediately after its assignment (even before the validators are called). 254 | 255 | ## Tests 256 | 257 | ```Shell 258 | $ rake 259 | $ rake test:unit 260 | $ rake test:integration 261 | $ rubocop 262 | 263 | # test different active model versions 264 | $ bundle exec appraisal install 265 | $ bundle exec appraisal rake 266 | ``` 267 | 268 | ## Problems 269 | 270 | Please use GitHub's [issue tracker](http://github.com/musaffa/file_validations/issues). 271 | 272 | ## Contributing 273 | 274 | 1. Fork it 275 | 2. Create your feature branch (`git checkout -b my-new-feature`) 276 | 3. Commit your changes (`git commit -am 'Added some feature'`) 277 | 4. Push to the branch (`git push origin my-new-feature`) 278 | 5. Create a new Pull Request 279 | 280 | ## Inspirations 281 | 282 | * [PaperClip](https://github.com/thoughtbot/paperclip) 283 | 284 | ## License 285 | 286 | This project rocks and uses MIT-LICENSE. 287 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = FileValidators 2 | 3 | This project rocks and uses MIT-LICENSE. -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require 'bundler/setup' 5 | rescue LoadError 6 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 7 | end 8 | 9 | require 'rspec/core/rake_task' 10 | 11 | namespace :test do 12 | RSpec::Core::RakeTask.new(:unit) do |t| 13 | t.pattern = ['spec/lib/**/*_spec.rb'] 14 | end 15 | 16 | RSpec::Core::RakeTask.new(:integration) do |t| 17 | t.pattern = ['spec/integration/**/*_spec.rb'] 18 | end 19 | end 20 | 21 | task default: ['test:unit', 'test:integration'] 22 | 23 | # require 'rdoc/task' 24 | 25 | # RDoc::Task.new(:rdoc) do |rdoc| 26 | # rdoc.rdoc_dir = 'rdoc' 27 | # rdoc.title = 'FileValidators' 28 | # rdoc.options << '--line-numbers' 29 | # rdoc.rdoc_files.include('README.rdoc') 30 | # rdoc.rdoc_files.include('lib/**/*.rb') 31 | # end 32 | 33 | Bundler::GemHelper.install_tasks 34 | -------------------------------------------------------------------------------- /file_validators.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.push File.expand_path('lib', __dir__) 4 | 5 | require 'file_validators/version' 6 | 7 | Gem::Specification.new do |s| 8 | s.name = 'file_validators' 9 | s.version = FileValidators::VERSION 10 | s.authors = ['Ahmad Musaffa'] 11 | s.email = ['musaffa_csemm@yahoo.com'] 12 | s.summary = 'ActiveModel file validators' 13 | s.description = 'Adds file validators to ActiveModel' 14 | s.homepage = 'https://github.com/musaffa/file_validators' 15 | s.license = 'MIT' 16 | 17 | s.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) 18 | s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) } 19 | s.test_files = s.files.grep(%r{^spec/}) 20 | s.require_paths = ['lib'] 21 | 22 | s.add_dependency 'activemodel', '>= 3.2' 23 | s.add_dependency 'mime-types', '>= 1.0' 24 | 25 | s.add_development_dependency 'coveralls' 26 | s.add_development_dependency 'fastimage' 27 | s.add_development_dependency 'marcel', '~> 0.3' if RUBY_VERSION >= '2.2.0' 28 | s.add_development_dependency 'mimemagic', '>= 0.3.2' 29 | s.add_development_dependency 'mini_mime', '~> 1.0' 30 | s.add_development_dependency 'rack-test' 31 | s.add_development_dependency 'rake' 32 | s.add_development_dependency 'rspec', '~> 3.5.0' 33 | s.add_development_dependency 'rubocop', '~> 0.58.2' 34 | end 35 | -------------------------------------------------------------------------------- /gemfiles/activemodel_3.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "activemodel", "3.2.22.5" 7 | gem "rack", "1.6.5" 8 | 9 | gemspec :path => "../" 10 | -------------------------------------------------------------------------------- /gemfiles/activemodel_4.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "activemodel", "4.0.13" 7 | gem "rack", "1.6.5" 8 | 9 | gemspec :path => "../" 10 | -------------------------------------------------------------------------------- /gemfiles/activemodel_5.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "activemodel", "5.0.1" 7 | 8 | gemspec :path => "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activemodel_6.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "activemodel", "6.0.3" 7 | 8 | gemspec :path => "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activemodel_6.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "activemodel", "6.1.0" 7 | 8 | gemspec :path => "../" 9 | -------------------------------------------------------------------------------- /lib/file_validators.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_model' 4 | require 'ostruct' 5 | 6 | module FileValidators 7 | extend ActiveSupport::Autoload 8 | autoload :Error 9 | autoload :MimeTypeAnalyzer 10 | end 11 | 12 | Dir[File.dirname(__FILE__) + '/file_validators/validators/*.rb'].each { |file| require file } 13 | 14 | locale_path = Dir.glob(File.dirname(__FILE__) + '/file_validators/locale/*.yml') 15 | I18n.load_path += locale_path unless I18n.load_path.include?(locale_path) 16 | -------------------------------------------------------------------------------- /lib/file_validators/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FileValidators 4 | class Error < StandardError 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/file_validators/locale/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | errors: 3 | messages: 4 | file_size_is_in: ! 'file size must be between %{min} and %{max}' 5 | file_size_is_less_than: ! 'file size must be less than %{count}' 6 | file_size_is_less_than_or_equal_to: ! 'file size must be less than or equal to %{count}' 7 | file_size_is_greater_than: ! 'file size must be greater than %{count}' 8 | file_size_is_greater_than_or_equal_to: ! 'file size must be greater than or equal to %{count}' 9 | 10 | allowed_file_content_types: ! 'file should be one of %{types}' 11 | excluded_file_content_types: ! 'file cannot be %{types}' 12 | -------------------------------------------------------------------------------- /lib/file_validators/mime_type_analyzer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Extracted from shrine/plugins/determine_mime_type.rb 4 | module FileValidators 5 | class MimeTypeAnalyzer 6 | SUPPORTED_TOOLS = %i[fastimage file filemagic mimemagic marcel mime_types mini_mime].freeze 7 | MAGIC_NUMBER = 256 * 1024 8 | 9 | def initialize(tool) 10 | raise Error, "unknown mime type analyzer #{tool.inspect}, supported analyzers are: #{SUPPORTED_TOOLS.join(',')}" unless SUPPORTED_TOOLS.include?(tool) 11 | 12 | @tool = tool 13 | end 14 | 15 | def call(io) 16 | mime_type = send(:"extract_with_#{@tool}", io) 17 | io.rewind 18 | 19 | mime_type 20 | end 21 | 22 | private 23 | 24 | def extract_with_file(io) 25 | require 'open3' 26 | 27 | return nil if io.eof? # file command returns "application/x-empty" for empty files 28 | 29 | Open3.popen3(*%W[file --mime-type --brief -]) do |stdin, stdout, stderr, thread| 30 | begin 31 | IO.copy_stream(io, stdin.binmode) 32 | rescue Errno::EPIPE 33 | end 34 | stdin.close 35 | 36 | status = thread.value 37 | 38 | raise Error, "file command failed to spawn: #{stderr.read}" if status.nil? 39 | raise Error, "file command failed: #{stderr.read}" unless status.success? 40 | $stderr.print(stderr.read) 41 | 42 | stdout.read.strip 43 | end 44 | rescue Errno::ENOENT 45 | raise Error, 'file command-line tool is not installed' 46 | end 47 | 48 | def extract_with_fastimage(io) 49 | require 'fastimage' 50 | 51 | type = FastImage.type(io) 52 | "image/#{type}" if type 53 | end 54 | 55 | def extract_with_filemagic(io) 56 | require 'filemagic' 57 | 58 | return nil if io.eof? # FileMagic returns "application/x-empty" for empty files 59 | 60 | FileMagic.open(FileMagic::MAGIC_MIME_TYPE) do |filemagic| 61 | filemagic.buffer(io.read(MAGIC_NUMBER)) 62 | end 63 | end 64 | 65 | def extract_with_mimemagic(io) 66 | require 'mimemagic' 67 | 68 | mime = MimeMagic.by_magic(io) 69 | mime.type if mime 70 | end 71 | 72 | def extract_with_marcel(io) 73 | require 'marcel' 74 | 75 | return nil if io.eof? # marcel returns "application/octet-stream" for empty files 76 | 77 | Marcel::MimeType.for(io) 78 | end 79 | 80 | def extract_with_mime_types(io) 81 | require 'mime/types' 82 | 83 | if filename = extract_filename(io) 84 | mime_type = MIME::Types.of(filename).first 85 | mime_type.content_type if mime_type 86 | end 87 | end 88 | 89 | def extract_with_mini_mime(io) 90 | require 'mini_mime' 91 | 92 | if filename = extract_filename(io) 93 | info = MiniMime.lookup_by_filename(filename) 94 | info.content_type if info 95 | end 96 | end 97 | 98 | def extract_filename(io) 99 | if io.respond_to?(:original_filename) 100 | io.original_filename 101 | elsif io.respond_to?(:path) 102 | File.basename(io.path) 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/file_validators/validators/file_content_type_validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveModel 4 | module Validations 5 | class FileContentTypeValidator < ActiveModel::EachValidator 6 | CHECKS = %i[allow exclude].freeze 7 | SUPPORTED_MODES = { relaxed: :mime_types, strict: :file }.freeze 8 | 9 | def self.helper_method_name 10 | :validates_file_content_type 11 | end 12 | 13 | def validate_each(record, attribute, value) 14 | begin 15 | values = parse_values(value) 16 | rescue JSON::ParserError 17 | record.errors.add attribute, :invalid 18 | return 19 | end 20 | 21 | return if values.empty? 22 | 23 | mode = option_value(record, :mode) 24 | tool = option_value(record, :tool) || SUPPORTED_MODES[mode] 25 | 26 | allowed_types = option_content_types(record, :allow) 27 | forbidden_types = option_content_types(record, :exclude) 28 | 29 | values.each do |val| 30 | content_type = get_content_type(val, tool) 31 | validate_whitelist(record, attribute, content_type, allowed_types) 32 | validate_blacklist(record, attribute, content_type, forbidden_types) 33 | end 34 | end 35 | 36 | def check_validity! 37 | unless (CHECKS & options.keys).present? 38 | raise ArgumentError, 'You must at least pass in :allow or :exclude option' 39 | end 40 | 41 | options.slice(*CHECKS).each do |option, value| 42 | unless value.is_a?(String) || value.is_a?(Array) || value.is_a?(Regexp) || value.is_a?(Proc) 43 | raise ArgumentError, ":#{option} must be a string, an array, a regex or a proc" 44 | end 45 | end 46 | end 47 | 48 | private 49 | 50 | def parse_values(value) 51 | return [] unless value.present? 52 | 53 | value = JSON.parse(value) if value.is_a?(String) 54 | 55 | Array.wrap(value).reject(&:blank?) 56 | end 57 | 58 | def get_content_type(value, tool) 59 | if tool.present? 60 | FileValidators::MimeTypeAnalyzer.new(tool).call(value) 61 | else 62 | value = OpenStruct.new(value) if value.is_a?(Hash) 63 | value.content_type 64 | end 65 | end 66 | 67 | def option_content_types(record, key) 68 | [option_value(record, key)].flatten.compact 69 | end 70 | 71 | def option_value(record, key) 72 | options[key].is_a?(Proc) ? options[key].call(record) : options[key] 73 | end 74 | 75 | def validate_whitelist(record, attribute, content_type, allowed_types) 76 | if allowed_types.present? && allowed_types.none? { |type| type === content_type } 77 | mark_invalid record, attribute, :allowed_file_content_types, allowed_types 78 | end 79 | end 80 | 81 | def validate_blacklist(record, attribute, content_type, forbidden_types) 82 | if forbidden_types.any? { |type| type === content_type } 83 | mark_invalid record, attribute, :excluded_file_content_types, forbidden_types 84 | end 85 | end 86 | 87 | def mark_invalid(record, attribute, error, option_types) 88 | error_options = options.merge(types: option_types.join(', ')) 89 | unless record.errors.added?(attribute, error, error_options) 90 | record.errors.add attribute, error, **error_options 91 | end 92 | end 93 | end 94 | 95 | module HelperMethods 96 | # Places ActiveModel validations on the content type of the file 97 | # assigned. The possible options are: 98 | # * +allow+: Allowed content types. Can be a single content type 99 | # or an array. Each type can be a String or a Regexp. It can also 100 | # be a proc/lambda. It should be noted that Internet Explorer uploads 101 | # files with content_types that you may not expect. For example, 102 | # JPEG images are given image/pjpeg and PNGs are image/x-png, so keep 103 | # that in mind when determining how you match. 104 | # Allows all by default. 105 | # * +exclude+: Forbidden content types. 106 | # * +message+: The message to display when the uploaded file has an invalid 107 | # content type. 108 | # * +mode+: :strict or :relaxed. 109 | # :strict mode validates the content type based on the actual contents 110 | # of the files. Thus it can detect media type spoofing. 111 | # :relaxed validates the content type based on the file name using 112 | # the mime-types gem. It's only for sanity check. 113 | # If mode is not set then it uses form supplied content type. 114 | # * +tool+: :file, :fastimage, :filemagic, :mimemagic, :marcel, :mime_types, :mini_mime 115 | # You can choose a different built-in MIME type analyzer 116 | # By default supplied content type is used to determine the MIME type 117 | # This option have precedence over mode option 118 | # * +if+: A lambda or name of an instance method. Validation will only 119 | # be run is this lambda or method returns true. 120 | # * +unless+: Same as +if+ but validates if lambda or method returns false. 121 | def validates_file_content_type(*attr_names) 122 | validates_with FileContentTypeValidator, _merge_attributes(attr_names) 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/file_validators/validators/file_size_validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveModel 4 | module Validations 5 | class FileSizeValidator < ActiveModel::EachValidator 6 | CHECKS = { in: :===, 7 | less_than: :<, 8 | less_than_or_equal_to: :<=, 9 | greater_than: :>, 10 | greater_than_or_equal_to: :>= }.freeze 11 | 12 | def self.helper_method_name 13 | :validates_file_size 14 | end 15 | 16 | def validate_each(record, attribute, value) 17 | begin 18 | values = parse_values(value) 19 | rescue JSON::ParserError 20 | record.errors.add attribute, :invalid 21 | return 22 | end 23 | 24 | return if values.empty? 25 | 26 | options.slice(*CHECKS.keys).each do |option, option_value| 27 | check_errors(record, attribute, values, option, option_value) 28 | end 29 | end 30 | 31 | def check_validity! 32 | unless (CHECKS.keys & options.keys).present? 33 | raise ArgumentError, 'You must at least pass in one of these options' \ 34 | ' - :in, :less_than, :less_than_or_equal_to,' \ 35 | ' :greater_than and :greater_than_or_equal_to' 36 | end 37 | 38 | check_options(Numeric, options.slice(*(CHECKS.keys - [:in]))) 39 | check_options(Range, options.slice(:in)) 40 | end 41 | 42 | private 43 | 44 | def parse_values(value) 45 | return [] unless value.present? 46 | 47 | value = JSON.parse(value) if value.is_a?(String) 48 | return [] unless value.present? 49 | 50 | value = OpenStruct.new(value) if value.is_a?(Hash) 51 | 52 | Array.wrap(value).reject(&:blank?) 53 | end 54 | 55 | def check_options(klass, options) 56 | options.each do |option, value| 57 | unless value.is_a?(klass) || value.is_a?(Proc) 58 | raise ArgumentError, ":#{option} must be a #{klass.name.to_s.downcase} or a proc" 59 | end 60 | end 61 | end 62 | 63 | def check_errors(record, attribute, values, option, option_value) 64 | option_value = option_value.call(record) if option_value.is_a?(Proc) 65 | has_invalid_size = values.any? { |v| !valid_size?(value_byte_size(v), option, option_value) } 66 | if has_invalid_size 67 | record.errors.add( 68 | attribute, 69 | "file_size_is_#{option}".to_sym, 70 | **filtered_options(values).merge!(detect_error_options(option_value)) 71 | ) 72 | end 73 | end 74 | 75 | def value_byte_size(value) 76 | if value.respond_to?(:byte_size) 77 | value.byte_size 78 | else 79 | value.size 80 | end 81 | end 82 | 83 | def valid_size?(size, option, option_value) 84 | return false if size.nil? 85 | if option_value.is_a?(Range) 86 | option_value.send(CHECKS[option], size) 87 | else 88 | size.send(CHECKS[option], option_value) 89 | end 90 | end 91 | 92 | def filtered_options(value) 93 | filtered = options.except(*CHECKS.keys) 94 | filtered[:value] = value 95 | filtered 96 | end 97 | 98 | def detect_error_options(option_value) 99 | if option_value.is_a?(Range) 100 | { min: human_size(option_value.min), max: human_size(option_value.max) } 101 | else 102 | { count: human_size(option_value) } 103 | end 104 | end 105 | 106 | def human_size(size) 107 | if defined?(ActiveSupport::NumberHelper) # Rails 4.0+ 108 | ActiveSupport::NumberHelper.number_to_human_size(size) 109 | else 110 | storage_units_format = I18n.translate( 111 | :'number.human.storage_units.format', 112 | locale: options[:locale], 113 | raise: true 114 | ) 115 | 116 | unit = I18n.translate( 117 | :'number.human.storage_units.units.byte', 118 | locale: options[:locale], 119 | count: size.to_i, 120 | raise: true 121 | ) 122 | 123 | storage_units_format.gsub(/%n/, size.to_i.to_s).gsub(/%u/, unit).html_safe 124 | end 125 | end 126 | end 127 | 128 | module HelperMethods 129 | # Places ActiveModel validations on the size of the file assigned. The 130 | # possible options are: 131 | # * +in+: a Range of bytes (i.e. +1..1.megabyte+), 132 | # * +less_than_or_equal_to+: equivalent to :in => 0..options[:less_than_or_equal_to] 133 | # * +greater_than_or_equal_to+: equivalent to :in => options[:greater_than_or_equal_to]..Infinity 134 | # * +less_than+: less than a number in bytes 135 | # * +greater_than+: greater than a number in bytes 136 | # * +message+: error message to display, use :min and :max as replacements 137 | # * +if+: A lambda or name of an instance method. Validation will only 138 | # be run if this lambda or method returns true. 139 | # * +unless+: Same as +if+ but validates if lambda or method returns false. 140 | def validates_file_size(*attr_names) 141 | validates_with FileSizeValidator, _merge_attributes(attr_names) 142 | end 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /lib/file_validators/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FileValidators 4 | VERSION = '3.0.0' 5 | end 6 | -------------------------------------------------------------------------------- /spec/fixtures/chubby_bubble.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musaffa/file_validators/55b8c7fb8aade95a51fc0ad913ee4e8313caf3fa/spec/fixtures/chubby_bubble.jpg -------------------------------------------------------------------------------- /spec/fixtures/chubby_cute.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musaffa/file_validators/55b8c7fb8aade95a51fc0ad913ee4e8313caf3fa/spec/fixtures/chubby_cute.png -------------------------------------------------------------------------------- /spec/fixtures/cute.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musaffa/file_validators/55b8c7fb8aade95a51fc0ad913ee4e8313caf3fa/spec/fixtures/cute.jpg -------------------------------------------------------------------------------- /spec/fixtures/sample.txt: -------------------------------------------------------------------------------- 1 | a sample text -------------------------------------------------------------------------------- /spec/fixtures/spoofed.jpg: -------------------------------------------------------------------------------- 1 | a sample text -------------------------------------------------------------------------------- /spec/integration/combined_validators_integration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'rack/test/uploaded_file' 5 | 6 | describe 'Combined File Validators integration with ActiveModel' do 7 | class Person 8 | include ActiveModel::Validations 9 | attr_accessor :avatar 10 | end 11 | 12 | before :all do 13 | @cute_path = File.join(File.dirname(__FILE__), '../fixtures/cute.jpg') 14 | @chubby_bubble_path = File.join(File.dirname(__FILE__), '../fixtures/chubby_bubble.jpg') 15 | @chubby_cute_path = File.join(File.dirname(__FILE__), '../fixtures/chubby_cute.png') 16 | @sample_text_path = File.join(File.dirname(__FILE__), '../fixtures/sample.txt') 17 | end 18 | 19 | context 'without helpers' do 20 | before :all do 21 | Person.class_eval do 22 | Person.reset_callbacks(:validate) 23 | validates :avatar, file_size: { less_than: 20.kilobytes }, 24 | file_content_type: { allow: 'image/jpeg' } 25 | end 26 | end 27 | 28 | subject { Person.new } 29 | 30 | context 'with an allowed type' do 31 | before { subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') } 32 | it { is_expected.to be_valid } 33 | end 34 | 35 | context 'with a disallowed type' do 36 | it 'invalidates jpeg image file having size bigger than the allowed size' do 37 | subject.avatar = Rack::Test::UploadedFile.new(@chubby_bubble_path, 'image/jpeg') 38 | expect(subject).not_to be_valid 39 | end 40 | 41 | it 'invalidates png image file' do 42 | subject.avatar = Rack::Test::UploadedFile.new(@chubby_cute_path, 'image/png') 43 | expect(subject).not_to be_valid 44 | end 45 | 46 | it 'invalidates text file' do 47 | subject.avatar = Rack::Test::UploadedFile.new(@sample_text_path, 'text/plain') 48 | expect(subject).not_to be_valid 49 | end 50 | end 51 | end 52 | 53 | context 'with helpers' do 54 | before :all do 55 | Person.class_eval do 56 | Person.reset_callbacks(:validate) 57 | validates_file_size :avatar, less_than: 20.kilobytes 58 | validates_file_content_type :avatar, allow: 'image/jpeg' 59 | end 60 | end 61 | 62 | subject { Person.new } 63 | 64 | context 'with an allowed type' do 65 | before { subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') } 66 | it { is_expected.to be_valid } 67 | end 68 | 69 | context 'with a disallowed type' do 70 | it 'invalidates jpeg image file having size bigger than the allowed size' do 71 | subject.avatar = Rack::Test::UploadedFile.new(@chubby_bubble_path, 'image/jpeg') 72 | expect(subject).not_to be_valid 73 | end 74 | 75 | it 'invalidates png image file' do 76 | subject.avatar = Rack::Test::UploadedFile.new(@chubby_cute_path, 'image/png') 77 | expect(subject).not_to be_valid 78 | end 79 | 80 | it 'invalidates text file' do 81 | subject.avatar = Rack::Test::UploadedFile.new(@sample_text_path, 'text/plain') 82 | expect(subject).not_to be_valid 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/integration/file_content_type_validation_integration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'rack/test/uploaded_file' 5 | 6 | describe 'File Content Type integration with ActiveModel' do 7 | class Person 8 | include ActiveModel::Validations 9 | attr_accessor :avatar 10 | end 11 | 12 | before :all do 13 | @cute_path = File.join(File.dirname(__FILE__), '../fixtures/cute.jpg') 14 | @chubby_bubble_path = File.join(File.dirname(__FILE__), '../fixtures/chubby_bubble.jpg') 15 | @chubby_cute_path = File.join(File.dirname(__FILE__), '../fixtures/chubby_cute.png') 16 | @sample_text_path = File.join(File.dirname(__FILE__), '../fixtures/sample.txt') 17 | @spoofed_file_path = File.join(File.dirname(__FILE__), '../fixtures/spoofed.jpg') 18 | end 19 | 20 | context ':allow option' do 21 | context 'a string' do 22 | before :all do 23 | Person.class_eval do 24 | Person.reset_callbacks(:validate) 25 | validates :avatar, file_content_type: { allow: 'image/jpeg' } 26 | end 27 | end 28 | 29 | subject { Person.new } 30 | 31 | context 'with an allowed type' do 32 | before { subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') } 33 | it { is_expected.to be_valid } 34 | end 35 | 36 | context 'with a disallowed type' do 37 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_cute_path, 'image/png') } 38 | it { is_expected.not_to be_valid } 39 | end 40 | end 41 | 42 | context 'as a regex' do 43 | before :all do 44 | Person.class_eval do 45 | Person.reset_callbacks(:validate) 46 | validates :avatar, file_content_type: { allow: /^image\/.*/, mode: :strict } 47 | end 48 | end 49 | 50 | subject { Person.new } 51 | 52 | context 'with an allowed types' do 53 | it 'validates jpeg image file' do 54 | subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') 55 | expect(subject).to be_valid 56 | end 57 | 58 | it 'validates png image file' do 59 | subject.avatar = Rack::Test::UploadedFile.new(@chubby_cute_path, 'image/png') 60 | expect(subject).to be_valid 61 | end 62 | end 63 | 64 | context 'with a disallowed type' do 65 | before { subject.avatar = Rack::Test::UploadedFile.new(@sample_text_path, 'text/plain') } 66 | it { is_expected.not_to be_valid } 67 | end 68 | end 69 | 70 | context 'as a list' do 71 | before :all do 72 | Person.class_eval do 73 | Person.reset_callbacks(:validate) 74 | validates :avatar, file_content_type: { allow: ['image/jpeg', 'text/plain'], 75 | mode: :strict } 76 | end 77 | end 78 | 79 | subject { Person.new } 80 | 81 | context 'with allowed types' do 82 | it 'validates jpeg' do 83 | subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') 84 | expect(subject).to be_valid 85 | end 86 | 87 | it 'validates text file' do 88 | subject.avatar = Rack::Test::UploadedFile.new(@sample_text_path, 'text/plain') 89 | expect(subject).to be_valid 90 | end 91 | end 92 | 93 | context 'with a disallowed type' do 94 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_cute_path, 'image/png') } 95 | it { is_expected.not_to be_valid } 96 | end 97 | end 98 | 99 | context 'as a proc' do 100 | before :all do 101 | Person.class_eval do 102 | Person.reset_callbacks(:validate) 103 | validates :avatar, file_content_type: { allow: ->(_record) { ['image/jpeg', 'text/plain'] }, 104 | mode: :strict } 105 | end 106 | end 107 | 108 | subject { Person.new } 109 | 110 | context 'with allowed types' do 111 | it 'validates jpeg' do 112 | subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') 113 | expect(subject).to be_valid 114 | end 115 | 116 | it 'validates text file' do 117 | subject.avatar = Rack::Test::UploadedFile.new(@sample_text_path, 'text/plain') 118 | expect(subject).to be_valid 119 | end 120 | end 121 | 122 | context 'with a disallowed type' do 123 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_cute_path, 'image/png') } 124 | it { is_expected.not_to be_valid } 125 | end 126 | end 127 | end 128 | 129 | context ':exclude option' do 130 | context 'a string' do 131 | before :all do 132 | Person.class_eval do 133 | Person.reset_callbacks(:validate) 134 | validates :avatar, file_content_type: { exclude: 'image/jpeg', mode: :strict } 135 | end 136 | end 137 | 138 | subject { Person.new } 139 | 140 | context 'with an allowed type' do 141 | before { subject.avatar = Rack::Test::UploadedFile.new(@sample_text_path, 'text/plain') } 142 | it { is_expected.to be_valid } 143 | end 144 | 145 | context 'with a disallowed type' do 146 | before { subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') } 147 | it { is_expected.not_to be_valid } 148 | end 149 | end 150 | 151 | context 'as a regex' do 152 | before :all do 153 | Person.class_eval do 154 | Person.reset_callbacks(:validate) 155 | validates :avatar, file_content_type: { exclude: /^image\/.*/, mode: :strict } 156 | end 157 | end 158 | 159 | subject { Person.new } 160 | 161 | context 'with an allowed type' do 162 | before { subject.avatar = Rack::Test::UploadedFile.new(@sample_text_path, 'text/plain') } 163 | it { is_expected.to be_valid } 164 | end 165 | 166 | context 'with a disallowed types' do 167 | it 'invalidates jpeg image file' do 168 | subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') 169 | expect(subject).not_to be_valid 170 | end 171 | 172 | it 'invalidates png image file' do 173 | subject.avatar = Rack::Test::UploadedFile.new(@chubby_cute_path, 'image/png') 174 | expect(subject).not_to be_valid 175 | end 176 | end 177 | end 178 | 179 | context 'as a list' do 180 | before :all do 181 | Person.class_eval do 182 | Person.reset_callbacks(:validate) 183 | validates :avatar, file_content_type: { exclude: ['image/jpeg', 'text/plain'], 184 | mode: :strict } 185 | end 186 | end 187 | 188 | subject { Person.new } 189 | 190 | context 'with an allowed type' do 191 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_cute_path, 'image/png') } 192 | it { is_expected.to be_valid } 193 | end 194 | 195 | context 'with a disallowed types' do 196 | it 'invalidates jpeg' do 197 | subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') 198 | expect(subject).not_to be_valid 199 | end 200 | 201 | it 'invalidates text file' do 202 | subject.avatar = Rack::Test::UploadedFile.new(@sample_text_path, 'text/plain') 203 | expect(subject).not_to be_valid 204 | end 205 | end 206 | end 207 | 208 | context 'as a proc' do 209 | before :all do 210 | Person.class_eval do 211 | Person.reset_callbacks(:validate) 212 | validates :avatar, file_content_type: { exclude: ->(_record) { /^image\/.*/ }, 213 | mode: :strict } 214 | end 215 | end 216 | 217 | subject { Person.new } 218 | 219 | context 'with an allowed type' do 220 | before { subject.avatar = Rack::Test::UploadedFile.new(@sample_text_path, 'text/plain') } 221 | it { is_expected.to be_valid } 222 | end 223 | 224 | context 'with a disallowed types' do 225 | it 'invalidates jpeg image file' do 226 | subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') 227 | expect(subject).not_to be_valid 228 | end 229 | end 230 | end 231 | end 232 | 233 | context ':allow and :exclude combined' do 234 | before :all do 235 | Person.class_eval do 236 | Person.reset_callbacks(:validate) 237 | validates :avatar, file_content_type: { allow: /^image\/.*/, exclude: 'image/png', 238 | mode: :strict } 239 | end 240 | end 241 | 242 | subject { Person.new } 243 | 244 | context 'with an allowed type' do 245 | before { subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') } 246 | it { is_expected.to be_valid } 247 | end 248 | 249 | context 'with a disallowed type' do 250 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_cute_path, 'image/png') } 251 | it { is_expected.not_to be_valid } 252 | end 253 | end 254 | 255 | context ':tool option' do 256 | before :all do 257 | Person.class_eval do 258 | Person.reset_callbacks(:validate) 259 | validates :avatar, file_content_type: { allow: 'image/jpeg', tool: :marcel } 260 | end 261 | end 262 | 263 | subject { Person.new } 264 | 265 | context 'with valid file' do 266 | it 'validates the file' do 267 | subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') 268 | expect(subject).to be_valid 269 | end 270 | end 271 | 272 | context 'with spoofed file' do 273 | it 'invalidates the file' do 274 | subject.avatar = Rack::Test::UploadedFile.new(@spoofed_file_path, 'image/jpeg') 275 | expect(subject).not_to be_valid 276 | end 277 | end 278 | end 279 | 280 | context ':mode option' do 281 | context 'strict mode' do 282 | before :all do 283 | Person.class_eval do 284 | Person.reset_callbacks(:validate) 285 | validates :avatar, file_content_type: { allow: 'image/jpeg', mode: :strict } 286 | end 287 | end 288 | 289 | subject { Person.new } 290 | 291 | context 'with valid file' do 292 | it 'validates the file' do 293 | subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') 294 | expect(subject).to be_valid 295 | end 296 | end 297 | 298 | context 'with spoofed file' do 299 | it 'invalidates the file' do 300 | subject.avatar = Rack::Test::UploadedFile.new(@spoofed_file_path, 'image/jpeg') 301 | expect(subject).not_to be_valid 302 | end 303 | end 304 | end 305 | 306 | context 'relaxed mode' do 307 | before :all do 308 | Person.class_eval do 309 | Person.reset_callbacks(:validate) 310 | validates :avatar, file_content_type: { allow: 'image/jpeg', mode: :relaxed } 311 | end 312 | end 313 | 314 | subject { Person.new } 315 | 316 | context 'with valid file' do 317 | it 'validates the file' do 318 | subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') 319 | expect(subject).to be_valid 320 | end 321 | end 322 | 323 | context 'with spoofed file' do 324 | it 'validates the file' do 325 | subject.avatar = Rack::Test::UploadedFile.new(@spoofed_file_path, 'image/jpeg') 326 | expect(subject).to be_valid 327 | end 328 | end 329 | end 330 | 331 | context 'default mode' do 332 | before :all do 333 | Person.class_eval do 334 | Person.reset_callbacks(:validate) 335 | validates :avatar, file_content_type: { allow: 'image/jpeg' } 336 | end 337 | end 338 | 339 | subject { Person.new } 340 | 341 | context 'with valid file' do 342 | it 'validates the file' do 343 | subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') 344 | expect(subject).to be_valid 345 | end 346 | end 347 | 348 | context 'with spoofed file' do 349 | it 'invalidates the file' do 350 | subject.avatar = Rack::Test::UploadedFile.new(@spoofed_file_path, 'image/jpeg') 351 | expect(subject).to be_valid 352 | end 353 | end 354 | end 355 | end 356 | 357 | context 'image data as json string' do 358 | before :all do 359 | Person.class_eval do 360 | Person.reset_callbacks(:validate) 361 | validates :avatar, file_content_type: { allow: 'image/jpeg' } 362 | end 363 | end 364 | 365 | subject { Person.new } 366 | 367 | context 'for invalid content type' do 368 | before do 369 | subject.avatar = '{"filename":"img140910_88338.GIF","content_type":"image/gif","size":13150}' 370 | end 371 | 372 | it { is_expected.not_to be_valid } 373 | end 374 | 375 | context 'for valid content type' do 376 | before do 377 | subject.avatar = '{"filename":"img140910_88338.jpg","content_type":"image/jpeg","size":13150}' 378 | end 379 | 380 | it { is_expected.to be_valid } 381 | end 382 | 383 | context 'empty json string' do 384 | before { subject.avatar = '{}' } 385 | it { is_expected.to be_valid } 386 | end 387 | 388 | context 'empty string' do 389 | before { subject.avatar = '' } 390 | it { is_expected.to be_valid } 391 | end 392 | 393 | context 'invalid json string' do 394 | before { subject.avatar = '{filename":"img140910_88338.jpg","content_type":"image/jpeg","size":13150}' } 395 | it { is_expected.not_to be_valid } 396 | end 397 | end 398 | 399 | context 'image data as hash' do 400 | before :all do 401 | Person.class_eval do 402 | Person.reset_callbacks(:validate) 403 | validates :avatar, file_content_type: { allow: 'image/jpeg' } 404 | end 405 | end 406 | 407 | subject { Person.new } 408 | 409 | context 'for invalid content type' do 410 | before do 411 | subject.avatar = { 412 | 'filename' => 'img140910_88338.GIF', 413 | 'content_type' => 'image/gif', 414 | 'size' => 13_150 415 | } 416 | end 417 | 418 | it { is_expected.not_to be_valid } 419 | end 420 | 421 | context 'for valid content type' do 422 | before do 423 | subject.avatar = { 424 | 'filename' => 'img140910_88338.jpg', 425 | 'content_type' => 'image/jpeg', 426 | 'size' => 13_150 427 | } 428 | end 429 | 430 | it { is_expected.to be_valid } 431 | end 432 | 433 | context 'empty hash' do 434 | before { subject.avatar = {} } 435 | it { is_expected.to be_valid } 436 | end 437 | end 438 | 439 | context 'image data as array' do 440 | before :all do 441 | Person.class_eval do 442 | Person.reset_callbacks(:validate) 443 | validates :avatar, file_content_type: { allow: 'image/jpeg' } 444 | end 445 | end 446 | 447 | subject { Person.new } 448 | 449 | context 'for one invalid content type' do 450 | before do 451 | subject.avatar = [ 452 | Rack::Test::UploadedFile.new(@sample_text_path, 'text/plain'), 453 | Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') 454 | ] 455 | end 456 | it { is_expected.not_to be_valid } 457 | end 458 | 459 | context 'for two invalid content types' do 460 | before do 461 | subject.avatar = [ 462 | Rack::Test::UploadedFile.new(@sample_text_path, 'text/plain'), 463 | Rack::Test::UploadedFile.new(@sample_text_path, 'text/plain') 464 | ] 465 | end 466 | 467 | it 'is invalid and adds just one error' do 468 | expect(subject).not_to be_valid 469 | expect(subject.errors.count).to eq 1 470 | end 471 | end 472 | 473 | context 'for valid content type' do 474 | before do 475 | subject.avatar = [ 476 | Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg'), 477 | Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') 478 | ] 479 | end 480 | it { is_expected.to be_valid } 481 | end 482 | 483 | context 'empty array' do 484 | before { subject.avatar = [] } 485 | it { is_expected.to be_valid } 486 | end 487 | end 488 | end 489 | -------------------------------------------------------------------------------- /spec/integration/file_size_validator_integration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'rack/test/uploaded_file' 5 | 6 | describe 'File Size Validator integration with ActiveModel' do 7 | class Person 8 | include ActiveModel::Validations 9 | attr_accessor :avatar 10 | end 11 | 12 | before :all do 13 | @cute_path = File.join(File.dirname(__FILE__), '../fixtures/cute.jpg') 14 | @chubby_bubble_path = File.join(File.dirname(__FILE__), '../fixtures/chubby_bubble.jpg') 15 | @chubby_cute_path = File.join(File.dirname(__FILE__), '../fixtures/chubby_cute.png') 16 | end 17 | 18 | context ':in option' do 19 | context 'as a range' do 20 | before :all do 21 | Person.class_eval do 22 | Person.reset_callbacks(:validate) 23 | validates :avatar, file_size: { in: 20.kilobytes..40.kilobytes } 24 | end 25 | end 26 | 27 | subject { Person.new } 28 | 29 | context 'when file size is out of range' do 30 | before { subject.avatar = Rack::Test::UploadedFile.new(@cute_path) } 31 | it { is_expected.not_to be_valid } 32 | end 33 | 34 | context 'when file size is out of range' do 35 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_cute_path) } 36 | it { is_expected.not_to be_valid } 37 | end 38 | 39 | context 'when file size within range' do 40 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_bubble_path) } 41 | it { is_expected.to be_valid } 42 | end 43 | end 44 | 45 | context 'as a proc' do 46 | before :all do 47 | Person.class_eval do 48 | Person.reset_callbacks(:validate) 49 | validates :avatar, file_size: { in: ->(_record) { 20.kilobytes..40.kilobytes } } 50 | end 51 | end 52 | 53 | subject { Person.new } 54 | 55 | context 'when file size is out of range' do 56 | before { subject.avatar = Rack::Test::UploadedFile.new(@cute_path) } 57 | it { is_expected.not_to be_valid } 58 | end 59 | 60 | context 'when file size is out of range' do 61 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_cute_path) } 62 | it { is_expected.not_to be_valid } 63 | end 64 | 65 | context 'when file size within range' do 66 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_bubble_path) } 67 | it { is_expected.to be_valid } 68 | end 69 | end 70 | end 71 | 72 | context ':greater_than and :less_than option' do 73 | context 'as numbers' do 74 | before :all do 75 | Person.class_eval do 76 | Person.reset_callbacks(:validate) 77 | validates :avatar, file_size: { greater_than: 20.kilobytes, 78 | less_than: 40.kilobytes } 79 | end 80 | end 81 | 82 | subject { Person.new } 83 | 84 | context 'when file size is out of range' do 85 | before { subject.avatar = Rack::Test::UploadedFile.new(@cute_path) } 86 | it { is_expected.not_to be_valid } 87 | end 88 | 89 | context 'when file size is out of range' do 90 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_cute_path) } 91 | it { is_expected.not_to be_valid } 92 | end 93 | 94 | context 'when file size within range' do 95 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_bubble_path) } 96 | it { is_expected.to be_valid } 97 | end 98 | end 99 | 100 | context 'as procs' do 101 | before :all do 102 | Person.class_eval do 103 | Person.reset_callbacks(:validate) 104 | validates :avatar, file_size: { greater_than: ->(_record) { 20.kilobytes }, 105 | less_than: ->(_record) { 40.kilobytes } } 106 | end 107 | end 108 | 109 | subject { Person.new } 110 | 111 | context 'when file size is out of range' do 112 | before { subject.avatar = Rack::Test::UploadedFile.new(@cute_path) } 113 | it { is_expected.not_to be_valid } 114 | end 115 | 116 | context 'when file size is out of range' do 117 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_cute_path) } 118 | it { is_expected.not_to be_valid } 119 | end 120 | 121 | context 'when file size within range' do 122 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_bubble_path) } 123 | it { is_expected.to be_valid } 124 | end 125 | end 126 | end 127 | 128 | context ':less_than_or_equal_to option' do 129 | before :all do 130 | Person.class_eval do 131 | Person.reset_callbacks(:validate) 132 | validates :avatar, file_size: { less_than_or_equal_to: 20.kilobytes } 133 | end 134 | end 135 | 136 | subject { Person.new } 137 | 138 | context 'when file size is greater than the specified size' do 139 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_bubble_path) } 140 | it { is_expected.not_to be_valid } 141 | end 142 | 143 | context 'when file size within the specified size' do 144 | before { subject.avatar = Rack::Test::UploadedFile.new(@cute_path) } 145 | it { is_expected.to be_valid } 146 | end 147 | end 148 | 149 | context ':greater_than_or_equal_to option' do 150 | before :all do 151 | Person.class_eval do 152 | Person.reset_callbacks(:validate) 153 | validates :avatar, file_size: { greater_than_or_equal_to: 20.kilobytes } 154 | end 155 | end 156 | 157 | subject { Person.new } 158 | 159 | context 'when file size is less than the specified size' do 160 | before { subject.avatar = Rack::Test::UploadedFile.new(@cute_path) } 161 | it { is_expected.not_to be_valid } 162 | end 163 | 164 | context 'when file size within the specified size' do 165 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_bubble_path) } 166 | it { is_expected.to be_valid } 167 | end 168 | end 169 | 170 | context ':less_than option' do 171 | before :all do 172 | Person.class_eval do 173 | Person.reset_callbacks(:validate) 174 | validates :avatar, file_size: { less_than: 20.kilobytes } 175 | end 176 | end 177 | 178 | subject { Person.new } 179 | 180 | context 'when file size is greater than the specified size' do 181 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_bubble_path) } 182 | it { is_expected.not_to be_valid } 183 | end 184 | 185 | context 'when file size within the specified size' do 186 | before { subject.avatar = Rack::Test::UploadedFile.new(@cute_path) } 187 | it { is_expected.to be_valid } 188 | end 189 | end 190 | 191 | context ':greater_than option' do 192 | before :all do 193 | Person.class_eval do 194 | Person.reset_callbacks(:validate) 195 | validates :avatar, file_size: { greater_than: 20.kilobytes } 196 | end 197 | end 198 | 199 | subject { Person.new } 200 | 201 | context 'when file size is less than the specified size' do 202 | before { subject.avatar = Rack::Test::UploadedFile.new(@cute_path) } 203 | it { is_expected.not_to be_valid } 204 | end 205 | 206 | context 'when file size within the specified size' do 207 | before { subject.avatar = Rack::Test::UploadedFile.new(@chubby_bubble_path) } 208 | it { is_expected.to be_valid } 209 | end 210 | end 211 | 212 | context 'image data as json string' do 213 | before :all do 214 | Person.class_eval do 215 | Person.reset_callbacks(:validate) 216 | validates :avatar, file_size: { greater_than: 20.kilobytes } 217 | end 218 | end 219 | 220 | subject { Person.new } 221 | 222 | context 'when file size is less than the specified size' do 223 | before do 224 | subject.avatar = '{"filename":"img140910_88338.GIF","content_type":"image/gif","size":13150}' 225 | end 226 | 227 | it { is_expected.not_to be_valid } 228 | end 229 | 230 | context 'when file size within the specified size' do 231 | before do 232 | subject.avatar = '{"filename":"img140910_88338.GIF","content_type":"image/gif","size":33150}' 233 | end 234 | 235 | it { is_expected.to be_valid } 236 | end 237 | 238 | context 'empty json string' do 239 | before { subject.avatar = '{}' } 240 | it { is_expected.to be_valid } 241 | end 242 | 243 | context 'empty string' do 244 | before { subject.avatar = '' } 245 | it { is_expected.to be_valid } 246 | end 247 | 248 | context 'invalid json string' do 249 | before { subject.avatar = '{filename":"img140910_88338.GIF","content_type":"image/gif","size":33150}' } 250 | it { is_expected.not_to be_valid } 251 | end 252 | end 253 | 254 | context 'image data as hash' do 255 | before :all do 256 | Person.class_eval do 257 | Person.reset_callbacks(:validate) 258 | validates :avatar, file_size: { greater_than: 20.kilobytes } 259 | end 260 | end 261 | 262 | subject { Person.new } 263 | 264 | context 'when file size is less than the specified size' do 265 | before do 266 | subject.avatar = { 267 | 'filename' => 'img140910_88338.GIF', 268 | 'content_type' => 'image/gif', 269 | 'size' => 13_150 270 | } 271 | end 272 | 273 | it { is_expected.not_to be_valid } 274 | end 275 | 276 | context 'when file size within the specified size' do 277 | before do 278 | subject.avatar = { 279 | 'filename' => 'img140910_88338.GIF', 280 | 'content_type' => 'image/gif', 281 | 'size' => 33_150 282 | } 283 | end 284 | 285 | it { is_expected.to be_valid } 286 | end 287 | 288 | context 'empty hash' do 289 | before { subject.avatar = {} } 290 | it { is_expected.to be_valid } 291 | end 292 | end 293 | 294 | context 'image data as array' do 295 | before :all do 296 | Person.class_eval do 297 | Person.reset_callbacks(:validate) 298 | validates :avatar, file_size: { greater_than: 20.kilobytes } 299 | end 300 | end 301 | 302 | subject { Person.new } 303 | 304 | context 'when size of one file is less than the specified size' do 305 | before do 306 | subject.avatar = [ 307 | Rack::Test::UploadedFile.new(@cute_path), 308 | Rack::Test::UploadedFile.new(@chubby_bubble_path) 309 | ] 310 | end 311 | it { is_expected.not_to be_valid } 312 | end 313 | 314 | context 'when size of all files is within the specified size' do 315 | before do 316 | subject.avatar = [ 317 | Rack::Test::UploadedFile.new(@cute_path), 318 | Rack::Test::UploadedFile.new(@cute_path) 319 | ] 320 | end 321 | 322 | it 'is invalid and adds just one error' do 323 | expect(subject).not_to be_valid 324 | expect(subject.errors.count).to eq 1 325 | end 326 | end 327 | 328 | context 'when size of all files is less than the specified size' do 329 | before do 330 | subject.avatar = [ 331 | Rack::Test::UploadedFile.new(@chubby_bubble_path), 332 | Rack::Test::UploadedFile.new(@chubby_bubble_path) 333 | ] 334 | end 335 | 336 | it { is_expected.to be_valid } 337 | end 338 | 339 | context 'one file' do 340 | context 'when file size is out of range' do 341 | before { subject.avatar = [Rack::Test::UploadedFile.new(@cute_path)] } 342 | it { is_expected.not_to be_valid } 343 | end 344 | 345 | context 'when file size within range' do 346 | before { subject.avatar = [Rack::Test::UploadedFile.new(@chubby_bubble_path)] } 347 | it { is_expected.to be_valid } 348 | end 349 | end 350 | 351 | context 'empty array' do 352 | before { subject.avatar = [] } 353 | it { is_expected.to be_valid } 354 | end 355 | end 356 | end 357 | -------------------------------------------------------------------------------- /spec/lib/file_validators/mime_type_analyzer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'rack/test/uploaded_file' 5 | 6 | describe FileValidators::MimeTypeAnalyzer do 7 | it 'rises error when tool is invalid' do 8 | expect { described_class.new(:invalid) }.to raise_error(FileValidators::Error) 9 | end 10 | 11 | before :all do 12 | @cute_path = File.join(File.dirname(__FILE__), '../../fixtures/cute.jpg') 13 | @spoofed_file_path = File.join(File.dirname(__FILE__), '../../fixtures/spoofed.jpg') 14 | end 15 | 16 | let(:cute_image) { Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') } 17 | let(:spoofed_file) { Rack::Test::UploadedFile.new(@spoofed_file_path, 'image/jpeg') } 18 | 19 | describe ':file analyzer' do 20 | let(:analyzer) { described_class.new(:file) } 21 | 22 | it 'determines MIME type from file contents' do 23 | expect(analyzer.call(cute_image)).to eq('image/jpeg') 24 | end 25 | 26 | it 'returns text/plain for unidentified MIME types' do 27 | expect(analyzer.call(fakeio('a' * 5 * 1024 * 1024))).to eq('text/plain') 28 | end 29 | 30 | it 'is able to determine MIME type for spoofed files' do 31 | expect(analyzer.call(spoofed_file)).to eq('text/plain') 32 | end 33 | 34 | it 'is able to determine MIME type for non-files' do 35 | expect(analyzer.call(fakeio(cute_image.read))).to eq('image/jpeg') 36 | end 37 | 38 | it 'returns nil for empty IOs' do 39 | expect(analyzer.call(fakeio(''))).to eq(nil) 40 | end 41 | 42 | it 'raises error if file command is not found' do 43 | allow(Open3).to receive(:popen3).and_raise(Errno::ENOENT) 44 | expect { analyzer.call(fakeio) }.to raise_error(FileValidators::Error, 'file command-line tool is not installed') 45 | end 46 | end 47 | 48 | describe ':fastimage analyzer' do 49 | let(:analyzer) { described_class.new(:fastimage) } 50 | 51 | it 'extracts MIME type of any IO' do 52 | expect(analyzer.call(cute_image)).to eq('image/jpeg') 53 | end 54 | 55 | it 'returns nil for unidentified MIME types' do 56 | expect(analyzer.call(fakeio('😃'))).to eq nil 57 | end 58 | 59 | it 'returns nil for empty IOs' do 60 | expect(analyzer.call(fakeio(''))).to eq nil 61 | end 62 | end 63 | 64 | describe ':mimemagic analyzer' do 65 | let(:analyzer) { described_class.new(:mimemagic) } 66 | 67 | it 'extracts MIME type of any IO' do 68 | expect(analyzer.call(cute_image)).to eq('image/jpeg') 69 | end 70 | 71 | it 'returns nil for unidentified MIME types' do 72 | expect(analyzer.call(fakeio('😃'))).to eq nil 73 | end 74 | 75 | it 'returns nil for empty IOs' do 76 | expect(analyzer.call(fakeio(''))).to eq nil 77 | end 78 | end 79 | 80 | if RUBY_VERSION >= '2.2.0' 81 | describe ':marcel analyzer' do 82 | let(:analyzer) { described_class.new(:marcel) } 83 | 84 | it 'extracts MIME type of any IO' do 85 | expect(analyzer.call(cute_image)).to eq('image/jpeg') 86 | end 87 | 88 | it 'returns application/octet-stream for unidentified MIME types' do 89 | expect(analyzer.call(fakeio('😃'))).to eq 'application/octet-stream' 90 | end 91 | 92 | it 'returns nil for empty IOs' do 93 | expect(analyzer.call(fakeio(''))).to eq nil 94 | end 95 | end 96 | end 97 | 98 | describe ':mime_types analyzer' do 99 | let(:analyzer) { described_class.new(:mime_types) } 100 | 101 | it 'extract MIME type from the file extension' do 102 | expect(analyzer.call(fakeio(filename: 'image.png'))).to eq('image/png') 103 | expect(analyzer.call(cute_image)).to eq('image/jpeg') 104 | end 105 | 106 | it 'extracts MIME type from file extension when IO is empty' do 107 | expect(analyzer.call(fakeio('', filename: 'image.png'))).to eq('image/png') 108 | end 109 | 110 | it 'returns nil on unknown extension' do 111 | expect(analyzer.call(fakeio(filename: 'file.foo'))).to eq(nil) 112 | end 113 | 114 | it 'returns nil when input is not a file' do 115 | expect(analyzer.call(fakeio)).to eq(nil) 116 | end 117 | end 118 | 119 | describe ':mini_mime analyzer' do 120 | let(:analyzer) { described_class.new(:mini_mime) } 121 | 122 | it 'extract MIME type from the file extension' do 123 | expect(analyzer.call(fakeio(filename: 'image.png'))).to eq('image/png') 124 | expect(analyzer.call(cute_image)).to eq('image/jpeg') 125 | end 126 | 127 | it 'extracts MIME type from file extension when IO is empty' do 128 | expect(analyzer.call(fakeio('', filename: 'image.png'))).to eq('image/png') 129 | end 130 | 131 | it 'returns nil on unkown extension' do 132 | expect(analyzer.call(fakeio(filename: 'file.foo'))).to eq(nil) 133 | end 134 | 135 | it 'returns nil when input is not a file' do 136 | expect(analyzer.call(fakeio)).to eq(nil) 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /spec/lib/file_validators/validators/file_content_type_validator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe ActiveModel::Validations::FileContentTypeValidator do 6 | class Dummy 7 | include ActiveModel::Validations 8 | end 9 | 10 | subject { Dummy } 11 | 12 | def build_validator(options) 13 | @validator = described_class.new(options.merge(attributes: :avatar)) 14 | end 15 | 16 | context 'whitelist format' do 17 | context 'with an allowed type' do 18 | context 'as a string' do 19 | before { build_validator allow: 'image/jpg' } 20 | it { is_expected.to allow_file_content_type('image/jpg', @validator) } 21 | end 22 | 23 | context 'as an regexp' do 24 | before { build_validator allow: /^image\/.*/ } 25 | it { is_expected.to allow_file_content_type('image/png', @validator) } 26 | end 27 | 28 | context 'as a list' do 29 | before { build_validator allow: ['image/png', 'image/jpg', 'image/jpeg'] } 30 | it { is_expected.to allow_file_content_type('image/png', @validator) } 31 | end 32 | 33 | context 'as a proc' do 34 | before { build_validator allow: ->(_record) { ['image/png', 'image/jpg', 'image/jpeg'] } } 35 | it { is_expected.to allow_file_content_type('image/png', @validator) } 36 | end 37 | end 38 | 39 | context 'with a disallowed type' do 40 | context 'as a string' do 41 | before { build_validator allow: 'image/png' } 42 | it { is_expected.not_to allow_file_content_type('image/jpeg', @validator) } 43 | end 44 | 45 | context 'as a regexp' do 46 | before { build_validator allow: /^text\/.*/ } 47 | it { is_expected.not_to allow_file_content_type('image/png', @validator) } 48 | end 49 | 50 | context 'as a proc' do 51 | before { build_validator allow: ->(_record) { /^text\/.*/ } } 52 | it { is_expected.not_to allow_file_content_type('image/png', @validator) } 53 | end 54 | 55 | context 'with :message option' do 56 | context 'without interpolation' do 57 | before do 58 | build_validator allow: 'image/png', 59 | message: 'should be a PNG image' 60 | end 61 | 62 | it do 63 | is_expected.not_to allow_file_content_type( 64 | 'image/jpeg', @validator, 65 | message: 'Avatar should be a PNG image' 66 | ) 67 | end 68 | end 69 | 70 | context 'with interpolation' do 71 | before do 72 | build_validator allow: 'image/png', 73 | message: 'should have content type %{types}' 74 | end 75 | 76 | it do 77 | is_expected.not_to allow_file_content_type( 78 | 'image/jpeg', @validator, 79 | message: 'Avatar should have content type image/png' 80 | ) 81 | end 82 | 83 | it do 84 | is_expected.to allow_file_content_type( 85 | 'image/png', @validator, 86 | message: 'Avatar should have content type image/png' 87 | ) 88 | end 89 | end 90 | end 91 | 92 | context 'default message' do 93 | before { build_validator allow: 'image/png' } 94 | 95 | it do 96 | is_expected.not_to allow_file_content_type( 97 | 'image/jpeg', @validator, 98 | message: 'Avatar file should be one of image/png' 99 | ) 100 | end 101 | end 102 | end 103 | end 104 | 105 | context 'blacklist format' do 106 | context 'with an allowed type' do 107 | context 'as a string' do 108 | before { build_validator exclude: 'image/gif' } 109 | it { is_expected.to allow_file_content_type('image/png', @validator) } 110 | end 111 | 112 | context 'as an regexp' do 113 | before { build_validator exclude: /^text\/.*/ } 114 | it { is_expected.to allow_file_content_type('image/png', @validator) } 115 | end 116 | 117 | context 'as a list' do 118 | before { build_validator exclude: ['image/png', 'image/jpg', 'image/jpeg'] } 119 | it { is_expected.to allow_file_content_type('image/gif', @validator) } 120 | end 121 | 122 | context 'as a proc' do 123 | before { build_validator exclude: ->(_record) { ['image/png', 'image/jpg', 'image/jpeg'] } } 124 | it { is_expected.to allow_file_content_type('image/gif', @validator) } 125 | end 126 | end 127 | 128 | context 'with a disallowed type' do 129 | context 'as a string' do 130 | before { build_validator exclude: 'image/gif' } 131 | it { is_expected.not_to allow_file_content_type('image/gif', @validator) } 132 | end 133 | 134 | context 'as an regexp' do 135 | before { build_validator exclude: /^text\/.*/ } 136 | it { is_expected.not_to allow_file_content_type('text/plain', @validator) } 137 | end 138 | 139 | context 'as an proc' do 140 | before { build_validator exclude: ->(_record) { /^text\/.*/ } } 141 | it { is_expected.not_to allow_file_content_type('text/plain', @validator) } 142 | end 143 | 144 | context 'with :message option' do 145 | context 'without interpolation' do 146 | before do 147 | build_validator exclude: 'image/png', 148 | message: 'should not be a PNG image' 149 | end 150 | 151 | it do 152 | is_expected.not_to allow_file_content_type( 153 | 'image/png', @validator, 154 | message: 'Avatar should not be a PNG image' 155 | ) 156 | end 157 | end 158 | 159 | context 'with interpolation' do 160 | before do 161 | build_validator exclude: 'image/png', 162 | message: 'should not have content type %{types}' 163 | end 164 | 165 | it do 166 | is_expected.not_to allow_file_content_type( 167 | 'image/png', @validator, 168 | message: 'Avatar should not have content type image/png' 169 | ) 170 | end 171 | 172 | it do 173 | is_expected.to allow_file_content_type( 174 | 'image/jpeg', @validator, 175 | message: 'Avatar should not have content type image/jpeg' 176 | ) 177 | end 178 | end 179 | end 180 | 181 | context 'default message' do 182 | before { build_validator exclude: 'image/png' } 183 | 184 | it do 185 | is_expected.not_to allow_file_content_type( 186 | 'image/png', @validator, 187 | message: 'Avatar file cannot be image/png' 188 | ) 189 | end 190 | end 191 | end 192 | end 193 | 194 | context 'using the helper' do 195 | before { Dummy.validates_file_content_type :avatar, allow: 'image/jpg' } 196 | 197 | it 'adds the validator to the class' do 198 | expect(Dummy.validators_on(:avatar)).to include(described_class) 199 | end 200 | end 201 | 202 | context 'given options' do 203 | it 'raises argument error if no required argument was given' do 204 | expect { build_validator message: 'Some message' }.to raise_error(ArgumentError) 205 | end 206 | 207 | described_class::CHECKS.each do |argument| 208 | it "does not raise error if :#{argument} is string, array, regexp or a proc" do 209 | expect { build_validator argument => 'image/jpg' }.not_to raise_error 210 | expect { build_validator argument => ['image/jpg'] }.not_to raise_error 211 | expect { build_validator argument => /^image\/.*/ }.not_to raise_error 212 | expect { build_validator argument => ->(_record) { 'image/jpg' } }.not_to raise_error 213 | end 214 | 215 | it "raises argument error if :#{argument} is neither a string, array, regexp nor proc" do 216 | expect { build_validator argument => 5.kilobytes }.to raise_error(ArgumentError) 217 | end 218 | end 219 | end 220 | end 221 | -------------------------------------------------------------------------------- /spec/lib/file_validators/validators/file_size_validator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe ActiveModel::Validations::FileSizeValidator do 6 | class Dummy 7 | include ActiveModel::Validations 8 | end 9 | 10 | def storage_units 11 | if defined?(ActiveSupport::NumberHelper) # Rails 4.0+ 12 | { 5120 => '5 KB', 10_240 => '10 KB' } 13 | else 14 | { 5120 => '5120 Bytes', 10_240 => '10240 Bytes' } 15 | end 16 | end 17 | 18 | before :all do 19 | @storage_units = storage_units 20 | end 21 | 22 | subject { Dummy } 23 | 24 | def build_validator(options) 25 | @validator = described_class.new(options.merge(attributes: :avatar)) 26 | end 27 | 28 | context 'with :in option' do 29 | context 'as a range' do 30 | before { build_validator in: (5.kilobytes..10.kilobytes) } 31 | 32 | it { is_expected.to allow_file_size(7.kilobytes, @validator) } 33 | it { is_expected.not_to allow_file_size(4.kilobytes, @validator) } 34 | it { is_expected.not_to allow_file_size(11.kilobytes, @validator) } 35 | end 36 | 37 | context 'as a proc' do 38 | before { build_validator in: ->(_record) { (5.kilobytes..10.kilobytes) } } 39 | 40 | it { is_expected.to allow_file_size(7.kilobytes, @validator) } 41 | it { is_expected.not_to allow_file_size(4.kilobytes, @validator) } 42 | it { is_expected.not_to allow_file_size(11.kilobytes, @validator) } 43 | end 44 | end 45 | 46 | context 'with :greater_than_or_equal_to option' do 47 | context 'as a number' do 48 | before { build_validator greater_than_or_equal_to: 10.kilobytes } 49 | 50 | it { is_expected.to allow_file_size(11.kilobytes, @validator) } 51 | it { is_expected.to allow_file_size(10.kilobytes, @validator) } 52 | it { is_expected.not_to allow_file_size(9.kilobytes, @validator) } 53 | end 54 | 55 | context 'as a proc' do 56 | before { build_validator greater_than_or_equal_to: ->(_record) { 10.kilobytes } } 57 | 58 | it { is_expected.to allow_file_size(11.kilobytes, @validator) } 59 | it { is_expected.to allow_file_size(10.kilobytes, @validator) } 60 | it { is_expected.not_to allow_file_size(9.kilobytes, @validator) } 61 | end 62 | end 63 | 64 | context 'with :less_than_or_equal_to option' do 65 | context 'as a number' do 66 | before { build_validator less_than_or_equal_to: 10.kilobytes } 67 | 68 | it { is_expected.to allow_file_size(9.kilobytes, @validator) } 69 | it { is_expected.to allow_file_size(10.kilobytes, @validator) } 70 | it { is_expected.not_to allow_file_size(11.kilobytes, @validator) } 71 | end 72 | 73 | context 'as a proc' do 74 | before { build_validator less_than_or_equal_to: ->(_record) { 10.kilobytes } } 75 | 76 | it { is_expected.to allow_file_size(9.kilobytes, @validator) } 77 | it { is_expected.to allow_file_size(10.kilobytes, @validator) } 78 | it { is_expected.not_to allow_file_size(11.kilobytes, @validator) } 79 | end 80 | end 81 | 82 | context 'with :greater_than option' do 83 | context 'as a number' do 84 | before { build_validator greater_than: 10.kilobytes } 85 | 86 | it { is_expected.to allow_file_size(11.kilobytes, @validator) } 87 | it { is_expected.not_to allow_file_size(10.kilobytes, @validator) } 88 | end 89 | 90 | context 'as a proc' do 91 | before { build_validator greater_than: ->(_record) { 10.kilobytes } } 92 | 93 | it { is_expected.to allow_file_size(11.kilobytes, @validator) } 94 | it { is_expected.not_to allow_file_size(10.kilobytes, @validator) } 95 | end 96 | end 97 | 98 | context 'with :less_than option' do 99 | context 'as a number' do 100 | before { build_validator less_than: 10.kilobytes } 101 | 102 | it { is_expected.to allow_file_size(9.kilobytes, @validator) } 103 | it { is_expected.not_to allow_file_size(10.kilobytes, @validator) } 104 | end 105 | 106 | context 'as a proc' do 107 | before { build_validator less_than: ->(_record) { 10.kilobytes } } 108 | 109 | it { is_expected.to allow_file_size(9.kilobytes, @validator) } 110 | it { is_expected.not_to allow_file_size(10.kilobytes, @validator) } 111 | end 112 | end 113 | 114 | context 'with :greater_than and :less_than option' do 115 | context 'as a number' do 116 | before { build_validator greater_than: 5.kilobytes, less_than: 10.kilobytes } 117 | 118 | it { is_expected.to allow_file_size(7.kilobytes, @validator) } 119 | it { is_expected.not_to allow_file_size(5.kilobytes, @validator) } 120 | it { is_expected.not_to allow_file_size(10.kilobytes, @validator) } 121 | end 122 | 123 | context 'as a proc' do 124 | before do 125 | build_validator greater_than: ->(_record) { 5.kilobytes }, 126 | less_than: ->(_record) { 10.kilobytes } 127 | end 128 | 129 | it { is_expected.to allow_file_size(7.kilobytes, @validator) } 130 | it { is_expected.not_to allow_file_size(5.kilobytes, @validator) } 131 | it { is_expected.not_to allow_file_size(10.kilobytes, @validator) } 132 | end 133 | end 134 | 135 | context 'with :message option' do 136 | before do 137 | build_validator in: (5.kilobytes..10.kilobytes), 138 | message: 'is invalid. (Between %{min} and %{max} please.)' 139 | end 140 | 141 | it do 142 | is_expected.not_to allow_file_size( 143 | 11.kilobytes, @validator, 144 | message: "Avatar is invalid. (Between #{@storage_units[5120]}" \ 145 | " and #{@storage_units[10_240]} please.)" 146 | ) 147 | end 148 | 149 | it do 150 | is_expected.to allow_file_size( 151 | 7.kilobytes, @validator, 152 | message: "Avatar is invalid. (Between #{@storage_units[5120]}" \ 153 | " and #{@storage_units[10_240]} please.)" 154 | ) 155 | end 156 | end 157 | 158 | context 'default error message' do 159 | context 'given :in options' do 160 | before { build_validator in: 5.kilobytes..10.kilobytes } 161 | 162 | it do 163 | is_expected.not_to allow_file_size( 164 | 11.kilobytes, @validator, 165 | message: "Avatar file size must be between #{@storage_units[5120]}" \ 166 | " and #{@storage_units[10_240]}" 167 | ) 168 | end 169 | 170 | it do 171 | is_expected.not_to allow_file_size( 172 | 4.kilobytes, @validator, 173 | message: "Avatar file size must be between #{@storage_units[5120]}" \ 174 | " and #{@storage_units[10_240]}" 175 | ) 176 | end 177 | end 178 | 179 | context 'given :greater_than and :less_than options' do 180 | before { build_validator greater_than: 5.kilobytes, less_than: 10.kilobytes } 181 | 182 | it do 183 | is_expected.not_to allow_file_size( 184 | 11.kilobytes, @validator, 185 | message: "Avatar file size must be less than #{@storage_units[10_240]}" 186 | ) 187 | end 188 | 189 | it do 190 | is_expected.not_to allow_file_size( 191 | 4.kilobytes, @validator, 192 | message: "Avatar file size must be greater than #{@storage_units[5120]}" 193 | ) 194 | end 195 | end 196 | 197 | context 'given :greater_than_or_equal_to and :less_than_or_equal_to options' do 198 | before do 199 | build_validator greater_than_or_equal_to: 5.kilobytes, 200 | less_than_or_equal_to: 10.kilobytes 201 | end 202 | 203 | it do 204 | is_expected.not_to allow_file_size( 205 | 11.kilobytes, @validator, 206 | message: "Avatar file size must be less than or equal to #{@storage_units[10_240]}" 207 | ) 208 | end 209 | 210 | it do 211 | is_expected.not_to allow_file_size( 212 | 4.kilobytes, @validator, 213 | message: "Avatar file size must be greater than or equal to #{@storage_units[5120]}" 214 | ) 215 | end 216 | end 217 | end 218 | 219 | context 'exceptional file size' do 220 | before { build_validator less_than: 3.kilobytes } 221 | 222 | it { is_expected.to allow_file_size(0, @validator) } # zero-byte file 223 | it { is_expected.not_to allow_file_size(nil, @validator) } 224 | end 225 | 226 | context 'using the helper' do 227 | before { Dummy.validates_file_size :avatar, in: (5.kilobytes..10.kilobytes) } 228 | 229 | it 'adds the validator to the class' do 230 | expect(Dummy.validators_on(:avatar)).to include(described_class) 231 | end 232 | end 233 | 234 | context 'given options' do 235 | it 'raises argument error if no required argument was given' do 236 | expect { build_validator message: 'Some message' }.to raise_error(ArgumentError) 237 | end 238 | 239 | (described_class::CHECKS.keys - [:in]).each do |argument| 240 | it "does not raise argument error if :#{argument} is numeric or a proc" do 241 | expect { build_validator argument => 5.kilobytes }.not_to raise_error 242 | expect { build_validator argument => ->(_record) { 5.kilobytes } }.not_to raise_error 243 | end 244 | 245 | it "raises error if :#{argument} is neither a number nor a proc" do 246 | expect { build_validator argument => 5.kilobytes..10.kilobytes }.to raise_error(ArgumentError) 247 | end 248 | end 249 | 250 | it 'does not raise argument error if :in is a range or a proc' do 251 | expect { build_validator in: 5.kilobytes..10.kilobytes }.not_to raise_error 252 | expect { build_validator in: ->(_record) { 5.kilobytes..10.kilobytes } }.not_to raise_error 253 | end 254 | 255 | it 'raises error if :in is neither a range nor a proc' do 256 | expect { build_validator in: 5.kilobytes }.to raise_error(ArgumentError) 257 | end 258 | end 259 | end 260 | -------------------------------------------------------------------------------- /spec/locale/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | number: 3 | human: 4 | storage_units: 5 | format: "%n %u" 6 | units: 7 | byte: 8 | one: "Byte" 9 | other: "Bytes" 10 | kb: "KB" 11 | mb: "MB" 12 | gb: "GB" 13 | tb: "TB" 14 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['RAILS_ENV'] ||= 'test' 4 | 5 | require 'active_support' 6 | require 'active_support/deprecation' 7 | require 'active_support/core_ext' 8 | require 'file_validators' 9 | require 'rspec' 10 | require 'coveralls' 11 | 12 | Coveralls.wear! 13 | 14 | locale_path = Dir.glob(File.dirname(__FILE__) + '/locale/*.yml') 15 | I18n.load_path += locale_path unless I18n.load_path.include?(locale_path) 16 | I18n.enforce_available_locales = false 17 | 18 | Dir[File.join(File.dirname(__FILE__), 'support/**/*.rb')].each { |f| require f } 19 | 20 | RSpec.configure do |config| 21 | config.include Helpers 22 | 23 | # Suppress stdout in the console 24 | config.before { allow($stdout).to receive(:write) } 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/fakeio.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | require 'stringio' 5 | 6 | class FakeIO 7 | attr_reader :original_filename, :content_type 8 | 9 | def initialize(content, filename: nil, content_type: nil) 10 | @io = StringIO.new(content) 11 | @original_filename = filename 12 | @content_type = content_type 13 | end 14 | 15 | extend Forwardable 16 | delegate %i[read rewind eof? close size] => :@io 17 | end 18 | -------------------------------------------------------------------------------- /spec/support/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Helpers 4 | def fakeio(content = 'file', **options) 5 | FakeIO.new(content, **options) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/matchers/allow_content_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define :allow_file_content_type do |content_type, validator, message| 4 | match do |model| 5 | value = double('file', path: content_type, original_filename: content_type) 6 | allow_any_instance_of(model).to receive(:read_attribute_for_validation).and_return(value) 7 | allow(validator).to receive(:get_content_type).and_return(content_type) 8 | dummy = model.new 9 | validator.validate(dummy) 10 | if message.present? 11 | dummy.errors.full_messages.exclude?(message[:message]) 12 | else 13 | dummy.errors.empty? 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/matchers/allow_file_size.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define :allow_file_size do |size, validator, message| 4 | match do |model| 5 | value = double('file', size: size) 6 | allow_any_instance_of(model).to receive(:read_attribute_for_validation).and_return(value) 7 | dummy = model.new 8 | validator.validate(dummy) 9 | if message.present? 10 | dummy.errors.full_messages.exclude?(message[:message]) 11 | else 12 | dummy.errors.empty? 13 | end 14 | end 15 | end 16 | --------------------------------------------------------------------------------