active_model_otp 2.3.0 → 2.3.2
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.
- checksums.yaml +4 -4
- data/.github/workflows/active_model_otp.yml +34 -8
- data/Appraisals +7 -0
- data/README.md +25 -3
- data/gemfiles/rails_7.0.gemfile +10 -0
- data/lib/active_model/one_time_password.rb +28 -45
- data/lib/active_model/otp/version.rb +1 -1
- data/test/models/after_user.rb +5 -0
- data/test/one_time_password_test.rb +47 -12
- data/test/schema.rb +9 -0
- data/test/test_helper.rb +1 -0
- metadata +6 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 98c3fad16e797e0483e6aeebcdae72e868dd699b82f07400ad0ee9b0261dbd2a
|
4
|
+
data.tar.gz: 3454ea7c190b0f7f69b50cf617c132a46e1a22f7f5554e4159acec61dd40979f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 93a2cec634c0b00a4480598f0dc5fe8bfcf27a9dcc35fb6603eb7bbdfb90fc4f85f94443fbe4b9df9acb6113facd7c14bc12368f0ad15f4df2c0cd5b5498f6bf
|
7
|
+
data.tar.gz: 232296291239477d07f49ba75bc9db6288a8a3e039f6b1bc93bb3f9ce57d07d8e3624450ebc425e4b7532073f5937506f97f67ec014cc9c6e2312c30aeeb755e
|
@@ -9,22 +9,48 @@ on:
|
|
9
9
|
jobs:
|
10
10
|
ci:
|
11
11
|
runs-on: ubuntu-latest
|
12
|
-
|
12
|
+
|
13
13
|
strategy:
|
14
14
|
matrix:
|
15
|
-
gemfile: [rails_4.2, rails_5.0, rails_5.1, rails_5.2, rails_6.0, rails_6.1]
|
16
|
-
ruby-version: [2.3, 2.4, 2.5, 2.6, 2.7, 3.0]
|
15
|
+
gemfile: [rails_4.2, rails_5.0, rails_5.1, rails_5.2, rails_6.0, rails_6.1, rails_7.0]
|
16
|
+
ruby-version: [2.3, 2.4, 2.5, 2.6, 2.7, 3.0, 3.1, 3.2]
|
17
17
|
exclude:
|
18
|
-
- { gemfile:
|
19
|
-
- { gemfile:
|
20
|
-
- { gemfile: rails_6.0, ruby-version: 2.4 }
|
21
|
-
- { gemfile: rails_6.1, ruby-version: 2.4 }
|
18
|
+
- { gemfile: rails_4.2, ruby-version: 2.5 }
|
19
|
+
- { gemfile: rails_4.2, ruby-version: 2.6 }
|
22
20
|
- { gemfile: rails_4.2, ruby-version: 2.7 }
|
23
21
|
- { gemfile: rails_4.2, ruby-version: 3.0 }
|
22
|
+
- { gemfile: rails_4.2, ruby-version: 3.1 }
|
23
|
+
- { gemfile: rails_4.2, ruby-version: 3.2 }
|
24
|
+
- { gemfile: rails_5.0, ruby-version: 2.5 }
|
25
|
+
- { gemfile: rails_5.0, ruby-version: 2.6 }
|
26
|
+
- { gemfile: rails_5.0, ruby-version: 2.7 }
|
24
27
|
- { gemfile: rails_5.0, ruby-version: 3.0 }
|
28
|
+
- { gemfile: rails_5.0, ruby-version: 3.1 }
|
29
|
+
- { gemfile: rails_5.0, ruby-version: 3.2 }
|
30
|
+
- { gemfile: rails_5.1, ruby-version: 2.6 }
|
31
|
+
- { gemfile: rails_5.1, ruby-version: 2.7 }
|
25
32
|
- { gemfile: rails_5.1, ruby-version: 3.0 }
|
33
|
+
- { gemfile: rails_5.1, ruby-version: 3.1 }
|
34
|
+
- { gemfile: rails_5.1, ruby-version: 3.2 }
|
35
|
+
- { gemfile: rails_5.2, ruby-version: 2.7 }
|
26
36
|
- { gemfile: rails_5.2, ruby-version: 3.0 }
|
27
|
-
|
37
|
+
- { gemfile: rails_5.2, ruby-version: 3.1 }
|
38
|
+
- { gemfile: rails_5.2, ruby-version: 3.2 }
|
39
|
+
- { gemfile: rails_6.0, ruby-version: 2.3 }
|
40
|
+
- { gemfile: rails_6.0, ruby-version: 2.4 }
|
41
|
+
- { gemfile: rails_6.0, ruby-version: 3.0 }
|
42
|
+
- { gemfile: rails_6.0, ruby-version: 3.1 }
|
43
|
+
- { gemfile: rails_6.0, ruby-version: 3.2 }
|
44
|
+
- { gemfile: rails_6.1, ruby-version: 2.3 }
|
45
|
+
- { gemfile: rails_6.1, ruby-version: 2.4 }
|
46
|
+
- { gemfile: rails_6.1, ruby-version: 3.0 }
|
47
|
+
- { gemfile: rails_6.1, ruby-version: 3.1 }
|
48
|
+
- { gemfile: rails_6.1, ruby-version: 3.2 }
|
49
|
+
- { gemfile: rails_7.0, ruby-version: 2.3 }
|
50
|
+
- { gemfile: rails_7.0, ruby-version: 2.4 }
|
51
|
+
- { gemfile: rails_7.0, ruby-version: 2.5 }
|
52
|
+
- { gemfile: rails_7.0, ruby-version: 2.6 }
|
53
|
+
|
28
54
|
env:
|
29
55
|
BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile
|
30
56
|
|
data/Appraisals
CHANGED
@@ -34,3 +34,10 @@ appraise "rails-6.1" do
|
|
34
34
|
gem "activemodel-serializers-xml"
|
35
35
|
gem "sqlite3", "~> 1.4"
|
36
36
|
end
|
37
|
+
|
38
|
+
appraise "rails-7.0" do
|
39
|
+
gem "activerecord", "~> 7.0"
|
40
|
+
gem "activemodel", "~> 7.0"
|
41
|
+
gem "activemodel-serializers-xml"
|
42
|
+
gem "sqlite3", "~> 1.6"
|
43
|
+
end
|
data/README.md
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
[](https://github.com/heapsource/active_model_otp/actions/workflows/active_model_otp.yml)
|
2
2
|
[](http://badge.fury.io/rb/active_model_otp)
|
3
3
|
[](https://houndci.com)
|
4
4
|
|
@@ -98,9 +98,31 @@ sleep 30 # lets wait again
|
|
98
98
|
user.authenticate_otp('186522', drift: 60) # => true
|
99
99
|
```
|
100
100
|
|
101
|
+
### Preventing reuse of Time based OTP's
|
102
|
+
|
103
|
+
By keeping track of the last time a user's OTP was verified, we can prevent token reuse during the interval window (default 30 seconds). It is useful with SMS, that is commonly used in combination with `drift` to extend the life of the code.
|
104
|
+
|
105
|
+
```ruby
|
106
|
+
rails g migration AddLastOtpAtToUsers last_otp_at:integer
|
107
|
+
=>
|
108
|
+
invoke active_record
|
109
|
+
create db/migrate/20220407010931_add_last_otp_at_to_users.rb
|
110
|
+
```
|
111
|
+
|
112
|
+
```ruby
|
113
|
+
class User < ApplicationRecord
|
114
|
+
has_one_time_password after_column_name: :last_otp_at
|
115
|
+
end
|
116
|
+
```
|
117
|
+
|
118
|
+
```ruby
|
119
|
+
user.authenticate_otp('186522') # => true
|
120
|
+
user.authenticate_otp('186522') # => false
|
121
|
+
```
|
122
|
+
|
101
123
|
## Counter based OTP
|
102
124
|
|
103
|
-
An
|
125
|
+
An additional counter field is required in our ``User`` Model
|
104
126
|
|
105
127
|
```ruby
|
106
128
|
rails g migration AddCounterForOtpToUsers otp_counter:integer
|
@@ -213,7 +235,7 @@ user.provisioning_uri(nil, issuer: 'MYAPP') #=> 'otpauth://totp/hello@heapsource
|
|
213
235
|
|
214
236
|
This can then be rendered as a QR Code which can be scanned and added to the users list of OTP credentials.
|
215
237
|
|
216
|
-
### Setting up a customer interval
|
238
|
+
### Setting up a customer interval
|
217
239
|
|
218
240
|
If you define a custom interval for TOTP codes, just as `has_one_time_password interval: 10` (for example), remember to include the interval also in `provisioning_uri` method. If not defined, the default value is 30 seconds (according to ROTP gem: https://github.com/mdp/rotp/blob/master/lib/rotp/totp.rb#L9)
|
219
241
|
|
@@ -13,32 +13,20 @@ module ActiveModel
|
|
13
13
|
module ClassMethods
|
14
14
|
def has_one_time_password(options = {})
|
15
15
|
cattr_accessor :otp_column_name, :otp_counter_column_name,
|
16
|
-
:otp_backup_codes_column_name
|
16
|
+
:otp_backup_codes_column_name, :otp_after_column_name
|
17
17
|
class_attribute :otp_digits, :otp_counter_based,
|
18
18
|
:otp_backup_codes_count, :otp_one_time_backup_codes,
|
19
19
|
:otp_interval
|
20
20
|
|
21
|
-
self.otp_column_name = (
|
22
|
-
options[:column_name] || OTP_DEFAULT_COLUMN_NAME
|
23
|
-
).to_s
|
21
|
+
self.otp_column_name = (options[:column_name] || OTP_DEFAULT_COLUMN_NAME).to_s
|
24
22
|
self.otp_digits = options[:length] || OTP_DEFAULT_DIGITS
|
25
|
-
self.otp_counter_based =
|
26
|
-
|
27
|
-
)
|
28
|
-
self.otp_counter_column_name = (
|
29
|
-
options[:counter_column_name] || OTP_DEFAULT_COUNTER_COLUMN_NAME
|
30
|
-
).to_s
|
23
|
+
self.otp_counter_based = options[:counter_based] || OTP_COUNTER_ENABLED_BY_DEFAULT
|
24
|
+
self.otp_counter_column_name = (options[:counter_column_name] || OTP_DEFAULT_COUNTER_COLUMN_NAME).to_s
|
31
25
|
self.otp_interval = options[:interval]
|
32
|
-
self.
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
self.otp_backup_codes_count = (
|
37
|
-
options[:backup_codes_count] || OTP_DEFAULT_BACKUP_CODES_COUNT
|
38
|
-
)
|
39
|
-
self.otp_one_time_backup_codes = (
|
40
|
-
options[:one_time_backup_codes] || OTP_BACKUP_CODES_ENABLED_BY_DEFAULT
|
41
|
-
)
|
26
|
+
self.otp_after_column_name = options[:after_column_name]
|
27
|
+
self.otp_backup_codes_column_name = (options[:backup_codes_column_name] || OTP_DEFAULT_BACKUP_CODES_COLUMN_NAME).to_s
|
28
|
+
self.otp_backup_codes_count = options[:backup_codes_count] || OTP_DEFAULT_BACKUP_CODES_COUNT
|
29
|
+
self.otp_one_time_backup_codes = options[:one_time_backup_codes] || OTP_BACKUP_CODES_ENABLED_BY_DEFAULT
|
42
30
|
|
43
31
|
include InstanceMethodsOnActivation
|
44
32
|
|
@@ -72,6 +60,7 @@ module ActiveModel
|
|
72
60
|
end
|
73
61
|
|
74
62
|
def authenticate_otp(code, options = {})
|
63
|
+
return false if code.nil? || code.empty?
|
75
64
|
return true if backup_codes_enabled? && authenticate_backup_code(code)
|
76
65
|
|
77
66
|
if otp_counter_based
|
@@ -94,13 +83,9 @@ module ActiveModel
|
|
94
83
|
account ||= ""
|
95
84
|
|
96
85
|
if otp_counter_based
|
97
|
-
ROTP::HOTP
|
98
|
-
.new(otp_column, options)
|
99
|
-
.provisioning_uri(account, self.otp_counter)
|
86
|
+
ROTP::HOTP.new(otp_column, options).provisioning_uri(account, self.otp_counter)
|
100
87
|
else
|
101
|
-
ROTP::TOTP
|
102
|
-
.new(otp_column, options)
|
103
|
-
.provisioning_uri(account)
|
88
|
+
ROTP::TOTP.new(otp_column, options).provisioning_uri(account)
|
104
89
|
end
|
105
90
|
end
|
106
91
|
|
@@ -132,6 +117,7 @@ module ActiveModel
|
|
132
117
|
options ||= {}
|
133
118
|
options[:except] = Array(options[:except])
|
134
119
|
options[:except] << self.class.otp_column_name
|
120
|
+
|
135
121
|
super(options)
|
136
122
|
end
|
137
123
|
|
@@ -153,45 +139,42 @@ module ActiveModel
|
|
153
139
|
def authenticate_hotp(code, options = {})
|
154
140
|
hotp = ROTP::HOTP.new(otp_column, digits: otp_digits)
|
155
141
|
result = hotp.verify(code, otp_counter)
|
142
|
+
|
156
143
|
if result && options[:auto_increment]
|
157
144
|
self.otp_counter += 1
|
158
145
|
save if respond_to?(:changed?) && !new_record?
|
159
146
|
end
|
147
|
+
|
160
148
|
result
|
161
149
|
end
|
162
150
|
|
163
151
|
def authenticate_totp(code, options = {})
|
164
|
-
totp = ROTP::TOTP.new(
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
)
|
169
|
-
|
170
|
-
totp.verify(code, drift_behind: drift)
|
171
|
-
else
|
172
|
-
totp.verify(code)
|
152
|
+
totp = ROTP::TOTP.new(otp_column, digits: otp_digits, interval: otp_interval)
|
153
|
+
|
154
|
+
otp_after = public_send(otp_after_column_name) if otp_after_column_name_enabled?
|
155
|
+
|
156
|
+
totp.verify(code, drift_behind: options[:drift] || 0, after: otp_after).tap do |updated_last_otp_at|
|
157
|
+
updated_last_otp_at && otp_after_column_name_enabled? && update(otp_after_column_name => updated_last_otp_at)
|
173
158
|
end
|
174
159
|
end
|
175
160
|
|
161
|
+
def otp_after_column_name_enabled?
|
162
|
+
otp_after_column_name && respond_to?(otp_after_column_name)
|
163
|
+
end
|
164
|
+
|
176
165
|
def hotp_code(options = {})
|
177
166
|
if options[:auto_increment]
|
178
167
|
self.otp_counter += 1
|
179
168
|
save if respond_to?(:changed?) && !new_record?
|
180
169
|
end
|
170
|
+
|
181
171
|
ROTP::HOTP.new(otp_column, digits: otp_digits).at(otp_counter)
|
182
172
|
end
|
183
173
|
|
184
174
|
def totp_code(options = {})
|
185
|
-
time =
|
186
|
-
|
187
|
-
|
188
|
-
options
|
189
|
-
end
|
190
|
-
ROTP::TOTP.new(
|
191
|
-
otp_column,
|
192
|
-
digits: otp_digits,
|
193
|
-
interval: otp_interval
|
194
|
-
).at(time)
|
175
|
+
time = options.is_a?(Hash) ? options.fetch(:time, Time.now) : options
|
176
|
+
|
177
|
+
ROTP::TOTP.new(otp_column, digits: otp_digits, interval: otp_interval).at(time)
|
195
178
|
end
|
196
179
|
|
197
180
|
def authenticate_backup_code(code)
|
@@ -3,6 +3,8 @@
|
|
3
3
|
require 'test_helper'
|
4
4
|
|
5
5
|
class OtpTest < MiniTest::Test
|
6
|
+
include ActiveSupport::Testing::TimeHelpers
|
7
|
+
|
6
8
|
def setup
|
7
9
|
@user = User.new
|
8
10
|
@user.email = 'roberto@heapsource.com'
|
@@ -23,6 +25,10 @@ class OtpTest < MiniTest::Test
|
|
23
25
|
@opt_in = OptInTwoFactor.new
|
24
26
|
@opt_in.email = 'roberto@heapsource.com'
|
25
27
|
@opt_in.run_callbacks :create
|
28
|
+
|
29
|
+
@after_user = AfterUser.new
|
30
|
+
@after_user.email = 'roberto@heapsource.com'
|
31
|
+
@after_user.run_callbacks :create
|
26
32
|
end
|
27
33
|
|
28
34
|
def test_authenticate_with_otp
|
@@ -33,6 +39,23 @@ class OtpTest < MiniTest::Test
|
|
33
39
|
assert @visitor.authenticate_otp(code)
|
34
40
|
end
|
35
41
|
|
42
|
+
def test_authenticate_with_otp_passing_false_or_empty_codes
|
43
|
+
refute @user.authenticate_otp(nil)
|
44
|
+
refute @user.authenticate_otp('')
|
45
|
+
|
46
|
+
refute @visitor.authenticate_otp(nil)
|
47
|
+
refute @visitor.authenticate_otp('')
|
48
|
+
|
49
|
+
refute @member.authenticate_otp(nil)
|
50
|
+
refute @member.authenticate_otp('')
|
51
|
+
|
52
|
+
refute @ar_user.authenticate_otp(nil)
|
53
|
+
refute @ar_user.authenticate_otp('')
|
54
|
+
|
55
|
+
refute @opt_in.authenticate_otp(nil)
|
56
|
+
refute @opt_in.authenticate_otp('')
|
57
|
+
end
|
58
|
+
|
36
59
|
def test_counter_based_otp
|
37
60
|
code = @member.otp_code
|
38
61
|
assert @member.authenticate_otp(code)
|
@@ -71,6 +94,18 @@ class OtpTest < MiniTest::Test
|
|
71
94
|
assert_equal true, @visitor.authenticate_otp(code, drift: 60)
|
72
95
|
end
|
73
96
|
|
97
|
+
def test_authenticate_with_otp_when_after_is_allowed
|
98
|
+
code = @user.otp_code
|
99
|
+
assert_equal true, @user.authenticate_otp(code)
|
100
|
+
assert_equal true, @user.authenticate_otp(code)
|
101
|
+
|
102
|
+
code = @after_user.otp_code
|
103
|
+
assert_equal true, @after_user.authenticate_otp(code)
|
104
|
+
assert_equal false, @after_user.authenticate_otp(code)
|
105
|
+
assert_equal false, @after_user.authenticate_otp('1111111')
|
106
|
+
assert_equal false, @after_user.authenticate_otp(code)
|
107
|
+
end
|
108
|
+
|
74
109
|
def test_authenticate_with_backup_code
|
75
110
|
backup_code = @user.public_send(@user.otp_backup_codes_column_name).first
|
76
111
|
assert_equal true, @user.authenticate_otp(backup_code)
|
@@ -98,7 +133,7 @@ class OtpTest < MiniTest::Test
|
|
98
133
|
|
99
134
|
def test_otp_code_without_specific_length
|
100
135
|
assert_match(/^\d{6}$/, @user.otp_code(2160).to_s)
|
101
|
-
|
136
|
+
assert @user.otp_code(2160).to_s.length <= 6
|
102
137
|
end
|
103
138
|
|
104
139
|
def test_provisioning_uri_with_provided_account
|
@@ -129,13 +164,8 @@ class OtpTest < MiniTest::Test
|
|
129
164
|
&issuer=Example$
|
130
165
|
}x
|
131
166
|
|
132
|
-
assert_match(
|
133
|
-
|
134
|
-
)
|
135
|
-
|
136
|
-
assert_match(
|
137
|
-
account, @visitor.provisioning_uri('roberto', issuer: 'Example')
|
138
|
-
)
|
167
|
+
assert_match account, @user.provisioning_uri('roberto', issuer: 'Example')
|
168
|
+
assert_match account, @visitor.provisioning_uri('roberto', issuer: 'Example')
|
139
169
|
|
140
170
|
assert_match email, @user.provisioning_uri(nil, issuer: 'Example')
|
141
171
|
assert_match email, @visitor.provisioning_uri(nil, issuer: 'Example')
|
@@ -169,8 +199,10 @@ class OtpTest < MiniTest::Test
|
|
169
199
|
@interval_user.run_callbacks :create
|
170
200
|
otp_code = @interval_user.otp_code
|
171
201
|
2.times { assert_match(otp_code, @interval_user.otp_code) }
|
172
|
-
|
173
|
-
|
202
|
+
|
203
|
+
travel 5.seconds do
|
204
|
+
refute_match(otp_code, @interval_user.otp_code)
|
205
|
+
end
|
174
206
|
end
|
175
207
|
|
176
208
|
def test_otp_default_interval
|
@@ -178,8 +210,11 @@ class OtpTest < MiniTest::Test
|
|
178
210
|
@default_interval_user.email = 'roberto@heapsource.com'
|
179
211
|
@default_interval_user.run_callbacks :create
|
180
212
|
otp_code = @default_interval_user.otp_code
|
213
|
+
|
181
214
|
2.times { assert_match(otp_code, @default_interval_user.otp_code) }
|
182
|
-
|
183
|
-
|
215
|
+
|
216
|
+
travel 5.seconds do
|
217
|
+
assert_match(otp_code, @default_interval_user.otp_code)
|
218
|
+
end
|
184
219
|
end
|
185
220
|
end
|
data/test/schema.rb
CHANGED
@@ -24,4 +24,13 @@ ActiveRecord::Schema.define do
|
|
24
24
|
t.string :otp_secret_key
|
25
25
|
t.timestamps
|
26
26
|
end
|
27
|
+
|
28
|
+
create_table :after_users, force: true do |t|
|
29
|
+
t.string :key
|
30
|
+
t.string :email
|
31
|
+
t.integer :otp_counter
|
32
|
+
t.string :otp_secret_key
|
33
|
+
t.integer :last_otp_at
|
34
|
+
t.timestamps
|
35
|
+
end
|
27
36
|
end
|
data/test/test_helper.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_model_otp
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.3.
|
4
|
+
version: 2.3.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Guillermo Iguaran
|
@@ -10,7 +10,7 @@ authors:
|
|
10
10
|
autorequire:
|
11
11
|
bindir: bin
|
12
12
|
cert_chain: []
|
13
|
-
date:
|
13
|
+
date: 2023-04-26 00:00:00.000000000 Z
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
16
16
|
name: activemodel
|
@@ -135,10 +135,12 @@ files:
|
|
135
135
|
- gemfiles/rails_5.2.gemfile
|
136
136
|
- gemfiles/rails_6.0.gemfile
|
137
137
|
- gemfiles/rails_6.1.gemfile
|
138
|
+
- gemfiles/rails_7.0.gemfile
|
138
139
|
- lib/active_model/one_time_password.rb
|
139
140
|
- lib/active_model/otp/version.rb
|
140
141
|
- lib/active_model_otp.rb
|
141
142
|
- test/models/activerecord_user.rb
|
143
|
+
- test/models/after_user.rb
|
142
144
|
- test/models/default_interval_user.rb
|
143
145
|
- test/models/interval_user.rb
|
144
146
|
- test/models/member.rb
|
@@ -167,12 +169,13 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
167
169
|
- !ruby/object:Gem::Version
|
168
170
|
version: '0'
|
169
171
|
requirements: []
|
170
|
-
rubygems_version: 3.
|
172
|
+
rubygems_version: 3.4.1
|
171
173
|
signing_key:
|
172
174
|
specification_version: 4
|
173
175
|
summary: Adds methods to set and authenticate against one time passwords.
|
174
176
|
test_files:
|
175
177
|
- test/models/activerecord_user.rb
|
178
|
+
- test/models/after_user.rb
|
176
179
|
- test/models/default_interval_user.rb
|
177
180
|
- test/models/interval_user.rb
|
178
181
|
- test/models/member.rb
|