attr_masker 0.1.0 → 0.3.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.
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