attr_masker 0.1.0 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/tests.yml +91 -0
  3. data/.gitignore +5 -1
  4. data/.rubocop.yml +13 -1069
  5. data/CHANGELOG.adoc +31 -0
  6. data/Gemfile +5 -0
  7. data/README.adoc +81 -30
  8. data/Rakefile +0 -27
  9. data/attr_masker.gemspec +15 -10
  10. data/bin/console +14 -0
  11. data/bin/rake +29 -0
  12. data/bin/rspec +29 -0
  13. data/bin/rubocop +29 -0
  14. data/bin/setup +9 -0
  15. data/gemfiles/Rails-4.2.gemfile +2 -3
  16. data/gemfiles/Rails-5.0.gemfile +2 -3
  17. data/gemfiles/Rails-5.1.gemfile +2 -3
  18. data/gemfiles/Rails-5.2.gemfile +4 -0
  19. data/gemfiles/Rails-6.0.gemfile +3 -0
  20. data/gemfiles/Rails-6.1.gemfile +3 -0
  21. data/gemfiles/Rails-head.gemfile +1 -3
  22. data/gemfiles/common.gemfile +4 -0
  23. data/lib/attr_masker.rb +6 -210
  24. data/lib/attr_masker/attribute.rb +80 -0
  25. data/lib/attr_masker/error.rb +1 -0
  26. data/lib/attr_masker/maskers/replacing.rb +20 -3
  27. data/lib/attr_masker/maskers/simple.rb +20 -5
  28. data/lib/attr_masker/model.rb +143 -0
  29. data/lib/attr_masker/performer.rb +56 -17
  30. data/lib/attr_masker/version.rb +1 -16
  31. data/lib/tasks/db.rake +13 -4
  32. data/spec/dummy/app/models/non_persisted_model.rb +2 -0
  33. data/spec/dummy/config/attr_masker.rb +1 -0
  34. data/spec/dummy/config/mongoid.yml +33 -0
  35. data/spec/dummy/config/routes.rb +0 -1
  36. data/spec/dummy/db/schema.rb +1 -0
  37. data/spec/features/active_record_spec.rb +97 -0
  38. data/spec/features/mongoid_spec.rb +36 -0
  39. data/spec/features/shared_examples.rb +382 -0
  40. data/spec/spec_helper.rb +26 -3
  41. data/spec/support/00_control_constants.rb +2 -0
  42. data/spec/support/10_mongoid_env.rb +9 -0
  43. data/spec/support/20_combustion.rb +10 -0
  44. data/spec/support/db_cleaner.rb +13 -2
  45. data/spec/support/force_config_file_reload.rb +9 -0
  46. data/spec/support/rake.rb +1 -1
  47. data/spec/unit/attribute_spec.rb +210 -0
  48. data/spec/{maskers → unit/maskers}/replacing_spec.rb +0 -0
  49. data/spec/{maskers → unit/maskers}/simple_spec.rb +2 -2
  50. data/spec/unit/model_spec.rb +12 -0
  51. data/spec/unit/rake_task_spec.rb +30 -0
  52. metadata +139 -32
  53. data/.travis.yml +0 -32
  54. data/gemfiles/Rails-4.0.gemfile +0 -5
  55. data/gemfiles/Rails-4.1.gemfile +0 -5
  56. data/spec/features_spec.rb +0 -203
  57. data/spec/support/0_combustion.rb +0 -5
@@ -1,67 +1,106 @@
1
1
  # (c) 2017 Ribose Inc.
2
2
  #
3
+
3
4
  module AttrMasker
4
5
  module Performer
5
- class ActiveRecord
6
+ class Base
6
7
  def mask
7
- unless defined? ::ActiveRecord
8
- raise AttrMasker::Error, "ActiveRecord undefined. Nothing to do!"
9
- end
10
-
11
8
  # Do not want production environment to be masked!
12
9
  #
13
10
  if Rails.env.production?
14
- raise AttrMasker::Error, "Attempted to run in production environment."
11
+ unless ENV["FORCE_MASK"]
12
+ msg = "Attempted to run in production environment."
13
+ raise AttrMasker::Error, msg
14
+ end
15
15
  end
16
16
 
17
17
  all_models.each do |klass|
18
18
  next if klass.masker_attributes.empty?
19
+
19
20
  mask_class(klass)
20
21
  end
21
22
  end
22
23
 
23
24
  private
24
25
 
26
+ # Mask all objects of a class in batches to not run out of memory!
27
+ #--
28
+ # rubocop:todo Metrics/MethodLength
25
29
  def mask_class(klass)
26
30
  progressbar_for_model(klass) do |bar|
27
- klass.all.each do |model|
28
- mask_object model
29
- bar.increment
31
+ if klass.all.unscoped.respond_to?(:find_each)
32
+ klass.all.unscoped.find_each(batch_size: 1000) do |model|
33
+ mask_object model
34
+ bar.increment
35
+ end
36
+ else
37
+ klass.all.unscoped.each do |model|
38
+ mask_object model
39
+ bar.increment
40
+ end
30
41
  end
31
42
  end
32
43
  end
44
+ # rubocop:enable Metrics/MethodLength
33
45
 
34
46
  # For each masker attribute, mask it, and save it!
35
47
  #
36
48
  def mask_object(instance)
37
49
  klass = instance.class
38
50
 
39
- updates = klass.masker_attributes.reduce({}) do |acc, masker_attr|
40
- attr_name = masker_attr[0]
41
- column_name = masker_attr[1][:column_name] || attr_name
42
- masker_value = instance.mask(attr_name)
43
- acc.merge!(column_name => masker_value)
51
+ updates = klass.masker_attributes.values.reduce({}) do |acc, attribute|
52
+ next acc unless attribute.should_mask?(instance)
53
+
54
+ attribute.mask(instance)
55
+ acc.merge! attribute.masked_attributes_new_values(instance)
44
56
  end
45
57
 
46
- klass.all.update(instance.id, updates)
58
+ make_update instance, updates unless updates.empty?
47
59
  end
48
60
 
49
61
  def progressbar_for_model(klass)
50
62
  bar = ProgressBar.create(
51
63
  title: klass.name,
52
- total: klass.count,
64
+ total: klass.unscoped.count,
53
65
  throttle_rate: 0.1,
54
- format: %q[%t %c/%C (%j%%) %B %E],
66
+ format: "%t %c/%C (%j%%) %B %E",
55
67
  )
56
68
 
57
69
  yield bar
58
70
  ensure
59
71
  bar.finish
60
72
  end
73
+ end
74
+
75
+ class ActiveRecord < Base
76
+ def dependencies_available?
77
+ defined? ::ActiveRecord
78
+ end
61
79
 
62
80
  def all_models
63
81
  ::ActiveRecord::Base.descendants.select(&:table_exists?)
64
82
  end
83
+
84
+ #--
85
+ # rubocop:disable Rails/SkipsModelValidations
86
+ def make_update(instance, updates)
87
+ instance.class.all.unscoped.where(id: instance.id).update_all(updates)
88
+ end
89
+ # rubocop:enable Rails/SkipsModelValidations
90
+ end
91
+
92
+ class Mongoid < Base
93
+ def dependencies_available?
94
+ defined? ::Mongoid
95
+ end
96
+
97
+ def all_models
98
+ ::Mongoid.models
99
+ end
100
+
101
+ def make_update(instance, updates)
102
+ instance.class.all.unscoped.where(id: instance.id).update(updates)
103
+ end
65
104
  end
66
105
  end
67
106
  end
@@ -2,20 +2,5 @@
2
2
  #
3
3
 
4
4
  module AttrMasker
5
- # Contains information about this gem's version
6
- module Version
7
- MAJOR = 0
8
- MINOR = 1
9
- PATCH = 0
10
-
11
- # Returns a version string by joining <tt>MAJOR</tt>, <tt>MINOR</tt>, and
12
- # <tt>PATCH</tt> with <tt>'.'</tt>
13
- #
14
- # Example
15
- #
16
- # Version.string # '1.0.2'
17
- def self.string
18
- [MAJOR, MINOR, PATCH].join(".")
19
- end
20
- end
5
+ VERSION = "0.3.1".freeze
21
6
  end
data/lib/tasks/db.rake CHANGED
@@ -1,9 +1,6 @@
1
1
  # (c) 2017 Ribose Inc.
2
2
  #
3
3
 
4
- # Hashrocket style looks better when describing task dependencies.
5
- # rubocop:disable Style/HashSyntax
6
-
7
4
  namespace :db do
8
5
  desc "Mask every DB record according to rules set up in the respective " \
9
6
  "ActiveRecord"
@@ -16,6 +13,18 @@ namespace :db do
16
13
  # http://stackoverflow.com/questions/14163938/activerecordconnectionnotestablished-within-a-rake-task
17
14
  #
18
15
  task :mask => :environment do
19
- AttrMasker::Performer::ActiveRecord.new.mask
16
+ Rails.application.eager_load!
17
+
18
+ config_file = Rails.root.join("config", "attr_masker.rb").to_s
19
+ require config_file if File.file?(config_file)
20
+
21
+ performers = AttrMasker::Performer::Base.descendants.map(&:new)
22
+ performers.select!(&:dependencies_available?)
23
+
24
+ if performers.empty?
25
+ raise AttrMasker::Error, "No supported database!"
26
+ end
27
+
28
+ performers.each(&:mask)
20
29
  end
21
30
  end
@@ -0,0 +1,2 @@
1
+ class NonPersistedModel
2
+ end
@@ -0,0 +1 @@
1
+ $CONFIG_LOADED_AT = Time.now
@@ -0,0 +1,33 @@
1
+ # Heavily based on Mongoid's test suite
2
+ # https://github.com/mongodb/mongoid/blob/v6.2.0/spec/config/mongoid.yml
3
+ test:
4
+ clients:
5
+ default:
6
+ database: attr_masker_test
7
+ hosts:
8
+ - <%=ENV["MONGOID_SPEC_HOST"]%>:<%=ENV["MONGOID_SPEC_PORT"]%>
9
+ options:
10
+ auth_source: "admin"
11
+ read:
12
+ mode: :primary_preferred
13
+ tag_sets:
14
+ - use: web
15
+ max_pool_size: 1
16
+ reports:
17
+ database: reports
18
+ hosts:
19
+ - <%=ENV["MONGOID_SPEC_HOST"]%>:<%=ENV["MONGOID_SPEC_PORT"]%>
20
+ options:
21
+ user: "mongoid-user"
22
+ password: "password"
23
+ auth_source: "admin"
24
+ options:
25
+ include_root_in_json: false
26
+ include_type_for_serialization: false
27
+ preload_models: false
28
+ scope_overwrite_exception: false
29
+ raise_not_found_error: true
30
+ use_activesupport_time_zone: true
31
+ use_utc: false
32
+ log_level: :warn
33
+ app_name: 'testing'
@@ -1,3 +1,2 @@
1
1
  Rails.application.routes.draw do
2
- #
3
2
  end
@@ -3,6 +3,7 @@ ActiveRecord::Schema.define do
3
3
  t.string :first_name
4
4
  t.string :last_name
5
5
  t.string :email
6
+ t.text :avatar
6
7
  t.timestamps null: false
7
8
  end
8
9
  end
@@ -0,0 +1,97 @@
1
+ # (c) 2017 Ribose Inc.
2
+ #
3
+
4
+ require_relative "shared_examples"
5
+
6
+ RSpec.describe "Attr Masker gem", :suppress_progressbar do
7
+ context "when used with ActiveRecord" do
8
+ before do
9
+ if WITHOUT_ACTIVE_RECORD
10
+ expect(defined?(::ActiveRecord)).to be(nil)
11
+ skip "Active Record specs disabled with WITHOUT=activerecord shell " \
12
+ "variable"
13
+ end
14
+ end
15
+
16
+ # For performance reasons, Rails features its own DescendantsTracker,
17
+ # which stores parent-child class relationships, and uses it to find
18
+ # subclasses instead of crawling the ObjectSpace.
19
+ #
20
+ # The drawback is that anonymous classes are never garbage collected,
21
+ # because there is always at least one reference, which is held by that
22
+ # tracker. Therefore, the return value of ActiveRecord::Base.descendants
23
+ # method call is typically polluted by anonymous classes created by this
24
+ # test suite. What is worse, that means that tests depend on each other.
25
+ #
26
+ # Till now, we used to stub ActiveRecord::Base.descendants method in specs
27
+ # where it matters, but that did not stop anonymous classes from being
28
+ # carried over to other examples, making the whole test suite quite
29
+ # fragile. And indeed, issues have been observed with Rails 5.2.
30
+ #
31
+ # This commit introduces a very different approach. Anonymous classes are
32
+ # removed from DescendantsTracker when respective test example is done.
33
+ # Nothing is carried over, no stubbing is necessary. It must be noted
34
+ # though that the new approach relies on Rails private APIs. But
35
+ # fortunately, DescendantsTracker is modified extremely rarely (no changes
36
+ # to AST since 2012), therefore I expect that this new approach will
37
+ # require much less maintenance than stubbing we did.
38
+ #
39
+ # -----
40
+ # 2020-01
41
+ #
42
+ # From Rails 6.0, DescendantsTracker uses weak references, and no longer
43
+ # blocks garbage collection of anonymous classes. See:
44
+ # https://github.com/rails/rails/pull/31442
45
+ #
46
+ # However, instances of these classes, which are bound to example life cycle
47
+ # via #let helpers, also hold the references, hence garbage collection must
48
+ # be postponed till example life cycle ends.
49
+ #
50
+ # Consequently, #after hooks cannot be used, as they are run too early for
51
+ # this purpose, but fortunately this can be worked around by
52
+ # before(:example) + after(:all) combo.
53
+ #
54
+ # -----
55
+ # 2020-01 part 2
56
+ #
57
+ # Not really. Garbage Collector cannot be trusted. It is not guaranteed
58
+ # to work consistently in different Ruby versions, and in fact is a cause
59
+ # of build failures in case of Rails 6.0 on Ruby 2.5.7 when run with
60
+ # WITHOUT=mongoid environment variable set.
61
+ #
62
+ # Therefore, manual sweep is still necessary in Ruby 6+.
63
+ after do
64
+ if defined?(::ActiveRecord::Base)
65
+ if ::ActiveSupport.gem_version < Gem::Version.new("6.0.0")
66
+ ::ActiveSupport::DescendantsTracker.
67
+ class_variable_get("@@direct_descendants")[::ActiveRecord::Base].
68
+ delete(user_class_definition)
69
+ else
70
+ ::ActiveSupport::DescendantsTracker.
71
+ class_variable_get("@@direct_descendants")[::ActiveRecord::Base].
72
+ instance_variable_get("@refs").
73
+ delete(user_class_definition)
74
+ end
75
+ end
76
+ end
77
+
78
+ # Rails 5.2 seems to reset connection shortly after Combustion gets its job,
79
+ # causing in-memory database to be dropped. Hence, schema is loaded
80
+ # once again here.
81
+ before(:all) do
82
+ if defined?(::ActiveRecord::Base)
83
+ schema_path = File.expand_path("../dummy/db/schema.rb", __dir__)
84
+ load(schema_path)
85
+ end
86
+ end
87
+
88
+ # No point in using ApplicationRecord here.
89
+ # rubocop:disable Rails/ApplicationRecord
90
+
91
+ let(:user_class_definition) { Class.new(ActiveRecord::Base) }
92
+
93
+ # rubocop:enable Rails/ApplicationRecord
94
+
95
+ include_examples "Attr Masker gem feature specs"
96
+ end
97
+ end
@@ -0,0 +1,36 @@
1
+ # (c) 2017 Ribose Inc.
2
+ #
3
+
4
+ require_relative "shared_examples"
5
+
6
+ RSpec.describe "Attr Masker gem", :suppress_progressbar do
7
+ context "when used with Mongoid" do
8
+ before do
9
+ if WITHOUT_MONGOID
10
+ expect(defined?(::Mongoid)).to be(nil)
11
+ skip "Mongoid specs disabled with WITHOUT=mongoid shell variable"
12
+ end
13
+ end
14
+
15
+ after do
16
+ # Remove the example-specific model from Mongoid.models
17
+ ::Mongoid.models.delete(user_class_definition) if defined?(::Mongoid)
18
+ end
19
+
20
+ let(:user_class_definition) do
21
+ Class.new do
22
+ include Mongoid::Document
23
+ include Mongoid::Timestamps
24
+
25
+ store_in collection: "users"
26
+
27
+ field :first_name
28
+ field :last_name
29
+ field :email
30
+ field :avatar
31
+ end
32
+ end
33
+
34
+ include_examples "Attr Masker gem feature specs"
35
+ end
36
+ end
@@ -0,0 +1,382 @@
1
+ # (c) 2017 Ribose Inc.
2
+ #
3
+
4
+ # No point in ensuring a trailing comma in multiline argument lists here.
5
+ # rubocop:disable Style/TrailingCommaInArguments
6
+
7
+ require "spec_helper"
8
+
9
+ RSpec.shared_examples "Attr Masker gem feature specs" do
10
+ before do
11
+ stub_const "User", user_class_definition
12
+
13
+ User.class_eval do
14
+ def jedi?
15
+ email.ends_with? "@jedi.example.test"
16
+ end
17
+ end
18
+ end
19
+
20
+ let!(:han) do
21
+ User.create!(
22
+ first_name: "Han",
23
+ last_name: "Solo",
24
+ email: "han@example.test",
25
+ avatar: Marshal.dump("Millenium Falcon photo"),
26
+ )
27
+ end
28
+
29
+ let!(:luke) do
30
+ User.create!(
31
+ first_name: "Luke",
32
+ last_name: "Skywalker",
33
+ email: "luke@jedi.example.test",
34
+ avatar: Marshal.dump("photo with a light saber"),
35
+ )
36
+ end
37
+
38
+ example "Masking a single text attribute with default options" do
39
+ User.class_eval do
40
+ attr_masker :last_name
41
+ end
42
+
43
+ expect { run_rake_task }.not_to(change { User.count })
44
+
45
+ [han, luke].each do |record|
46
+ expect { record.reload }.to(
47
+ change { record.last_name }.to("(redacted)") &
48
+ preserve { record.first_name } &
49
+ preserve { record.email }
50
+ )
51
+ end
52
+ end
53
+
54
+ example "Specifying multiple attributes in an attr_masker declaration" do
55
+ User.class_eval do
56
+ attr_masker :first_name, :last_name
57
+ end
58
+
59
+ expect { run_rake_task }.not_to(change { User.count })
60
+
61
+ [han, luke].each do |record|
62
+ expect { record.reload }.to(
63
+ change { record.first_name }.to("(redacted)") &
64
+ change { record.last_name }.to("(redacted)") &
65
+ preserve { record.email }
66
+ )
67
+ end
68
+ end
69
+
70
+ example "Skipping some records when a symbol is passed to :if option" do
71
+ User.class_eval do
72
+ attr_masker :first_name, :last_name, if: :jedi?
73
+ end
74
+
75
+ expect { run_rake_task }.not_to(change { User.count })
76
+
77
+ expect { han.reload }.to(
78
+ preserve { han.first_name } &
79
+ preserve { han.last_name } &
80
+ preserve { han.email }
81
+ )
82
+
83
+ expect { luke.reload }.to(
84
+ change { luke.first_name }.to("(redacted)") &
85
+ change { luke.last_name }.to("(redacted)") &
86
+ preserve { luke.email }
87
+ )
88
+ end
89
+
90
+ example "Skipping some records when a lambda is passed to :if option" do
91
+ User.class_eval do
92
+ attr_masker :first_name, :last_name, if: ->(r) { r.jedi? }
93
+ end
94
+
95
+ expect { run_rake_task }.not_to(change { User.count })
96
+
97
+ expect { han.reload }.to(
98
+ preserve { han.first_name } &
99
+ preserve { han.last_name } &
100
+ preserve { han.email }
101
+ )
102
+
103
+ expect { luke.reload }.to(
104
+ change { luke.first_name }.to("(redacted)") &
105
+ change { luke.last_name }.to("(redacted)") &
106
+ preserve { luke.email }
107
+ )
108
+ end
109
+
110
+ example "Skipping some records when a symbol is passed to :unless option" do
111
+ User.class_eval do
112
+ attr_masker :first_name, :last_name, unless: :jedi?
113
+ end
114
+
115
+ expect { run_rake_task }.not_to(change { User.count })
116
+
117
+ expect { han.reload }.to(
118
+ change { han.first_name }.to("(redacted)") &
119
+ change { han.last_name }.to("(redacted)") &
120
+ preserve { han.email }
121
+ )
122
+
123
+ expect { luke.reload }.to(
124
+ preserve { luke.first_name } &
125
+ preserve { luke.last_name } &
126
+ preserve { luke.email }
127
+ )
128
+ end
129
+
130
+ example "Skipping some records when a lambda is passed to :unless option" do
131
+ User.class_eval do
132
+ attr_masker :first_name, :last_name, unless: ->(r) { r.jedi? }
133
+ end
134
+
135
+ expect { run_rake_task }.not_to(change { User.count })
136
+
137
+ expect { han.reload }.to(
138
+ change { han.first_name }.to("(redacted)") &
139
+ change { han.last_name }.to("(redacted)") &
140
+ preserve { han.email }
141
+ )
142
+
143
+ expect { luke.reload }.to(
144
+ preserve { luke.first_name } &
145
+ preserve { luke.last_name } &
146
+ preserve { luke.email }
147
+ )
148
+ end
149
+
150
+ example "Using a custom masker" do
151
+ reverse_masker = ->(value:, **_) do
152
+ value.reverse
153
+ end
154
+
155
+ upcase_masker = ->(value:, **_) do
156
+ value.upcase
157
+ end
158
+
159
+ User.class_eval do
160
+ attr_masker :first_name, masker: reverse_masker
161
+ attr_masker :last_name, masker: upcase_masker
162
+ end
163
+
164
+ expect { run_rake_task }.not_to(change { User.count })
165
+
166
+ expect { han.reload }.to(
167
+ change { han.first_name }.to("naH") &
168
+ change { han.last_name }.to("SOLO") &
169
+ preserve { han.email }
170
+ )
171
+
172
+ expect { luke.reload }.to(
173
+ change { luke.first_name }.to("ekuL") &
174
+ change { luke.last_name }.to("SKYWALKER") &
175
+ preserve { luke.email }
176
+ )
177
+ end
178
+
179
+ example "Using a custom masker with model" do
180
+ first_name_masker = ->(value:, model:, **_) do
181
+ value.reverse + model.email
182
+ end
183
+
184
+ last_name_masker = ->(value:, model:, **_) do
185
+ model.email + value.upcase
186
+ end
187
+
188
+ User.class_eval do
189
+ attr_masker :first_name, masker: first_name_masker
190
+ attr_masker :last_name, masker: last_name_masker
191
+ end
192
+
193
+ expect { run_rake_task }.not_to(change { User.count })
194
+
195
+ expect { han.reload }.to(
196
+ change { han.first_name }.to("naHhan@example.test") &
197
+ change { han.last_name }.to("han@example.testSOLO") &
198
+ preserve { han.email }
199
+ )
200
+
201
+ expect { luke.reload }.to(
202
+ change { luke.first_name }.to("ekuLluke@jedi.example.test") &
203
+ change { luke.last_name }.to("luke@jedi.example.testSKYWALKER") &
204
+ preserve { luke.email }
205
+ )
206
+ end
207
+
208
+ example "Masked value is assigned via attribute writer" do
209
+ User.class_eval do
210
+ attr_masker :first_name, :last_name
211
+
212
+ def first_name=(value)
213
+ self[:first_name] = "#{value} with side effects"
214
+ end
215
+
216
+ def last_name=(value)
217
+ self[:last_name] = value
218
+ end
219
+ end
220
+
221
+ expect { run_rake_task }.not_to(change { User.count })
222
+
223
+ expect { han.reload }.to(
224
+ change { han.first_name }.to("(redacted) with side effects") &
225
+ change { han.last_name }.to("(redacted)") &
226
+ preserve { han.email }
227
+ )
228
+
229
+ expect { luke.reload }.to(
230
+ change { luke.first_name }.to("(redacted) with side effects") &
231
+ change { luke.last_name }.to("(redacted)") &
232
+ preserve { luke.email }
233
+ )
234
+ end
235
+
236
+ example "Masking a marshalled attribute" do
237
+ User.class_eval do
238
+ attr_masker :avatar, marshal: true
239
+ end
240
+
241
+ expect { run_rake_task }.not_to(change { User.count })
242
+
243
+ expect { han.reload }.to(
244
+ preserve { han.first_name } &
245
+ preserve { han.last_name } &
246
+ preserve { han.email } &
247
+ change { han.avatar }
248
+ )
249
+
250
+ expect(han.avatar).to eq(Marshal.dump("(redacted)"))
251
+
252
+ expect { luke.reload }.to(
253
+ preserve { luke.first_name } &
254
+ preserve { luke.last_name } &
255
+ preserve { luke.email } &
256
+ change { luke.avatar }
257
+ )
258
+
259
+ expect(luke.avatar).to eq(Marshal.dump("(redacted)"))
260
+ end
261
+
262
+ example "Masking a marshalled attribute with a custom marshaller" do
263
+ module CustomMarshal
264
+ module_function
265
+
266
+ def load_marshalled(*args)
267
+ Marshal.load(*args) # rubocop:disable Security/MarshalLoad
268
+ end
269
+
270
+ def dump_json(*args)
271
+ JSON.dump(json: args)
272
+ end
273
+ end
274
+
275
+ User.class_eval do
276
+ attr_masker(
277
+ :avatar,
278
+ marshal: true,
279
+ marshaler: CustomMarshal,
280
+ load_method: :load_marshalled,
281
+ dump_method: :dump_json,
282
+ )
283
+ end
284
+
285
+ expect { run_rake_task }.not_to(change { User.count })
286
+
287
+ expect { han.reload }.to(
288
+ preserve { han.first_name } &
289
+ preserve { han.last_name } &
290
+ preserve { han.email } &
291
+ change { han.avatar }
292
+ )
293
+
294
+ expect(han.avatar).to eq({ json: ["(redacted)"] }.to_json)
295
+
296
+ expect { luke.reload }.to(
297
+ preserve { luke.first_name } &
298
+ preserve { luke.last_name } &
299
+ preserve { luke.email } &
300
+ change { luke.avatar }
301
+ )
302
+
303
+ expect(luke.avatar).to eq({ json: ["(redacted)"] }.to_json)
304
+ end
305
+
306
+ example "Masking an attribute which spans on more than one table column \
307
+ (or document field)" do
308
+ User.class_eval do
309
+ attr_masker :full_name,
310
+ column_names: %i[first_name last_name],
311
+ masker: ->(**_) { "(first) (last)" }
312
+
313
+ def full_name
314
+ [first_name, last_name].join(" ")
315
+ end
316
+
317
+ def full_name=(value)
318
+ self.first_name = value.split(" ")[-2]
319
+ self.last_name = value.split(" ")[-1]
320
+ end
321
+ end
322
+
323
+ expect { run_rake_task }.not_to(change { User.count })
324
+
325
+ expect { han.reload }.to(
326
+ change { han.first_name }.to("(first)") &
327
+ change { han.last_name }.to("(last)") &
328
+ preserve { han.email } &
329
+ preserve { han.avatar }
330
+ )
331
+
332
+ expect { luke.reload }.to(
333
+ change { luke.first_name }.to("(first)") &
334
+ change { luke.last_name }.to("(last)") &
335
+ preserve { luke.email } &
336
+ preserve { luke.avatar }
337
+ )
338
+ end
339
+
340
+ example "It is disabled in production environment" do
341
+ allow(Rails).to receive(:env) { "production".inquiry }
342
+
343
+ User.class_eval do
344
+ attr_masker :last_name
345
+ end
346
+
347
+ expect { run_rake_task }.to(
348
+ preserve { User.count } &
349
+ raise_exception(AttrMasker::Error)
350
+ )
351
+
352
+ [han, luke].each do |record|
353
+ expect { record.reload }.not_to(change { record })
354
+ end
355
+ end
356
+
357
+ example "It masks records disregarding default scope" do
358
+ User.class_eval do
359
+ attr_masker :last_name
360
+
361
+ default_scope -> { where(last_name: "Solo") }
362
+ end
363
+
364
+ expect { run_rake_task }.not_to(change { User.unscoped.count })
365
+
366
+ [han, luke].each do |record|
367
+ expect { record.reload }.to(
368
+ change { record.last_name }.to("(redacted)")
369
+ )
370
+ end
371
+ end
372
+
373
+ example "It loads configuration file", :force_config_file_reload do
374
+ expect { run_rake_task }.to change { $CONFIG_LOADED_AT }
375
+ end
376
+
377
+ def run_rake_task
378
+ Rake::Task["db:mask"].execute
379
+ end
380
+ end
381
+
382
+ # rubocop:enable Style/TrailingCommaInArguments