attr_masker 0.2.1 → 0.3.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.
@@ -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 = 2
9
- PATCH = 1
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.0".freeze
21
6
  end
@@ -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"
@@ -1,3 +1,2 @@
1
1
  Rails.application.routes.draw do
2
- #
3
2
  end
@@ -1,28 +1,97 @@
1
1
  # (c) 2017 Ribose Inc.
2
2
  #
3
3
 
4
- # No point in using ApplicationRecord here.
5
- # rubocop:disable Rails/ApplicationRecord
6
-
7
4
  require_relative "shared_examples"
8
5
 
9
6
  RSpec.describe "Attr Masker gem", :suppress_progressbar do
10
7
  context "when used with ActiveRecord" do
11
8
  before do
12
- if ENV["WITHOUT_ACTIVE_RECORD"]
9
+ if WITHOUT_ACTIVE_RECORD
13
10
  expect(defined?(::ActiveRecord)).to be(nil)
14
- skip "Active Record specs disabled with WITHOUT_ACTIVE_RECORD shell " \
11
+ skip "Active Record specs disabled with WITHOUT=activerecord shell " \
15
12
  "variable"
16
13
  end
17
14
  end
18
15
 
19
- before do
20
- allow(ActiveRecord::Base).to receive(:descendants).
21
- and_return([ActiveRecord::SchemaMigration, user_class_definition])
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
22
86
  end
23
87
 
88
+ # No point in using ApplicationRecord here.
89
+ # rubocop:disable Rails/ApplicationRecord
90
+
24
91
  let(:user_class_definition) { Class.new(ActiveRecord::Base) }
25
92
 
93
+ # rubocop:enable Rails/ApplicationRecord
94
+
26
95
  include_examples "Attr Masker gem feature specs"
27
96
  end
28
97
  end
@@ -6,15 +6,15 @@ require_relative "shared_examples"
6
6
  RSpec.describe "Attr Masker gem", :suppress_progressbar do
7
7
  context "when used with Mongoid" do
8
8
  before do
9
- if ENV["WITHOUT_MONGOID"]
9
+ if WITHOUT_MONGOID
10
10
  expect(defined?(::Mongoid)).to be(nil)
11
- skip "Mongoid specs disabled with WITHOUT_MONGOID shell variable"
11
+ skip "Mongoid specs disabled with WITHOUT=mongoid shell variable"
12
12
  end
13
13
  end
14
14
 
15
15
  after do
16
16
  # Remove the example-specific model from Mongoid.models
17
- ::Mongoid.models.delete(user_class_definition)
17
+ ::Mongoid.models.delete(user_class_definition) if defined?(::Mongoid)
18
18
  end
19
19
 
20
20
  let(:user_class_definition) do
@@ -176,6 +176,63 @@ RSpec.shared_examples "Attr Masker gem feature specs" do
176
176
  )
177
177
  end
178
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
+
179
236
  example "Masking a marshalled attribute" do
180
237
  User.class_eval do
181
238
  attr_masker :avatar, marshal: true
@@ -246,6 +303,40 @@ RSpec.shared_examples "Attr Masker gem feature specs" do
246
303
  expect(luke.avatar).to eq({ json: ["(redacted)"] }.to_json)
247
304
  end
248
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
+
249
340
  example "It is disabled in production environment" do
250
341
  allow(Rails).to receive(:env) { "production".inquiry }
251
342
 
@@ -267,7 +358,7 @@ RSpec.shared_examples "Attr Masker gem feature specs" do
267
358
  User.class_eval do
268
359
  attr_masker :last_name
269
360
 
270
- default_scope ->() { where(last_name: "Solo") }
361
+ default_scope -> { where(last_name: "Solo") }
271
362
  end
272
363
 
273
364
  expect { run_rake_task }.not_to(change { User.unscoped.count })
@@ -283,3 +374,5 @@ RSpec.shared_examples "Attr Masker gem feature specs" do
283
374
  Rake::Task["db:mask"].execute
284
375
  end
285
376
  end
377
+
378
+ # rubocop:enable Style/TrailingCommaInArguments
@@ -1,10 +1,35 @@
1
1
  # (c) 2017 Ribose Inc.
2
2
  #
3
3
 
4
+ # Warnings gem must be added to Gemfile or otherwise loaded in order to have
5
+ # the following configuration in effect.
6
+ #
7
+ # TODO: Add Warning to gemspec or Gemfile after dropping support for Ruby 2.3.
8
+ begin
9
+ require "warning"
10
+
11
+ # Deduplicate warnings
12
+ Warning.dedup
13
+
14
+ # Ignore all warnings in Gem dependencies
15
+ Gem.path.each do |path|
16
+ Warning.ignore(//, path)
17
+ end
18
+ rescue LoadError
19
+ end
20
+
21
+ require "simplecov"
22
+ SimpleCov.start
23
+
24
+ if ENV.key?("CI")
25
+ require "codecov"
26
+ SimpleCov.formatter = SimpleCov::Formatter::Codecov
27
+ end
28
+
4
29
  require "bundler"
5
30
  Bundler.require :default, :development
6
31
 
7
- Dir[File.expand_path "../support/**/*.rb", __FILE__].sort.each { |f| require f }
32
+ Dir[File.expand_path "support/**/*.rb", __dir__].sort.each { |f| require f }
8
33
 
9
34
  RSpec.configure do |config|
10
35
  # Enable flags like --only-failures and --next-failure
@@ -0,0 +1,2 @@
1
+ WITHOUT_ACTIVE_RECORD = ENV.fetch("WITHOUT", "") =~ /\bactiverecord\b/
2
+ WITHOUT_MONGOID = ENV.fetch("WITHOUT", "") =~ /\bmongoid\b/
@@ -6,4 +6,4 @@
6
6
  ENV["MONGOID_SPEC_HOST"] ||= "127.0.0.1"
7
7
  ENV["MONGOID_SPEC_PORT"] ||= "27017"
8
8
 
9
- require "mongoid" unless ENV["WITHOUT_MONGOID"]
9
+ require "mongoid" unless WITHOUT_MONGOID
@@ -3,8 +3,8 @@
3
3
 
4
4
  Combustion.path = "spec/dummy"
5
5
 
6
- if ENV["WITHOUT_ACTIVE_RECORD"].nil?
7
- Combustion.initialize! :active_record
8
- else
6
+ if WITHOUT_ACTIVE_RECORD
9
7
  Combustion.initialize!
8
+ else
9
+ Combustion.initialize! :active_record
10
10
  end
@@ -2,14 +2,16 @@ require "database_cleaner"
2
2
 
3
3
  RSpec.configure do |config|
4
4
  config.before(:suite) do
5
- unless ENV["WITHOUT_ACTIVE_RECORD"]
5
+ unless WITHOUT_ACTIVE_RECORD
6
+ require "database_cleaner-active_record"
6
7
  DatabaseCleaner[:active_record].strategy = :truncation
7
8
  end
8
9
 
9
10
  # Since models are defined dynamically in specs, Database Cleaner is unable
10
11
  # to list them and to determine collection names to be cleaned.
11
12
  # Therefore, they are specified explicitly here.
12
- unless ENV["WITHOUT_MONGOID"]
13
+ unless WITHOUT_MONGOID
14
+ require "database_cleaner-mongoid"
13
15
  DatabaseCleaner[:mongoid].strategy = :truncation, { only: "users" }
14
16
  end
15
17
 
@@ -3,4 +3,4 @@
3
3
 
4
4
  require "rake"
5
5
  Rails.application.load_tasks
6
- load File.expand_path("../../../lib/tasks/db.rake", __FILE__)
6
+ load File.expand_path("../../lib/tasks/db.rake", __dir__)
@@ -16,17 +16,17 @@ RSpec.describe AttrMasker::Attribute do
16
16
  end
17
17
  end
18
18
 
19
- describe "#column_name" do
20
- subject { receiver.method :column_name }
19
+ describe "#column_names" do
20
+ subject { receiver.method :column_names }
21
21
  let(:receiver) { described_class.new :some_attr, :some_model, options }
22
22
  let(:options) { {} }
23
23
 
24
- it "defaults to attribute name" do
25
- expect(subject.call).to eq(:some_attr)
24
+ it "defaults to array containing attribute name only" do
25
+ expect(subject.call).to contain_exactly(:some_attr)
26
26
  end
27
27
 
28
- it "can be overriden with :column_name option" do
29
- options[:column_name] = :some_column
28
+ it "can be overriden with :column_names option" do
29
+ options[:column_names] = :some_column
30
30
  expect(subject.call).to eq(:some_column)
31
31
  end
32
32
  end
@@ -55,6 +55,88 @@ RSpec.describe AttrMasker::Attribute do
55
55
  end
56
56
  end
57
57
 
58
+ describe "mask" do
59
+ subject { described_class.instance_method :mask }
60
+ let(:receiver) { described_class.new :some_attr, :some_model, options }
61
+ let(:model_instance) { Struct.new(:some_attr).new("value") }
62
+ let(:options) { { masker: masker } }
63
+ let(:masker) { ->(**) { "masked_value" } }
64
+
65
+ it "takes the instance.options[:masker] and calls it" do
66
+ expect(masker).to receive(:call)
67
+ subject.bind(receiver).call(model_instance)
68
+ end
69
+
70
+ it "passes the unmarshalled attribute value to the masker" do
71
+ expect(receiver).to receive(:unmarshal_data).
72
+ with("value").and_return("unmarshalled_value")
73
+ expect(masker).to receive(:call).
74
+ with(hash_including(value: "unmarshalled_value"))
75
+ subject.bind(receiver).call(model_instance)
76
+ end
77
+
78
+ it "passes the model instance to the masker" do
79
+ expect(masker).to receive(:call).
80
+ with(hash_including(model: model_instance))
81
+ subject.bind(receiver).call(model_instance)
82
+ end
83
+
84
+ it "passes the attribute name to the masker" do
85
+ expect(masker).to receive(:call).
86
+ with(hash_including(attribute_name: :some_attr))
87
+ subject.bind(receiver).call(model_instance)
88
+ end
89
+
90
+ it "passes the masking options to the masker" do
91
+ expect(masker).to receive(:call).
92
+ with(hash_including(masking_options: options))
93
+ subject.bind(receiver).call(model_instance)
94
+ end
95
+
96
+ it "marshals the masked value, and assigns it to the attribute" do
97
+ expect(receiver).to receive(:marshal_data).
98
+ with("masked_value").and_return("marshalled_masked_value")
99
+ subject.bind(receiver).call(model_instance)
100
+ expect(model_instance.some_attr).to eq("marshalled_masked_value")
101
+ end
102
+ end
103
+
104
+ describe "#masked_attributes_new_values" do
105
+ subject { receiver.method :masked_attributes_new_values }
106
+ let(:receiver) { described_class.new :some_attr, :some_model, options }
107
+ let(:options) { {} }
108
+ let(:model_instance) { double } # Struct.new(:some_attr, :other_attr) }
109
+ let(:changes) { { some_attr: [nil, "new"], other_attr: [nil, "other"] } }
110
+
111
+ before { allow(model_instance).to receive(:changes).and_return(changes) }
112
+
113
+ # rubocop:disable Style/BracesAroundHashParameters
114
+ # We are comparing hashes here, and we want hash literals
115
+ it "returns a hash of required database updates which include masked field \
116
+ change, but ignores other attribute changes" do
117
+ expect(subject.(model_instance)).to eq({ some_attr: "new" })
118
+ end
119
+
120
+ it "returns an emtpy hash for an unchanged object" do
121
+ changes.clear
122
+ expect(subject.(model_instance)).to eq({})
123
+ end
124
+
125
+ it "allows overriding column/field name to be updated with column_name \
126
+ option" do
127
+ options[:column_names] = %i[other_attr]
128
+ expect(subject.(model_instance)).to eq({ other_attr: "other" })
129
+ end
130
+
131
+ it "allows specifying more than one column/field name to be updated \
132
+ with column_name option" do
133
+ options[:column_names] = %i[some_attr other_attr]
134
+ expect(subject.(model_instance)).
135
+ to eq({ some_attr: "new", other_attr: "other" })
136
+ end
137
+ # rubocop:enable Style/BracesAroundHashParameters
138
+ end
139
+
58
140
  describe "#evaluate_option" do
59
141
  subject { receiver.method :evaluate_option }
60
142
  let(:receiver) { described_class.new :some_attr, model_instance, options }