attr_redactor 0.1.0
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 +7 -0
- data/.gitignore +6 -0
- data/.travis.yml +17 -0
- data/CHANGELOG.md +3 -0
- data/Gemfile +3 -0
- data/MIT-LICENSE +20 -0
- data/README.md +259 -0
- data/Rakefile +22 -0
- data/attr_redactor.gemspec +52 -0
- data/lib/attr_redactor/adapters/active_record.rb +86 -0
- data/lib/attr_redactor/adapters/data_mapper.rb +21 -0
- data/lib/attr_redactor/version.rb +17 -0
- data/lib/attr_redactor.rb +318 -0
- data/test/active_record_test.rb +187 -0
- data/test/attr_redactor_test.rb +278 -0
- data/test/run.sh +12 -0
- data/test/test_helper.rb +36 -0
- metadata +208 -0
@@ -0,0 +1,318 @@
|
|
1
|
+
require 'hash_redactor'
|
2
|
+
|
3
|
+
# Adds attr_accessors that redact an object's attributes
|
4
|
+
module AttrRedactor
|
5
|
+
autoload :Version, 'attr_redactor/version'
|
6
|
+
|
7
|
+
def self.extended(base) # :nodoc:
|
8
|
+
base.class_eval do
|
9
|
+
include InstanceMethods
|
10
|
+
attr_writer :attr_redactor_options
|
11
|
+
@attr_redactor_options, @redacted_attributes = {}, {}
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# Generates attr_accessors that remove, digest or encrypt values in an attribute that is a hash transparently
|
16
|
+
#
|
17
|
+
# Options
|
18
|
+
# Any other options you specify are passed on to hash_redactor which is used for
|
19
|
+
# redacting, and in turn onto attr_encrypted which it uses for encryption)
|
20
|
+
#
|
21
|
+
# redact: Hash that describes the values to redact in the hash. Should be a
|
22
|
+
# map of the form { key => method }, where key is the key to redact
|
23
|
+
# and method is one of :remove, :digest, :encrypt
|
24
|
+
# eg {
|
25
|
+
# :ssn => :remove,
|
26
|
+
# :email => :digest
|
27
|
+
# :medical_notes => :encrypt,
|
28
|
+
# }
|
29
|
+
#
|
30
|
+
# attribute: The name of the referenced encrypted attribute. For example
|
31
|
+
# <tt>attr_accessor :data, attribute: :safe_data</tt> would generate
|
32
|
+
# an attribute named 'safe_data' to store the redacted data.
|
33
|
+
# This is useful when defining one attribute to encrypt at a time
|
34
|
+
# or when the :prefix and :suffix options aren't enough.
|
35
|
+
# Defaults to nil.
|
36
|
+
#
|
37
|
+
# prefix: A prefix used to generate the name of the referenced redacted attributes.
|
38
|
+
# For example <tt>attr_accessor :data, prefix: 'safe_'</tt> would
|
39
|
+
# generate attributes named 'safe_data' to store the redacted
|
40
|
+
# data hash.
|
41
|
+
# Defaults to 'redacted_'.
|
42
|
+
#
|
43
|
+
# suffix: A suffix used to generate the name of the referenced redacted attributes.
|
44
|
+
# For example <tt>attr_accessor :data, prefix: '', suffix: '_cleaned'</tt>
|
45
|
+
# would generate attributes named 'data_cleaned' to store the
|
46
|
+
# cleaned up data.
|
47
|
+
# Defaults to ''.
|
48
|
+
#
|
49
|
+
# encryption_key: The encryption key to use for encrypted fields.
|
50
|
+
# Defaults to nil. Required if you are using encryption.
|
51
|
+
#
|
52
|
+
# digest_salt: The salt to use for digests
|
53
|
+
# Defaults to ""
|
54
|
+
#
|
55
|
+
#
|
56
|
+
# You can specify your own default options
|
57
|
+
#
|
58
|
+
# class User
|
59
|
+
# attr_redactor_options.merge!(redact: { :ssn => :remove })
|
60
|
+
# attr_redactor :data
|
61
|
+
# end
|
62
|
+
#
|
63
|
+
#
|
64
|
+
# Example
|
65
|
+
#
|
66
|
+
# class User
|
67
|
+
# attr_redactor_options.merge!(encryption_key: 'some secret key')
|
68
|
+
# attr_redactor :data, redact: {
|
69
|
+
# :ssn => :remove,
|
70
|
+
# :email => :digest
|
71
|
+
# :medical_notes => :encrypt,
|
72
|
+
# }
|
73
|
+
# end
|
74
|
+
#
|
75
|
+
# @user = User.new
|
76
|
+
# @user.redacted_data # nil
|
77
|
+
# @user.data? # false
|
78
|
+
# @user.data = { ssn: 'private', email: 'mail@email.com', medical_notes: 'private' }
|
79
|
+
# @user.data? # true
|
80
|
+
# @user.redacted_data # { email_digest: 'XXXXXX', encrypted_medical_notes: 'XXXXXX', encrypted_medical_notes_iv: 'XXXXXXX' }
|
81
|
+
# @user.save!
|
82
|
+
# @user = User.last
|
83
|
+
#
|
84
|
+
# @user.data # { email_digest: 'XXXXXX', medical_notes: 'private' }
|
85
|
+
#
|
86
|
+
# See README for more examples
|
87
|
+
def attr_redactor(*attributes)
|
88
|
+
options = attributes.last.is_a?(Hash) ? attributes.pop : {}
|
89
|
+
options = attr_redactor_default_options.dup.merge!(attr_redactor_options).merge!(options)
|
90
|
+
|
91
|
+
attributes.each do |attribute|
|
92
|
+
redacted_attribute_name = (options[:attribute] ? options[:attribute] : [options[:prefix], attribute, options[:suffix]].join).to_sym
|
93
|
+
|
94
|
+
instance_methods_as_symbols = attribute_instance_methods_as_symbols
|
95
|
+
attr_reader redacted_attribute_name unless instance_methods_as_symbols.include?(redacted_attribute_name)
|
96
|
+
attr_writer redacted_attribute_name unless instance_methods_as_symbols.include?(:"#{redacted_attribute_name}=")
|
97
|
+
|
98
|
+
# Create a redactor for the attribute
|
99
|
+
options[:redactor] = HashRedactor::HashRedactor.new(options)
|
100
|
+
|
101
|
+
define_method(attribute) do
|
102
|
+
instance_variable_get("@#{attribute}") || instance_variable_set("@#{attribute}", unredact(attribute, send(redacted_attribute_name)))
|
103
|
+
end
|
104
|
+
|
105
|
+
define_method("#{attribute}=") do |value|
|
106
|
+
send("#{redacted_attribute_name}=", redact(attribute, value))
|
107
|
+
instance_variable_set("@#{attribute}", value)
|
108
|
+
# replace with redacted/unredacted value immediately
|
109
|
+
instance_variable_set("@#{attribute}", unredact(attribute, send(redacted_attribute_name)))
|
110
|
+
end
|
111
|
+
|
112
|
+
define_method("#{attribute}?") do
|
113
|
+
value = send(attribute)
|
114
|
+
value.respond_to?(:empty?) ? !value.empty? : !!value
|
115
|
+
end
|
116
|
+
|
117
|
+
define_method("#{attribute}_redact_hash") do
|
118
|
+
options = redacted_attributes[attribute]
|
119
|
+
options[:redact]
|
120
|
+
end
|
121
|
+
|
122
|
+
redacted_attributes[attribute.to_sym] = options.merge(attribute: redacted_attribute_name)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
# Default options to use with calls to <tt>attr_redactor</tt>
|
127
|
+
#
|
128
|
+
# It will inherit existing options from its superclass
|
129
|
+
def attr_redactor_options
|
130
|
+
@attr_redactor_options ||= superclass.attr_redactor_options.dup
|
131
|
+
end
|
132
|
+
|
133
|
+
def attr_redactor_default_options
|
134
|
+
{
|
135
|
+
prefix: 'redacted_',
|
136
|
+
suffix: '',
|
137
|
+
if: true,
|
138
|
+
unless: false,
|
139
|
+
marshal: false,
|
140
|
+
marshaler: Marshal,
|
141
|
+
dump_method: 'dump',
|
142
|
+
load_method: 'load',
|
143
|
+
}
|
144
|
+
end
|
145
|
+
|
146
|
+
private :attr_redactor_default_options
|
147
|
+
|
148
|
+
# Checks if an attribute is configured with <tt>attr_redactor</tt>
|
149
|
+
#
|
150
|
+
# Example
|
151
|
+
#
|
152
|
+
# class User
|
153
|
+
# attr_accessor :name
|
154
|
+
# attr_redactor :email
|
155
|
+
# end
|
156
|
+
#
|
157
|
+
# User.attr_redacted?(:name) # false
|
158
|
+
# User.attr_redacted?(:email) # true
|
159
|
+
def attr_redacted?(attribute)
|
160
|
+
redacted_attributes.has_key?(attribute.to_sym)
|
161
|
+
end
|
162
|
+
|
163
|
+
# Decrypts values in the attribute specified
|
164
|
+
#
|
165
|
+
# Example
|
166
|
+
#
|
167
|
+
# class User
|
168
|
+
# attr_redactor :data
|
169
|
+
# end
|
170
|
+
#
|
171
|
+
# data = User.redact(:data, SOME_REDACTED_HASH)
|
172
|
+
def unredact(attribute, redacted_value, options = {})
|
173
|
+
options = redacted_attributes[attribute.to_sym].merge(options)
|
174
|
+
if options[:if] && !options[:unless] && !redacted_value.nil?
|
175
|
+
value = options[:redactor].decrypt(redacted_value, options)
|
176
|
+
value
|
177
|
+
else
|
178
|
+
redacted_value
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
# Redacts for the attribute specified
|
183
|
+
#
|
184
|
+
# Example
|
185
|
+
#
|
186
|
+
# class User
|
187
|
+
# attr_redactor :data
|
188
|
+
# end
|
189
|
+
#
|
190
|
+
# redacted_data = User.redact(:data, { email: 'test@example.com' })
|
191
|
+
def redact(attribute, value, options = {})
|
192
|
+
options = redacted_attributes[attribute.to_sym].merge(options)
|
193
|
+
if options[:if] && !options[:unless] && !value.nil?
|
194
|
+
redacted_value = options[:redactor].redact(value, options)
|
195
|
+
else
|
196
|
+
value
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
# Contains a hash of redacted attributes with virtual attribute names as keys
|
201
|
+
# and their corresponding options as values
|
202
|
+
#
|
203
|
+
# Example
|
204
|
+
#
|
205
|
+
# class User
|
206
|
+
# attr_redactor :data, key: 'my secret key'
|
207
|
+
# end
|
208
|
+
#
|
209
|
+
# User.redacted_attributes # { data: { attribute: 'redacted_data', encryption_key: 'my secret key' } }
|
210
|
+
def redacted_attributes
|
211
|
+
@redacted_attributes ||= superclass.redacted_attributes.dup
|
212
|
+
end
|
213
|
+
|
214
|
+
# Forwards calls to :redact_#{attribute} or :unredact_#{attribute} to the corresponding redact or unredact method
|
215
|
+
# if attribute was configured with attr_redactor
|
216
|
+
#
|
217
|
+
# Example
|
218
|
+
#
|
219
|
+
# class User
|
220
|
+
# attr_redactor :data, key: 'my secret key'
|
221
|
+
# end
|
222
|
+
#
|
223
|
+
# User.redact_data('SOME_ENCRYPTED_EMAIL_STRING')
|
224
|
+
def method_missing(method, *arguments, &block)
|
225
|
+
if method.to_s =~ /^(redact|unredact)_(.+)$/ && attr_redacted?($2)
|
226
|
+
send($1, $2, *arguments)
|
227
|
+
else
|
228
|
+
super
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
module InstanceMethods
|
233
|
+
# Decrypts a value for the attribute specified using options evaluated in the current object's scope
|
234
|
+
#
|
235
|
+
# Example
|
236
|
+
#
|
237
|
+
# class User
|
238
|
+
# attr_accessor :secret_key
|
239
|
+
# attr_redactor :data, key: :secret_key
|
240
|
+
#
|
241
|
+
# def initialize(secret_key)
|
242
|
+
# self.secret_key = secret_key
|
243
|
+
# end
|
244
|
+
# end
|
245
|
+
#
|
246
|
+
# @user = User.new('some-secret-key')
|
247
|
+
# @user.unredact(:data, SOME_REDACTED_HASH)
|
248
|
+
def unredact(attribute, redacted_value)
|
249
|
+
redacted_attributes[attribute.to_sym][:operation] = :unredacting
|
250
|
+
self.class.unredact(attribute, redacted_value, evaluated_attr_redacted_options_for(attribute))
|
251
|
+
end
|
252
|
+
|
253
|
+
# Redacts a value for the attribute specified using options evaluated in the current object's scope
|
254
|
+
#
|
255
|
+
# Example
|
256
|
+
#
|
257
|
+
# class User
|
258
|
+
# attr_accessor :secret_key
|
259
|
+
# attr_redactor :data, key: :secret_key
|
260
|
+
#
|
261
|
+
# def initialize(secret_key)
|
262
|
+
# self.secret_key = secret_key
|
263
|
+
# end
|
264
|
+
# end
|
265
|
+
#
|
266
|
+
# @user = User.new('some-secret-key')
|
267
|
+
# @user.redact(:data, 'test@example.com')
|
268
|
+
def redact(attribute, value)
|
269
|
+
redacted_attributes[attribute.to_sym][:operation] = :redacting
|
270
|
+
self.class.redact(attribute, value, evaluated_attr_redacted_options_for(attribute))
|
271
|
+
end
|
272
|
+
|
273
|
+
# Copies the class level hash of redacted attributes with virtual attribute names as keys
|
274
|
+
# and their corresponding options as values to the instance
|
275
|
+
#
|
276
|
+
def redacted_attributes
|
277
|
+
@redacted_attributes ||= self.class.redacted_attributes.dup
|
278
|
+
end
|
279
|
+
|
280
|
+
protected
|
281
|
+
|
282
|
+
# Returns attr_redactor options evaluated in the current object's scope for the attribute specified
|
283
|
+
def evaluated_attr_redacted_options_for(attribute)
|
284
|
+
evaluated_options = Hash.new
|
285
|
+
attribute_option_value = redacted_attributes[attribute.to_sym][:attribute]
|
286
|
+
redacted_attributes[attribute.to_sym].map do |option, value|
|
287
|
+
evaluated_options[option] = evaluate_attr_redactor_option(value)
|
288
|
+
end
|
289
|
+
|
290
|
+
evaluated_options[:attribute] = attribute_option_value
|
291
|
+
|
292
|
+
evaluated_options
|
293
|
+
end
|
294
|
+
|
295
|
+
# Evaluates symbol (method reference) or proc (responds to call) options
|
296
|
+
#
|
297
|
+
# If the option is not a symbol or proc then the original option is returned
|
298
|
+
def evaluate_attr_redactor_option(option)
|
299
|
+
if option.is_a?(Symbol) && respond_to?(option)
|
300
|
+
send(option)
|
301
|
+
elsif option.respond_to?(:call)
|
302
|
+
option.call(self)
|
303
|
+
else
|
304
|
+
option
|
305
|
+
end
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
protected
|
310
|
+
|
311
|
+
def attribute_instance_methods_as_symbols
|
312
|
+
instance_methods.collect { |method| method.to_sym }
|
313
|
+
end
|
314
|
+
|
315
|
+
end
|
316
|
+
|
317
|
+
|
318
|
+
Dir[File.join(File.dirname(__FILE__), 'attr_redactor', 'adapters', '*.rb')].each { |adapter| require adapter }
|
@@ -0,0 +1,187 @@
|
|
1
|
+
require_relative 'test_helper'
|
2
|
+
|
3
|
+
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
|
4
|
+
|
5
|
+
def create_tables
|
6
|
+
silence_stream(STDOUT) do
|
7
|
+
ActiveRecord::Schema.define(version: 1) do
|
8
|
+
create_table :people do |t|
|
9
|
+
t.string :redacted_data
|
10
|
+
t.string :login
|
11
|
+
t.boolean :is_admin
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# The table needs to exist before defining the class
|
18
|
+
create_tables
|
19
|
+
|
20
|
+
ActiveRecord::MissingAttributeError = ActiveModel::MissingAttributeError unless defined?(ActiveRecord::MissingAttributeError)
|
21
|
+
|
22
|
+
if ::ActiveRecord::VERSION::STRING > "4.0"
|
23
|
+
module Rack
|
24
|
+
module Test
|
25
|
+
class UploadedFile; end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
require 'action_controller/metal/strong_parameters'
|
30
|
+
end
|
31
|
+
|
32
|
+
class Person < ActiveRecord::Base
|
33
|
+
self.attr_redactor_options[:encryption_key] = "a very very very long secure, very secure key"
|
34
|
+
self.attr_redactor_options[:redact] = {
|
35
|
+
"ssn" => :remove,
|
36
|
+
"email" => :digest,
|
37
|
+
"history" => :encrypt
|
38
|
+
}
|
39
|
+
|
40
|
+
serialize :redacted_data, Hash
|
41
|
+
attr_redactor :data
|
42
|
+
|
43
|
+
attr_protected :login if ::ActiveRecord::VERSION::STRING < "4.0"
|
44
|
+
end
|
45
|
+
|
46
|
+
class PersonWithValidation < Person
|
47
|
+
validates_presence_of :data
|
48
|
+
end
|
49
|
+
|
50
|
+
class ActiveRecordTest < Minitest::Test
|
51
|
+
|
52
|
+
def setup
|
53
|
+
ActiveRecord::Base.connection.tables.each { |table| ActiveRecord::Base.connection.drop_table(table) }
|
54
|
+
create_tables
|
55
|
+
end
|
56
|
+
|
57
|
+
def test_should_marshal_and_redact_data
|
58
|
+
@person = Person.create :data => { "ssn" => '12345', "email" => 'some@address.com',
|
59
|
+
"history" => 'A big secret' }
|
60
|
+
refute_nil @person.redacted_data
|
61
|
+
refute_equal @person.data, @person.redacted_data
|
62
|
+
assert_equal @person.data, Person.first.data
|
63
|
+
end
|
64
|
+
|
65
|
+
def test_should_validate_presence_of_data
|
66
|
+
@person = PersonWithValidation.new
|
67
|
+
assert !@person.valid?
|
68
|
+
assert !@person.errors[:data].empty? || @person.errors.on(:data)
|
69
|
+
end
|
70
|
+
|
71
|
+
def test_should_create_changed_predicate
|
72
|
+
data_to_redact = { "ssn" => '12345', "email" => 'some@address.com',
|
73
|
+
"history" => 'A big secret' }
|
74
|
+
alternate_data = { "ssn" => '54321', "email" => 'some@address.com',
|
75
|
+
"history" => 'A really big secret' }
|
76
|
+
|
77
|
+
person = Person.create!(data: data_to_redact)
|
78
|
+
refute person.data_changed?
|
79
|
+
person.data = data_to_redact
|
80
|
+
refute person.data_changed?
|
81
|
+
person.data = alternate_data
|
82
|
+
assert person.data_changed?
|
83
|
+
person.save!
|
84
|
+
person.data = alternate_data
|
85
|
+
refute person.data_changed?
|
86
|
+
person.data = nil
|
87
|
+
assert person.data_changed?
|
88
|
+
end
|
89
|
+
|
90
|
+
def test_changing_hash_should_not_cause_changed
|
91
|
+
# It's misleading to do so as the change won't be saved
|
92
|
+
data_to_redact = { "ssn" => '12345', "email" => 'some@address.com',
|
93
|
+
"history" => 'A big secret' }
|
94
|
+
|
95
|
+
person = Person.create!(data: data_to_redact)
|
96
|
+
refute person.data_changed?
|
97
|
+
person.data["email"] = 'anew@email.com'
|
98
|
+
refute person.data_changed?
|
99
|
+
end
|
100
|
+
|
101
|
+
def test_should_create_was_predicate
|
102
|
+
data_to_redact = { "ssn" => '12345', "email" => 'some@address.com',
|
103
|
+
"history" => 'A big secret' }
|
104
|
+
|
105
|
+
alternate_data = { "ssn" => '54321', "email" => 'some@address.com',
|
106
|
+
"history" => 'A really big secret' }
|
107
|
+
|
108
|
+
person = Person.create!(data: data_to_redact)
|
109
|
+
person.data = alternate_data
|
110
|
+
assert_equal person.unredact(:data, person.redact(:data, data_to_redact)), person.data_was
|
111
|
+
end
|
112
|
+
|
113
|
+
if ::ActiveRecord::VERSION::STRING > "4.0"
|
114
|
+
def test_should_assign_attributes
|
115
|
+
@user = Person.new(login: 'login', is_admin: false)
|
116
|
+
@user.attributes = ActionController::Parameters.new(login: 'modified', is_admin: true).permit(:login)
|
117
|
+
assert_equal 'modified', @user.login
|
118
|
+
end
|
119
|
+
|
120
|
+
def test_should_not_assign_protected_attributes
|
121
|
+
@user = Person.new(login: 'login', is_admin: false)
|
122
|
+
@user.attributes = ActionController::Parameters.new(login: 'modified', is_admin: true).permit(:login)
|
123
|
+
assert !@user.is_admin?
|
124
|
+
end
|
125
|
+
|
126
|
+
def test_should_raise_exception_if_not_permitted
|
127
|
+
@user = Person.new(login: 'login', is_admin: false)
|
128
|
+
assert_raises ActiveModel::ForbiddenAttributesError do
|
129
|
+
@user.attributes = ActionController::Parameters.new(login: 'modified', is_admin: true)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def test_should_raise_exception_on_init_if_not_permitted
|
134
|
+
assert_raises ActiveModel::ForbiddenAttributesError do
|
135
|
+
@user = Person.new ActionController::Parameters.new(login: 'modified', is_admin: true)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
else
|
139
|
+
def test_should_assign_attributes
|
140
|
+
@user = Person.new(login: 'login', is_admin: false)
|
141
|
+
@user.attributes = { login: 'modified', is_admin: true }
|
142
|
+
assert @user.is_admin
|
143
|
+
end
|
144
|
+
|
145
|
+
def test_should_not_assign_protected_attributes
|
146
|
+
@user = Person.new(login: 'login', is_admin: false)
|
147
|
+
@user.attributes = { login: 'modified', is_admin: true }
|
148
|
+
assert_nil @user.login
|
149
|
+
end
|
150
|
+
|
151
|
+
def test_should_assign_protected_attributes
|
152
|
+
@user = Person.new(login: 'login', is_admin: false)
|
153
|
+
if ::ActiveRecord::VERSION::STRING > "3.1"
|
154
|
+
@user.send(:assign_attributes, { login: 'modified', is_admin: true }, without_protection: true)
|
155
|
+
else
|
156
|
+
@user.send(:attributes=, { login: 'modified', is_admin: true }, false)
|
157
|
+
end
|
158
|
+
assert_equal 'modified', @user.login
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def test_should_allow_assignment_of_nil_attributes
|
163
|
+
@person = Person.new
|
164
|
+
assert_nil(@person.attributes = nil)
|
165
|
+
end
|
166
|
+
|
167
|
+
if ::ActiveRecord::VERSION::STRING > "3.1"
|
168
|
+
def test_should_allow_assign_attributes_with_nil
|
169
|
+
@person = Person.new
|
170
|
+
assert_nil(@person.assign_attributes nil)
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
# See https://github.com/attr-encrypted/attr_encrypted/issues/68
|
175
|
+
def test_should_invalidate_virtual_attributes_on_reload
|
176
|
+
old_data = { "history" => 'Itself' }
|
177
|
+
new_data = { "history" => 'Repeating itself' }
|
178
|
+
p = Person.create!(data: old_data)
|
179
|
+
assert_equal p.data["history"], old_data["history"]
|
180
|
+
p.data = new_data
|
181
|
+
assert_equal p.data["history"], new_data["history"]
|
182
|
+
|
183
|
+
result = p.reload
|
184
|
+
assert_equal p, result
|
185
|
+
assert_equal p.data["history"], old_data["history"]
|
186
|
+
end
|
187
|
+
end
|