pinspec 0.1.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 +7 -0
- data/CHANGELOG.md +133 -0
- data/LICENSE.txt +21 -0
- data/README.md +183 -0
- data/exe/pinspec +8 -0
- data/lib/pinspec/analyzer/app_profile_reader.rb +348 -0
- data/lib/pinspec/analyzer/factory_registry.rb +288 -0
- data/lib/pinspec/analyzer/inflector.rb +85 -0
- data/lib/pinspec/analyzer/schema_reader.rb +444 -0
- data/lib/pinspec/analyzer/source.rb +56 -0
- data/lib/pinspec/analyzer/target_parser.rb +710 -0
- data/lib/pinspec/cli.rb +585 -0
- data/lib/pinspec/emit/namer.rb +103 -0
- data/lib/pinspec/emit/spec_writer.rb +504 -0
- data/lib/pinspec/emit/stability_filter.rb +183 -0
- data/lib/pinspec/errors.rb +85 -0
- data/lib/pinspec/inputs/boundary.rb +112 -0
- data/lib/pinspec/inputs/corpus.rb +148 -0
- data/lib/pinspec/inputs/hydrator.rb +197 -0
- data/lib/pinspec/inputs/redactor.rb +138 -0
- data/lib/pinspec/inputs/sample_runner.rb +98 -0
- data/lib/pinspec/inputs/sampler.rb +187 -0
- data/lib/pinspec/report/summary.rb +348 -0
- data/lib/pinspec/runner/capture.rb +127 -0
- data/lib/pinspec/runner/probe_generator.rb +662 -0
- data/lib/pinspec/runner/sandbox.rb +121 -0
- data/lib/pinspec/setup/context_builder.rb +471 -0
- data/lib/pinspec/setup/dependency_resolver.rb +236 -0
- data/lib/pinspec/tags.rb +103 -0
- data/lib/pinspec/types.rb +497 -0
- data/lib/pinspec/validate/mutation_adapter.rb +108 -0
- data/lib/pinspec/validate/pin_scorer.rb +164 -0
- data/lib/pinspec/verify/verifier.rb +149 -0
- data/lib/pinspec/version.rb +8 -0
- data/lib/pinspec.rb +17 -0
- data/templates/factory_build.rb +54 -0
- data/templates/serializer.rb +243 -0
- data/templates/spec_support.rb +83 -0
- metadata +134 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
module Pinspec
|
|
8
|
+
module Runner
|
|
9
|
+
class Sandbox
|
|
10
|
+
PROBE_DIR = "tmp/pinspec"
|
|
11
|
+
PROBE_FILE = "probe.rb"
|
|
12
|
+
|
|
13
|
+
DEFAULT_TIMEOUT = 600
|
|
14
|
+
|
|
15
|
+
FORCED_ENV = {
|
|
16
|
+
"DISABLE_SPRING" => "1",
|
|
17
|
+
"TZ" => "UTC",
|
|
18
|
+
"RAILS_ENV" => "test"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
SCRUBBED_ENV = {
|
|
22
|
+
"BUNDLE_GEMFILE" => nil,
|
|
23
|
+
"BUNDLE_PATH" => nil,
|
|
24
|
+
"BUNDLE_BIN_PATH" => nil,
|
|
25
|
+
"BUNDLE_APP_CONFIG" => nil,
|
|
26
|
+
"BUNDLER_VERSION" => nil,
|
|
27
|
+
"BUNDLER_SETUP" => nil,
|
|
28
|
+
"RUBYOPT" => nil,
|
|
29
|
+
"RUBYLIB" => nil,
|
|
30
|
+
"GEM_HOME" => nil,
|
|
31
|
+
"GEM_PATH" => nil
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
Result = Data.define(:run, :observations, :env, :plan_id, :stderr) do
|
|
35
|
+
def observation(case_id)
|
|
36
|
+
observations.find { |o| o["case_id"] == case_id }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(app_root:, probe_source:, timeout: DEFAULT_TIMEOUT, runner: nil, env: {})
|
|
41
|
+
@app_root = app_root
|
|
42
|
+
@probe_source = probe_source
|
|
43
|
+
@timeout = timeout
|
|
44
|
+
@runner = runner
|
|
45
|
+
@env = env
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def probe_path
|
|
49
|
+
File.join(@app_root, PROBE_DIR, PROBE_FILE)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def write_probe!
|
|
53
|
+
FileUtils.mkdir_p(File.dirname(probe_path))
|
|
54
|
+
File.write(probe_path, @probe_source)
|
|
55
|
+
probe_path
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def capture(boots: 2)
|
|
59
|
+
write_probe!
|
|
60
|
+
|
|
61
|
+
(1..boots).map do |run|
|
|
62
|
+
execute(run: run, seed: 40 + run)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def execute(run:, seed:)
|
|
69
|
+
command = runner_command
|
|
70
|
+
env = SCRUBBED_ENV
|
|
71
|
+
.merge(FORCED_ENV)
|
|
72
|
+
.merge("PINSPEC_SHUFFLE_SEED" => seed.to_s)
|
|
73
|
+
.merge(@env)
|
|
74
|
+
|
|
75
|
+
stdout, stderr, status = Open3.capture3(env, *command, chdir: @app_root)
|
|
76
|
+
|
|
77
|
+
unless status.success?
|
|
78
|
+
raise ProbeFailure,
|
|
79
|
+
"the probe exited #{status.exitstatus} in #{@app_root}.\n" \
|
|
80
|
+
"#{stderr.to_s.lines.last(15).join}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
parse(stdout, stderr, run)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse(stdout, stderr, run)
|
|
87
|
+
json = stdout.to_s.lines.reverse.find { |line| line.strip.start_with?("{") }
|
|
88
|
+
|
|
89
|
+
if json.nil?
|
|
90
|
+
raise ProbeFailure,
|
|
91
|
+
"the probe produced no observations.\n" \
|
|
92
|
+
"stdout: #{stdout.to_s.lines.last(10).join}\nstderr: #{stderr.to_s.lines.last(10).join}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
parsed = JSON.parse(json)
|
|
96
|
+
|
|
97
|
+
Result.new(
|
|
98
|
+
run: run,
|
|
99
|
+
observations: parsed["observations"],
|
|
100
|
+
env: parsed["env"],
|
|
101
|
+
plan_id: parsed["plan_id"],
|
|
102
|
+
stderr: stderr
|
|
103
|
+
)
|
|
104
|
+
rescue JSON::ParserError => e
|
|
105
|
+
raise ProbeFailure, "the probe's output was not JSON (#{e.message}): #{json.to_s[0, 200]}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def runner_command
|
|
109
|
+
return @runner if @runner
|
|
110
|
+
|
|
111
|
+
relative = File.join(PROBE_DIR, PROBE_FILE)
|
|
112
|
+
|
|
113
|
+
if File.file?(File.join(@app_root, "Gemfile"))
|
|
114
|
+
["bundle", "exec", "rails", "runner", relative]
|
|
115
|
+
else
|
|
116
|
+
["rails", "runner", relative]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Pinspec
|
|
6
|
+
module Setup
|
|
7
|
+
class ContextBuilder
|
|
8
|
+
MAX_GENERATIONS = 3
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def build(target:, profile:, imports: [], generation: 1, tz: nil)
|
|
12
|
+
new(target: target, profile: profile, imports: imports, generation: generation, tz: tz).build
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(target:, profile:, imports: [], generation: 1, tz: nil)
|
|
17
|
+
@target = target
|
|
18
|
+
@profile = profile
|
|
19
|
+
@imports = imports
|
|
20
|
+
@generation = generation
|
|
21
|
+
@tz = tz || Runner::Sandbox::FORCED_ENV.fetch("TZ")
|
|
22
|
+
@resolver = DependencyResolver.new(profile.schema, profile.factories)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build
|
|
26
|
+
@steps = []
|
|
27
|
+
@bindings = {}
|
|
28
|
+
@notes = []
|
|
29
|
+
@counters = Hash.new(0)
|
|
30
|
+
@refs = {}
|
|
31
|
+
|
|
32
|
+
refuse_unsupported_tenancy!
|
|
33
|
+
refuse_attachment_targets!
|
|
34
|
+
|
|
35
|
+
environment_steps
|
|
36
|
+
record_steps
|
|
37
|
+
import_steps
|
|
38
|
+
context_steps
|
|
39
|
+
subject_step
|
|
40
|
+
|
|
41
|
+
plan
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def refuse_unsupported_tenancy!
|
|
47
|
+
return unless @profile.tenancy == :apartment
|
|
48
|
+
|
|
49
|
+
raise UnresolvableSetup.new(
|
|
50
|
+
:apartment,
|
|
51
|
+
"this app uses ros-apartment, which switches PostgreSQL schemas per " \
|
|
52
|
+
"tenant. pinspec cannot tell which schema a target expects, and building " \
|
|
53
|
+
"a world in the wrong one would pin behaviour that never happens."
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def refuse_attachment_targets!
|
|
58
|
+
return if @profile.attachments.empty?
|
|
59
|
+
|
|
60
|
+
touched = attachment_findings_for_target
|
|
61
|
+
return if touched.empty?
|
|
62
|
+
|
|
63
|
+
finding = touched.first
|
|
64
|
+
raise UnresolvableSetup.new(
|
|
65
|
+
:attachment,
|
|
66
|
+
"#{@target.class_name} carries an attachment (#{finding.kind} at " \
|
|
67
|
+
"#{finding.file}:#{finding.line}) and #{@target.qualified_name} reads it. " \
|
|
68
|
+
"pinspec cannot synthesize a blob, so it will not pin a target running " \
|
|
69
|
+
"against an empty attachment."
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def attachment_findings_for_target
|
|
74
|
+
attachment_kinds = %i[active_storage carrierwave paperclip]
|
|
75
|
+
|
|
76
|
+
@profile.model_findings.select do |finding|
|
|
77
|
+
next false unless attachment_kinds.include?(finding.kind)
|
|
78
|
+
next false unless finding.model == @target.class_name
|
|
79
|
+
|
|
80
|
+
references_attachment?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def references_attachment?
|
|
85
|
+
@target.referenced_constants.any? { |c| c.start_with?("ActiveStorage") } ||
|
|
86
|
+
@target.method_name.to_s.match?(/attach|upload|file|blob|avatar|photo|document/)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def environment_steps
|
|
90
|
+
add(:freeze_time, at: PINSPEC_EPOCH)
|
|
91
|
+
add(:seed_random, seed: PINSPEC_SEED)
|
|
92
|
+
add(:set_locale, locale: @profile.default_locale)
|
|
93
|
+
add(:set_zone, zone: @profile.default_zone)
|
|
94
|
+
|
|
95
|
+
flag_steps
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def flag_steps
|
|
99
|
+
return unless @profile.flags == :flipper
|
|
100
|
+
|
|
101
|
+
referenced_flags.each do |flag|
|
|
102
|
+
add(:set_flag, flag: flag, enabled: false)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def referenced_flags
|
|
107
|
+
return [] unless @target.referenced_constants.include?("Flipper")
|
|
108
|
+
|
|
109
|
+
source = File.file?(@target.file_path) ? Analyzer::Source.read(@target.file_path) : ""
|
|
110
|
+
first, last = @target.source_range
|
|
111
|
+
body = source.lines[(first - 1)..(last - 1)].to_a.join
|
|
112
|
+
|
|
113
|
+
body.scan(/Flipper\.enabled\?\(\s*:(\w+)/).flatten.map(&:to_sym).uniq
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def record_steps
|
|
117
|
+
roots = root_tables
|
|
118
|
+
@target.input_params.each do |param|
|
|
119
|
+
refuse_unresolvable_model_param!(param) if table_for_param(param).nil?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
return if roots.empty?
|
|
123
|
+
|
|
124
|
+
order = @resolver.creation_order(
|
|
125
|
+
roots,
|
|
126
|
+
prune: ->(table_name) { !@resolver.factory_for(table_name).nil? }
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
order.each { |table_name| create_record_for(table_name) }
|
|
130
|
+
|
|
131
|
+
bind_parameters
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def root_tables
|
|
135
|
+
tables = @target.input_params.filter_map { |param| table_for_param(param)&.name }
|
|
136
|
+
|
|
137
|
+
if @target.construction_kind == :model_instance
|
|
138
|
+
subject_table = @resolver.table_for(@target.class_name)
|
|
139
|
+
tables = [subject_table.name] + tables if subject_table
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
tables.uniq
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def table_for_param(param)
|
|
146
|
+
@resolver.table_for_type_hint(param.type_hint)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
NON_MODEL_HINTS = %w[
|
|
150
|
+
String Symbol Integer Float Numeric Decimal BigDecimal Boolean TrueClass
|
|
151
|
+
FalseClass Hash Array Range Time Date DateTime Proc Class Module Object
|
|
152
|
+
NilClass Params Param Options Option Attributes Attribute Id Ids Name
|
|
153
|
+
Amount Quantity Count Total Price Rate Percentage Status State Kind Type
|
|
154
|
+
].freeze
|
|
155
|
+
|
|
156
|
+
def refuse_unresolvable_model_param!(param)
|
|
157
|
+
hint = param.type_hint.to_s
|
|
158
|
+
return if hint.empty?
|
|
159
|
+
return unless hint.match?(/\A[A-Z]/)
|
|
160
|
+
return if NON_MODEL_HINTS.include?(hint)
|
|
161
|
+
return unless param.default_source.nil?
|
|
162
|
+
return if %i[rest keyrest block].include?(param.kind)
|
|
163
|
+
|
|
164
|
+
return if unambiguous_column_for(param.name)
|
|
165
|
+
|
|
166
|
+
raise UnresolvableSetup.new(
|
|
167
|
+
:unresolvable_parameter,
|
|
168
|
+
"parameter `#{param.name}` looks like a #{hint}, but no table, model or " \
|
|
169
|
+
"factory in this application answers to that name, so pinspec has nothing " \
|
|
170
|
+
"to pass. It will not pass nil instead: the target would raise on nil and " \
|
|
171
|
+
"that error would be pinned as though the application produced it. Add a " \
|
|
172
|
+
"factory named :#{underscore_word(hint)} (a declared `class:` is enough), or " \
|
|
173
|
+
"pin a target whose arguments can be built."
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def unambiguous_column_for(name)
|
|
178
|
+
matches = @profile.schema.tables.filter_map { |table| table.column(name) }
|
|
179
|
+
return nil if matches.empty?
|
|
180
|
+
|
|
181
|
+
matches.map(&:type).uniq.size == 1 ? matches.first : nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def underscore_word(str)
|
|
185
|
+
str.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def bind_parameters
|
|
189
|
+
bound = {}
|
|
190
|
+
|
|
191
|
+
@target.input_params.each do |param|
|
|
192
|
+
table = table_for_param(param)
|
|
193
|
+
next if table.nil?
|
|
194
|
+
|
|
195
|
+
@bindings[param.name] =
|
|
196
|
+
if bound[table.name]
|
|
197
|
+
create_record_for(table.name, additional: true)
|
|
198
|
+
else
|
|
199
|
+
bound[table.name] = true
|
|
200
|
+
@refs[table.name]
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
return unless @target.construction_kind == :model_instance
|
|
205
|
+
|
|
206
|
+
subject_table = @resolver.table_for(@target.class_name)
|
|
207
|
+
@bindings[:__subject__] = @refs[subject_table.name] if subject_table
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def create_record_for(table_name, additional: false)
|
|
211
|
+
return @refs[table_name] if @refs.key?(table_name) && !additional
|
|
212
|
+
|
|
213
|
+
table = @profile.schema.table(table_name)
|
|
214
|
+
refuse_unfillable_columns!(table)
|
|
215
|
+
|
|
216
|
+
if @resolver.self_referential_required?(table)
|
|
217
|
+
raise UnresolvableSetup.new(
|
|
218
|
+
:association_cycle,
|
|
219
|
+
"#{table_name} has a NOT NULL foreign key to itself, so its first row " \
|
|
220
|
+
"would have to exist before it is created."
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
factory = @resolver.factory_for(table_name)
|
|
225
|
+
note_declined_factory(table_name)
|
|
226
|
+
|
|
227
|
+
ref = next_ref(table_name)
|
|
228
|
+
@refs[table_name] = ref unless additional
|
|
229
|
+
|
|
230
|
+
if factory
|
|
231
|
+
add(:create_record, name: ref, factory: factory.name, model: factory.model, attrs: {}, assoc_refs: {})
|
|
232
|
+
else
|
|
233
|
+
add(:create_record,
|
|
234
|
+
name: ref,
|
|
235
|
+
factory: nil,
|
|
236
|
+
model: @resolver.model_for(table_name),
|
|
237
|
+
attrs: schema_attributes(table),
|
|
238
|
+
assoc_refs: association_refs(table))
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
ref
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def refuse_unfillable_columns!(table)
|
|
245
|
+
return if table.nil?
|
|
246
|
+
|
|
247
|
+
unfillable = table.required_columns.select(&:unknown_type?)
|
|
248
|
+
return if unfillable.empty?
|
|
249
|
+
|
|
250
|
+
column = unfillable.first
|
|
251
|
+
raise UnresolvableSetup.new(
|
|
252
|
+
:unknown_column_type,
|
|
253
|
+
"#{table.name}.#{column.name} is NOT NULL with type #{column.type} " \
|
|
254
|
+
"(#{table.name} is needed to build #{@target.qualified_name}), and " \
|
|
255
|
+
"pinspec has no value it can honestly supply for that type."
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def schema_attributes(table)
|
|
260
|
+
unique = @resolver.unique_columns(table)
|
|
261
|
+
|
|
262
|
+
@resolver.required_scalars(table).each_with_object({}) do |column, attrs|
|
|
263
|
+
uniquifier = unique.include?(column.name) ? uniquifier_for(table) : nil
|
|
264
|
+
value = @resolver.placeholder_for(column, frozen_time: PINSPEC_EPOCH, uniquifier: uniquifier)
|
|
265
|
+
attrs[column.name] = value unless value.nil?
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def uniquifier_for(table)
|
|
270
|
+
"p#{@generation}-#{@counters[table.name]}"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def association_refs(table)
|
|
274
|
+
@resolver.required_associations(table).each_with_object({}) do |(column, parent), refs|
|
|
275
|
+
parent_ref = @refs[parent]
|
|
276
|
+
refs[column] = parent_ref if parent_ref
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def note_declined_factory(table_name)
|
|
281
|
+
declined = @resolver.declined_factory_for(table_name)
|
|
282
|
+
return if declined.nil?
|
|
283
|
+
return if @resolver.factory_for(table_name)
|
|
284
|
+
|
|
285
|
+
note(:factory_declined,
|
|
286
|
+
"factory :#{declined.name} never persists " \
|
|
287
|
+
"(#{declined.hazards.map(&:first).join(', ')}), so #{table_name} is " \
|
|
288
|
+
"built from the schema instead")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def import_steps
|
|
292
|
+
@imports.each do |cluster|
|
|
293
|
+
add(:import_record,
|
|
294
|
+
name: cluster.name,
|
|
295
|
+
model: cluster.model,
|
|
296
|
+
attrs: cluster.attrs,
|
|
297
|
+
source: cluster.source,
|
|
298
|
+
redacted: cluster.redacted,
|
|
299
|
+
flags: cluster.flags)
|
|
300
|
+
|
|
301
|
+
@refs[cluster.table] ||= cluster.name
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def context_steps
|
|
306
|
+
tenant_step
|
|
307
|
+
auth_step
|
|
308
|
+
whodunnit_step
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def tenant_step
|
|
312
|
+
return unless @profile.tenancy == :acts_as_tenant
|
|
313
|
+
|
|
314
|
+
tenant_finding = @profile.findings(:acts_as_tenant).first
|
|
315
|
+
return note(:tenancy_unresolved, "acts_as_tenant is in use but no tenanted model was found") if tenant_finding.nil?
|
|
316
|
+
|
|
317
|
+
tenant_ref = tenant_ref_for(tenant_finding)
|
|
318
|
+
return note(:tenancy_unresolved,
|
|
319
|
+
"acts_as_tenant is in use but pinspec could not identify the " \
|
|
320
|
+
"tenant record for #{tenant_finding.model}") if tenant_ref.nil?
|
|
321
|
+
|
|
322
|
+
add(:set_tenant, record_ref: tenant_ref)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def tenant_ref_for(finding)
|
|
326
|
+
table = @resolver.table_for(finding.model)
|
|
327
|
+
return nil if table.nil?
|
|
328
|
+
|
|
329
|
+
tenant_tables = @resolver.required_associations(table).map(&:last)
|
|
330
|
+
tenant_table = tenant_tables.first
|
|
331
|
+
return nil if tenant_table.nil?
|
|
332
|
+
|
|
333
|
+
create_record_for(tenant_table)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def auth_step
|
|
337
|
+
if %i[devise current_attributes].include?(@profile.auth) && !target_may_read_current_user?
|
|
338
|
+
note(:current_user_not_built,
|
|
339
|
+
"#{@profile.auth} is in use, but this target does not mention a current " \
|
|
340
|
+
"user, so none is created - the smallest world that can exist is the one " \
|
|
341
|
+
"least likely to surprise a reader. Honest limit: this scans the target's " \
|
|
342
|
+
"own file, so a transitive callee that reads one will see nil.")
|
|
343
|
+
return auth_note
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
case @profile.auth
|
|
347
|
+
when :devise
|
|
348
|
+
add(:stub_current, kind: :devise_user, record_ref: user_ref)
|
|
349
|
+
when :current_attributes
|
|
350
|
+
add(:stub_current, kind: :current_attributes, record_ref: user_ref)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
auth_note
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def auth_note
|
|
357
|
+
return if @profile.authz == :none
|
|
358
|
+
|
|
359
|
+
note(:authz_present,
|
|
360
|
+
"#{@profile.authz} is in use; pinspec pins whatever it decides rather " \
|
|
361
|
+
"than bypassing it")
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
CURRENT_USER_READS = /
|
|
365
|
+
current_user | current_spree_user | spree_current_user | current_admin
|
|
366
|
+
| Current\s*\. | whodunnit | PaperTrail\s*\.\s*request
|
|
367
|
+
/x
|
|
368
|
+
|
|
369
|
+
def target_may_read_current_user?
|
|
370
|
+
return false unless File.file?(@target.file_path)
|
|
371
|
+
|
|
372
|
+
Analyzer::Source.read(@target.file_path).match?(CURRENT_USER_READS)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def whodunnit_step
|
|
376
|
+
return unless @profile.versioning == :paper_trail
|
|
377
|
+
|
|
378
|
+
ref = @steps.find { |step| step.kind == :stub_current }&.payload&.dig(:record_ref)
|
|
379
|
+
|
|
380
|
+
if ref.nil?
|
|
381
|
+
return note(:whodunnit_unset,
|
|
382
|
+
"paper_trail is in use but no current user was built, so versions " \
|
|
383
|
+
"this target creates record a null whodunnit. Both hosts do this, " \
|
|
384
|
+
"so the pin is consistent; it is production that differs.")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
add(:set_whodunnit, record_ref: ref)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def user_ref
|
|
391
|
+
table = %w[users accounts people admins].find { |name| @profile.schema.table(name) } ||
|
|
392
|
+
%w[User Account Person Admin].filter_map { |hint| @resolver.table_for_type_hint(hint)&.name }.first
|
|
393
|
+
|
|
394
|
+
if table.nil?
|
|
395
|
+
note(:user_table_missing,
|
|
396
|
+
"no users/accounts/people table found, so the current user is left " \
|
|
397
|
+
"unset; a target that reads it will see nil")
|
|
398
|
+
return nil
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
create_record_for(table)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def subject_step
|
|
405
|
+
return unless @target.needs_subject?
|
|
406
|
+
|
|
407
|
+
add(:construct_subject,
|
|
408
|
+
class: @target.class_name,
|
|
409
|
+
kind: @target.construction_kind,
|
|
410
|
+
params: @target.initializer_params.map(&:name))
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def plan
|
|
414
|
+
SetupPlan.new(
|
|
415
|
+
steps: @steps,
|
|
416
|
+
isolation: @profile.isolation,
|
|
417
|
+
env_fingerprint: env_fingerprint,
|
|
418
|
+
bindings: @bindings,
|
|
419
|
+
notes: @notes,
|
|
420
|
+
generation: @generation,
|
|
421
|
+
plan_id: nil
|
|
422
|
+
).then { |draft| draft.with(plan_id: fingerprint(draft)) }
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def env_fingerprint
|
|
426
|
+
{
|
|
427
|
+
tz: @tz,
|
|
428
|
+
locale: @profile.default_locale,
|
|
429
|
+
zone: @profile.default_zone,
|
|
430
|
+
rails: @profile.rails_version,
|
|
431
|
+
ruby: @profile.ruby_version,
|
|
432
|
+
serializer: SERIALIZER_VERSION
|
|
433
|
+
}
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def fingerprint(draft)
|
|
437
|
+
canonical = [
|
|
438
|
+
@target.qualified_name,
|
|
439
|
+
draft.isolation,
|
|
440
|
+
draft.generation,
|
|
441
|
+
draft.env_fingerprint.sort.map { |k, v| "#{k}=#{v}" }.join(","),
|
|
442
|
+
draft.steps.map { |step| "#{step.kind}:#{canonical_payload(step.payload)}" }
|
|
443
|
+
].flatten.join("|")
|
|
444
|
+
|
|
445
|
+
Digest::SHA256.hexdigest(canonical)[0, 12]
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def canonical_payload(payload)
|
|
449
|
+
payload.sort_by { |key, _| key.to_s }.map { |key, value| "#{key}=#{value.inspect}" }.join(",")
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def add(kind, **payload)
|
|
453
|
+
raise PinspecInternalError, "unknown setup step #{kind.inspect}" unless STEP_KINDS.include?(kind)
|
|
454
|
+
|
|
455
|
+
@steps << SetupStep.new(kind: kind, payload: payload)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def next_ref(table_name)
|
|
459
|
+
singular = Analyzer::Inflector.singular_candidates(table_name).first
|
|
460
|
+
@counters[table_name] += 1
|
|
461
|
+
|
|
462
|
+
"#{singular}_#{@counters[table_name]}"
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def note(kind, detail)
|
|
466
|
+
@notes << { kind: kind, detail: detail }
|
|
467
|
+
nil
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end
|