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.
- 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 }
|