sequel_password 0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: c7be80a93422a9c18bd61e2d309b61481fb4c4d1
4
+ data.tar.gz: b89e1934f8373ba7e341d50312b1620574f93f19
5
+ SHA512:
6
+ metadata.gz: a15ae5358ab1e5601c50dc6cc4c5b10bd4c223c3f3e5d2726e8a022b8dd28989a24e8b7b0e5321add1835549fcc5bbc1277e8df0ee12866db2748bb090e07863
7
+ data.tar.gz: 1014dcda3e264885bb94301736b00bfce78f574444919b11d6f0c71c53079192fb81345403385cc838e552ddc79d021d65d2acc7a7ede82d426af3822ef7909a
@@ -0,0 +1 @@
1
+ coverage
@@ -0,0 +1,44 @@
1
+ Style/AlignHash:
2
+ Enabled: false
3
+
4
+ Style/AlignArray:
5
+ Enabled: false
6
+
7
+ Style/AlignParameters:
8
+ Enabled: false
9
+
10
+ Style/Documentation:
11
+ Enabled: false
12
+
13
+ Style/CaseIndentation:
14
+ Enabled: false
15
+
16
+ Style/IndentHash:
17
+ Enabled: false
18
+
19
+ Style/NumericLiterals:
20
+ Enabled: false
21
+
22
+ Style/SignalException:
23
+ Enabled: false
24
+
25
+ Style/StringLiterals:
26
+ Enabled: false
27
+
28
+ Style/MultilineOperationIndentation:
29
+ Enabled: false
30
+
31
+ Style/StringLiterals:
32
+ Enabled: false
33
+
34
+ Metrics/LineLength:
35
+ Max: 250
36
+
37
+ Metrics/MethodLength:
38
+ Max: 250
39
+
40
+ Metrics/ClassLength:
41
+ Max: 250
42
+
43
+ Lint/UselessAssignment:
44
+ Enabled: false
@@ -0,0 +1,8 @@
1
+ language: ruby
2
+ cache: bundler
3
+ rvm:
4
+ - 2.2.1
5
+ sudo: false
6
+ script: bundle exec rspec
7
+ notifications:
8
+ email: false
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
@@ -0,0 +1,45 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ sequel_password (0.1)
5
+ bcrypt (~> 3.1.10)
6
+ pbkdf2-ruby (~> 0.2.1)
7
+ sequel (~> 4.21.0)
8
+
9
+ GEM
10
+ remote: https://rubygems.org/
11
+ specs:
12
+ bcrypt (3.1.10)
13
+ diff-lcs (1.2.5)
14
+ docile (1.1.5)
15
+ multi_json (1.11.0)
16
+ pbkdf2-ruby (0.2.1)
17
+ rspec (3.2.0)
18
+ rspec-core (~> 3.2.0)
19
+ rspec-expectations (~> 3.2.0)
20
+ rspec-mocks (~> 3.2.0)
21
+ rspec-core (3.2.2)
22
+ rspec-support (~> 3.2.0)
23
+ rspec-expectations (3.2.0)
24
+ diff-lcs (>= 1.2.0, < 2.0)
25
+ rspec-support (~> 3.2.0)
26
+ rspec-mocks (3.2.1)
27
+ diff-lcs (>= 1.2.0, < 2.0)
28
+ rspec-support (~> 3.2.0)
29
+ rspec-support (3.2.2)
30
+ sequel (4.21.0)
31
+ simplecov (0.9.2)
32
+ docile (~> 1.1.0)
33
+ multi_json (~> 1.0)
34
+ simplecov-html (~> 0.9.0)
35
+ simplecov-html (0.9.0)
36
+ sqlite3 (1.3.10)
37
+
38
+ PLATFORMS
39
+ ruby
40
+
41
+ DEPENDENCIES
42
+ rspec (~> 3.2.0)
43
+ sequel_password!
44
+ simplecov (~> 0.9.2)
45
+ sqlite3 (~> 1.3.10)
@@ -0,0 +1,4 @@
1
+ # Sequel password
2
+
3
+ This sequel plugin adds authentication and password hashing to Sequel models.
4
+ It supports pbkdf2 and bcrypt hashers.
@@ -0,0 +1,186 @@
1
+ require "base64"
2
+ require "bcrypt"
3
+ require "openssl"
4
+ require "pbkdf2"
5
+ require "securerandom"
6
+
7
+ module Sequel
8
+ module Plugins
9
+ module Password
10
+ class InvalidHasherException < Exception; end
11
+
12
+ def self.configure(model, options = {})
13
+ model.instance_eval do
14
+ @column = options.fetch(:column, :digest)
15
+ @hashers = options.fetch(:hashers,
16
+ pbkdf2_sha256: PBKDF2Hasher.new,
17
+ bcrypt_sha256: BCryptSHA256Hasher.new,
18
+ bcrypt: BCryptHasher.new,
19
+ sha1: SHA1Hasher.new)
20
+ end
21
+ end
22
+
23
+ module ClassMethods
24
+ attr_reader :column, :hashers
25
+
26
+ Plugins.inherited_instance_variables(self,
27
+ "@column": :digest, "@hashers": {})
28
+
29
+ def make_password(password, salt: nil, algorithm: :default)
30
+ return "!#{SecureRandom.hex(20)}" if password.nil?
31
+
32
+ salt = hasher(algorithm).salt if salt.nil?
33
+ hasher(algorithm).encode(password, salt)
34
+ end
35
+
36
+ def hasher(algorithm = :default)
37
+ @hashers.fetch(algorithm.to_sym, @hashers.values.first)
38
+ end
39
+
40
+ def usable_password?(encoded)
41
+ return false if encoded.nil? || encoded.start_with?("!")
42
+
43
+ algorithm = encoded.split('$').first
44
+ !hasher(algorithm).nil?
45
+ end
46
+
47
+ def check_password(password, encoded, setter: nil, algorithm: :default)
48
+ return false if password.nil? || !usable_password?(encoded)
49
+
50
+ preferred = hasher(algorithm)
51
+ hasher = hasher(encoded.split('$').first)
52
+
53
+ must_update = hasher.algorithm != preferred.algorithm
54
+ must_update = preferred.must_update(encoded) unless must_update
55
+
56
+ correct = hasher.verify(password, encoded)
57
+ setter.call(password) if !setter.nil? && correct && must_update
58
+
59
+ correct
60
+ end
61
+ end
62
+
63
+ module InstanceMethods
64
+ def authenticate(password)
65
+ encoded = send(model.column)
66
+ model.check_password(password, encoded, setter: method(:"password="))
67
+ end
68
+
69
+ def password=(password)
70
+ send("#{model.column}=", model.make_password(password))
71
+ end
72
+
73
+ def set_unusable_password
74
+ send("#{model.column}=", model.make_password(nil))
75
+ end
76
+ end
77
+
78
+ class Hasher
79
+ attr_reader :algorithm
80
+
81
+ def salt
82
+ # 72 bits
83
+ SecureRandom.hex(9)
84
+ end
85
+
86
+ def verify(password, encoded)
87
+ raise NotImplementedError
88
+ end
89
+
90
+ def encode(password, salt)
91
+ raise NotImplementedError
92
+ end
93
+
94
+ def must_update(encoded)
95
+ false
96
+ end
97
+
98
+ private
99
+
100
+ def constant_time_compare(a, b)
101
+ check = a.bytesize ^ b.bytesize
102
+ a.bytes.zip(b.bytes) { |x, y| check |= x ^ y }
103
+ check == 0
104
+ end
105
+ end
106
+
107
+ class PBKDF2Hasher < Hasher
108
+ def initialize
109
+ @algorithm = :pbkdf2_sha256
110
+ @iterations = 24000
111
+ @digest = OpenSSL::Digest::SHA256.new
112
+ end
113
+
114
+ def encode(password, salt, iterations = nil)
115
+ iterations = @iterations if iterations.nil?
116
+ hash = PBKDF2.new(password: password, salt: salt,
117
+ iterations: iterations, hash_function: @digest)
118
+ hash = Base64.strict_encode64(hash.value)
119
+ "#{@algorithm}$#{iterations}$#{salt}$#{hash}"
120
+ end
121
+
122
+ def verify(password, encoded)
123
+ algorithm, iterations, salt, hash = encoded.split('$', 4)
124
+ hash = encode(password, salt, iterations.to_i)
125
+ constant_time_compare(encoded, hash)
126
+ end
127
+
128
+ def must_update(encoded)
129
+ algorithm, iterations, salt, hash = encoded.split('$', 4)
130
+ iterations.to_i != @iterations
131
+ end
132
+ end
133
+
134
+ class BCryptSHA256Hasher < Hasher
135
+ def initialize
136
+ @algorithm = :bcrypt_sha256
137
+ @cost = 12
138
+ @digest = OpenSSL::Digest::SHA256.new
139
+ end
140
+
141
+ def salt
142
+ BCrypt::Engine.generate_salt(@cost)
143
+ end
144
+
145
+ def encode(password, salt)
146
+ password = @digest.digest(password) unless @digest.nil?
147
+ hash = BCrypt::Engine.hash_secret(password, salt)
148
+ "#{@algorithm}$#{hash}"
149
+ end
150
+
151
+ def verify(password, encoded)
152
+ algorithm, data = encoded.split('$', 2)
153
+ password = @digest.digest(password) unless @digest.nil?
154
+ hash = BCrypt::Engine.hash_secret(password, data)
155
+ constant_time_compare(data, hash)
156
+ end
157
+ end
158
+
159
+ class BCryptHasher < BCryptSHA256Hasher
160
+ def initialize
161
+ @algorithm = :bcrypt
162
+ @cost = 12
163
+ @digest = nil
164
+ end
165
+ end
166
+
167
+ class SHA1Hasher < Hasher
168
+ def initialize
169
+ @algorithm = :sha1
170
+ @digest = OpenSSL::Digest::SHA1.new
171
+ end
172
+
173
+ def encode(password, salt)
174
+ hash = @digest.digest(salt + password).unpack('H*').first
175
+ "#{@algorithm}$#{salt}$#{hash}"
176
+ end
177
+
178
+ def verify(password, encoded)
179
+ algorithm, salt, hash = encoded.split('$', 3)
180
+ hash = encode(password, salt)
181
+ constant_time_compare(encoded, hash)
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,25 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |gem|
4
+ gem.authors = ["Timothée Peignier"]
5
+ gem.email = ["timothee.peignier@tryphon.org"]
6
+ gem.description = %q{Sequel plugins to handle password hashing}
7
+ gem.summary = %q{Add passwords hashing to sequel models.}
8
+ gem.homepage = "http://rubygems.org/gems/sequel_password"
9
+ gem.license = 'MIT'
10
+
11
+ gem.files = `git ls-files`.split($\)
12
+ gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) }
13
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
14
+ gem.name = "sequel_password"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = '0.1'
17
+
18
+ gem.add_runtime_dependency 'sequel', '~> 4.21', '>= 4.21.0'
19
+ gem.add_runtime_dependency 'bcrypt', '~> 3.1', '>= 3.1.10'
20
+ gem.add_runtime_dependency 'pbkf2-ruby', '~> 0.2.1'
21
+
22
+ gem.add_development_dependency 'rspec', '~> 3.2', '>= 3.2.0'
23
+ gem.add_development_dependency 'simplecov', '~> 0.9.2'
24
+ gem.add_development_dependency 'sqlite3', '~> 1.3', '>= 1.3.10'
25
+ end
@@ -0,0 +1,115 @@
1
+ require "spec_helper"
2
+
3
+ describe Sequel::Plugins::Password do
4
+ subject(:user) { DefaultUser.new }
5
+
6
+ it "has an inherited instance variable @column" do
7
+ expect(DefaultUser.inherited_instance_variables).to include(:@column)
8
+ end
9
+
10
+ it "has an inherited instance variable @hashers" do
11
+ expect(DefaultUser.inherited_instance_variables).to include(:@hashers)
12
+ end
13
+
14
+ describe "set_unusable_password" do
15
+ let(:secret) { "lètmein" }
16
+
17
+ before { user.password = secret }
18
+
19
+ it "sets an unusable password" do
20
+ expect { user.set_unusable_password }.to change(user, :digest)
21
+ expect(user.digest).to match(/^!/)
22
+ expect(user.digest.length).to eq(41)
23
+ end
24
+ end
25
+
26
+ describe "#authenticate" do
27
+ let(:secret) { "lètmein" }
28
+
29
+ before { user.password = secret }
30
+
31
+ it "returns true if authentication is successful" do
32
+ expect(user.authenticate(secret)).to be_truthy
33
+ end
34
+
35
+ it "returns false when authentication fails" do
36
+ expect(user.authenticate("")).to be_falsey
37
+ end
38
+
39
+ it "upgrade to newest hasher" do
40
+ user.digest = "sha1$seasalt$cff36ea83f5706ce9aa7454e63e431fc726b2dc8"
41
+ expect { user.authenticate(secret) }.to change(user, :digest)
42
+ expect(user.digest).to match(/^pbkdf2_sha256\$/)
43
+ end
44
+
45
+ it "upgrade to new iterations values" do
46
+ user.digest = "pbkdf2_sha256$20000$seasalt$oBSd886ysm3AqYun62DOdin8YcfbU1z9cksZSuLP9r0="
47
+ expect { user.authenticate(secret) }.to change(user, :digest)
48
+ expect(user.digest).to match(/^pbkdf2_sha256\$24000\$/)
49
+ end
50
+ end
51
+
52
+ describe Sequel::Plugins::Password::PBKDF2Hasher do
53
+ let(:hasher) { described_class.new }
54
+ let(:password) { 'lètmein' }
55
+ let(:salt) { 'seasalt' }
56
+
57
+ it "encodes the password properly" do
58
+ encoded = hasher.encode(password, salt)
59
+ expect(encoded).to eq("pbkdf2_sha256$24000$#{salt}$V9DfCAVoweeLwxC/L2mb+7swhzF0XYdyQMqmusZqiTc=")
60
+ expect(hasher.verify(password, encoded)).to be_truthy
61
+ expect(hasher.verify(password.reverse, encoded)).to be_falsey
62
+ end
63
+
64
+ it "allows blank password" do
65
+ blank_encoded = hasher.encode('', salt)
66
+ expect(blank_encoded).to match(/^pbkdf2_sha256\$/)
67
+ expect(hasher.verify('', blank_encoded)).to be_truthy
68
+ expect(hasher.verify(' ', blank_encoded)).to be_falsey
69
+ end
70
+ end
71
+
72
+ describe Sequel::Plugins::Password::BCryptSHA256Hasher do
73
+ let(:hasher) { described_class.new }
74
+ let(:password) { 'lètmein' }
75
+
76
+ it "encodes the password properly" do
77
+ encoded = hasher.encode(password, hasher.salt)
78
+ expect(encoded).to match(/^bcrypt_sha256\$/)
79
+ expect(hasher.verify(password, encoded)).to be_truthy
80
+ expect(hasher.verify(password.reverse, encoded)).to be_falsey
81
+ end
82
+ end
83
+
84
+ describe Sequel::Plugins::Password::BCryptHasher do
85
+ let(:hasher) { described_class.new }
86
+ let(:password) { 'lètmein' }
87
+
88
+ it "encodes the password properly" do
89
+ encoded = hasher.encode(password, hasher.salt)
90
+ expect(encoded).to match(/^bcrypt\$/)
91
+ expect(hasher.verify(password, encoded)).to be_truthy
92
+ expect(hasher.verify(password.reverse, encoded)).to be_falsey
93
+ end
94
+ end
95
+
96
+ describe Sequel::Plugins::Password::SHA1Hasher do
97
+ let(:hasher) { described_class.new }
98
+ let(:password) { 'lètmein' }
99
+ let(:salt) { 'seasalt' }
100
+
101
+ it "encodes the password properly" do
102
+ encoded = hasher.encode(password, salt)
103
+ expect(encoded).to eq("sha1$#{salt}$cff36ea83f5706ce9aa7454e63e431fc726b2dc8")
104
+ expect(hasher.verify(password, encoded)).to be_truthy
105
+ expect(hasher.verify(password.reverse, encoded)).to be_falsey
106
+ end
107
+
108
+ it "allows blank password" do
109
+ blank_encoded = hasher.encode('', salt)
110
+ expect(blank_encoded).to match(/^sha1\$/)
111
+ expect(hasher.verify('', blank_encoded)).to be_truthy
112
+ expect(hasher.verify(' ', blank_encoded)).to be_falsey
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,64 @@
1
+ require "bundler"
2
+ Bundler.require
3
+
4
+ require "simplecov"
5
+ SimpleCov.start do
6
+ add_filter('spec/')
7
+ end
8
+
9
+ require "sequel"
10
+ require "sequel_password"
11
+
12
+ RSpec.configure do |config|
13
+ config.order = 'random'
14
+
15
+ config.before(:suite) do
16
+ Sequel::Model.plugin(:schema)
17
+ Sequel.connect('sqlite:/')
18
+
19
+ class DefaultUser < Sequel::Model
20
+ set_schema do
21
+ primary_key :id
22
+ varchar :digest
23
+ end
24
+
25
+ plugin :password
26
+ end
27
+
28
+ class BCryptUser < Sequel::Model
29
+ set_schema do
30
+ primary_key :id
31
+ varchar :digest
32
+ end
33
+
34
+ plugin :password, hashers: { bcrypt: Sequel::Plugins::Password::BCryptHasher.new }
35
+ end
36
+
37
+ class BCryptSHA256User < Sequel::Model
38
+ set_schema do
39
+ primary_key :id
40
+ varchar :digest
41
+ end
42
+
43
+ plugin :password, hashers: { bcrypt: Sequel::Plugins::Password::BCryptSHA256Hasher.new }
44
+ end
45
+
46
+ class AlternateColumnUser < Sequel::Model
47
+ set_schema do
48
+ primary_key :id
49
+ varchar :password_digest
50
+ end
51
+
52
+ plugin :password, column: :digest
53
+ end
54
+
55
+ DefaultUser.create_table!
56
+ BCryptUser.create_table!
57
+ BCryptSHA256User.create_table!
58
+ AlternateColumnUser.create_table!
59
+ end
60
+
61
+ config.around(:each) do |example|
62
+ Sequel::Model.db.transaction(rollback: :always) { example.run }
63
+ end
64
+ end
metadata ADDED
@@ -0,0 +1,164 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sequel_password
3
+ version: !ruby/object:Gem::Version
4
+ version: '0.1'
5
+ platform: ruby
6
+ authors:
7
+ - Timothée Peignier
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-04-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sequel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '4.21'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 4.21.0
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '4.21'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 4.21.0
33
+ - !ruby/object:Gem::Dependency
34
+ name: bcrypt
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.1'
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 3.1.10
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - "~>"
48
+ - !ruby/object:Gem::Version
49
+ version: '3.1'
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 3.1.10
53
+ - !ruby/object:Gem::Dependency
54
+ name: pbkf2-ruby
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: 0.2.1
60
+ type: :runtime
61
+ prerelease: false
62
+ version_requirements: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: 0.2.1
67
+ - !ruby/object:Gem::Dependency
68
+ name: rspec
69
+ requirement: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '3.2'
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: 3.2.0
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - "~>"
82
+ - !ruby/object:Gem::Version
83
+ version: '3.2'
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: 3.2.0
87
+ - !ruby/object:Gem::Dependency
88
+ name: simplecov
89
+ requirement: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: 0.9.2
94
+ type: :development
95
+ prerelease: false
96
+ version_requirements: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: 0.9.2
101
+ - !ruby/object:Gem::Dependency
102
+ name: sqlite3
103
+ requirement: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '1.3'
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: 1.3.10
111
+ type: :development
112
+ prerelease: false
113
+ version_requirements: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '1.3'
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: 1.3.10
121
+ description: Sequel plugins to handle password hashing
122
+ email:
123
+ - timothee.peignier@tryphon.org
124
+ executables: []
125
+ extensions: []
126
+ extra_rdoc_files: []
127
+ files:
128
+ - ".gitignore"
129
+ - ".rubocop.yml"
130
+ - ".travis.yml"
131
+ - Gemfile
132
+ - Gemfile.lock
133
+ - README.md
134
+ - lib/sequel_password.rb
135
+ - sequel_password.gemspec
136
+ - spec/sequel_password_spec.rb
137
+ - spec/spec_helper.rb
138
+ homepage: http://rubygems.org/gems/sequel_password
139
+ licenses:
140
+ - MIT
141
+ metadata: {}
142
+ post_install_message:
143
+ rdoc_options: []
144
+ require_paths:
145
+ - lib
146
+ required_ruby_version: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - ">="
149
+ - !ruby/object:Gem::Version
150
+ version: '0'
151
+ required_rubygems_version: !ruby/object:Gem::Requirement
152
+ requirements:
153
+ - - ">="
154
+ - !ruby/object:Gem::Version
155
+ version: '0'
156
+ requirements: []
157
+ rubyforge_project:
158
+ rubygems_version: 2.4.5
159
+ signing_key:
160
+ specification_version: 4
161
+ summary: Add passwords hashing to sequel models.
162
+ test_files:
163
+ - spec/sequel_password_spec.rb
164
+ - spec/spec_helper.rb