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.
- checksums.yaml +5 -5
- data/.github/workflows/tests.yml +80 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +13 -1069
- data/CHANGELOG.adoc +4 -0
- data/Gemfile +5 -0
- data/README.adoc +54 -29
- data/attr_masker.gemspec +13 -10
- data/bin/rake +12 -1
- data/bin/rspec +11 -0
- data/bin/rubocop +11 -0
- data/gemfiles/Rails-4.2.gemfile +2 -1
- data/gemfiles/Rails-5.0.gemfile +2 -1
- data/gemfiles/Rails-5.1.gemfile +2 -1
- data/gemfiles/Rails-5.2.gemfile +4 -0
- data/gemfiles/Rails-6.0.gemfile +3 -0
- data/gemfiles/Rails-head.gemfile +1 -1
- data/gemfiles/common.gemfile +3 -4
- data/lib/attr_masker/attribute.rb +15 -5
- data/lib/attr_masker/maskers/replacing.rb +19 -3
- data/lib/attr_masker/maskers/simple.rb +14 -4
- data/lib/attr_masker/model.rb +3 -0
- data/lib/attr_masker/performer.rb +27 -9
- data/lib/attr_masker/version.rb +1 -16
- data/lib/tasks/db.rake +0 -3
- data/spec/dummy/config/routes.rb +0 -1
- data/spec/features/active_record_spec.rb +77 -8
- data/spec/features/mongoid_spec.rb +3 -3
- data/spec/features/shared_examples.rb +94 -1
- data/spec/spec_helper.rb +26 -1
- data/spec/support/00_control_constants.rb +2 -0
- data/spec/support/{00_mongoid_env.rb → 10_mongoid_env.rb} +1 -1
- data/spec/support/20_combustion.rb +3 -3
- data/spec/support/db_cleaner.rb +4 -2
- data/spec/support/rake.rb +1 -1
- data/spec/unit/attribute_spec.rb +88 -6
- metadata +87 -29
- data/.travis.yml +0 -43
- data/gemfiles/Rails-4.0.gemfile +0 -3
- data/gemfiles/Rails-4.1.gemfile +0 -3
data/lib/attr_masker/version.rb
CHANGED
@@ -2,20 +2,5 @@
|
|
2
2
|
#
|
3
3
|
|
4
4
|
module AttrMasker
|
5
|
-
|
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
|
data/lib/tasks/db.rake
CHANGED
data/spec/dummy/config/routes.rb
CHANGED
@@ -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
|
9
|
+
if WITHOUT_ACTIVE_RECORD
|
13
10
|
expect(defined?(::ActiveRecord)).to be(nil)
|
14
|
-
skip "Active Record specs disabled with
|
11
|
+
skip "Active Record specs disabled with WITHOUT=activerecord shell " \
|
15
12
|
"variable"
|
16
13
|
end
|
17
14
|
end
|
18
15
|
|
19
|
-
|
20
|
-
|
21
|
-
|
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
|
9
|
+
if WITHOUT_MONGOID
|
10
10
|
expect(defined?(::Mongoid)).to be(nil)
|
11
|
-
skip "Mongoid specs disabled with
|
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 ->
|
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
|
data/spec/spec_helper.rb
CHANGED
@@ -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 "
|
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
|
data/spec/support/db_cleaner.rb
CHANGED
@@ -2,14 +2,16 @@ require "database_cleaner"
|
|
2
2
|
|
3
3
|
RSpec.configure do |config|
|
4
4
|
config.before(:suite) do
|
5
|
-
unless
|
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
|
13
|
+
unless WITHOUT_MONGOID
|
14
|
+
require "database_cleaner-mongoid"
|
13
15
|
DatabaseCleaner[:mongoid].strategy = :truncation, { only: "users" }
|
14
16
|
end
|
15
17
|
|
data/spec/support/rake.rb
CHANGED
data/spec/unit/attribute_spec.rb
CHANGED
@@ -16,17 +16,17 @@ RSpec.describe AttrMasker::Attribute do
|
|
16
16
|
end
|
17
17
|
end
|
18
18
|
|
19
|
-
describe "#
|
20
|
-
subject { receiver.method :
|
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
|
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 :
|
29
|
-
options[:
|
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 }
|