attr_masker 0.2.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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 }