karst 0.1.0 → 0.2.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 +4 -4
- data/ARCHITECTURE.md +8 -11
- data/CHANGELOG.md +37 -50
- data/README.md +20 -8
- data/SECURITY.md +1 -1
- data/docs/advanced-configuration.md +8 -9
- data/docs/rails8-authentication.md +171 -0
- data/lib/karst/access/population_approval.rb +67 -0
- data/lib/karst/access/population_revocation.rb +34 -0
- data/lib/karst/configuration.rb +5 -5
- data/lib/karst/execution_context.rb +6 -7
- data/lib/karst/railtie.rb +4 -7
- data/lib/karst/version.rb +1 -1
- data/lib/karst/web/badge.rb +5 -31
- data/lib/karst/web/browser_identity.rb +10 -13
- data/lib/karst/web/csrf.rb +43 -0
- data/lib/karst/web/middleware.rb +50 -78
- data/lib/karst/web/panel.rb +44 -81
- data/lib/karst/web/populations_panel.rb +46 -336
- data/lib/karst.rb +1 -3
- data/lib/rails/commands/karst/mcp/mcp_command.rb +14 -1
- metadata +7 -14
- data/lib/karst/access/population_config_snippet.rb +0 -67
- data/lib/karst/access/population_preview.rb +0 -83
- data/lib/karst/access/resource_evidence.rb +0 -233
- data/lib/karst/spec/catalog.rb +0 -199
- data/lib/karst/spec/example_observation.rb +0 -31
- data/lib/karst/spec/observer.rb +0 -300
- data/lib/karst/spec/principal.rb +0 -12
- data/lib/karst/spec/reporter.rb +0 -83
- data/lib/karst/spec/request_observation.rb +0 -38
- data/lib/karst/spec/scenario.rb +0 -65
- data/lib/tasks/karst.rake +0 -34
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../value"
|
|
4
|
-
require_relative "candidate_population"
|
|
5
|
-
|
|
6
|
-
module Karst
|
|
7
|
-
module Access
|
|
8
|
-
# The explicit, bounded "does this discovered candidate actually work"
|
|
9
|
-
# step -- deliberately separate from Karst::Access::PopulationDiscovery,
|
|
10
|
-
# which never executes anything. Only ever resolves a model/method pair
|
|
11
|
-
# that a given Karst::Access::PopulationDiscovery::Result itself already
|
|
12
|
-
# discovered; a submitted name that is not on that list is rejected
|
|
13
|
-
# without calling anything, so this can never become a general "call any
|
|
14
|
-
# class method by name" endpoint. Every resolution is bounded to
|
|
15
|
-
# PREVIEW_LIMIT rows via Karst::Access::CandidatePopulation.resolve --
|
|
16
|
-
# never a COUNT, never full materialization.
|
|
17
|
-
class PopulationPreview
|
|
18
|
-
PREVIEW_LIMIT = 3
|
|
19
|
-
|
|
20
|
-
Result = Value.define(:model_name, :method_name, :resolved, :records, :error)
|
|
21
|
-
|
|
22
|
-
def self.call(model_name:, method_name:, discovery_result:)
|
|
23
|
-
new(model_name: model_name, method_name: method_name, discovery_result: discovery_result).call
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def initialize(model_name:, method_name:, discovery_result:)
|
|
27
|
-
@model_name = model_name.to_s
|
|
28
|
-
@method_name = method_name.to_s
|
|
29
|
-
@discovery_result = discovery_result
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def call
|
|
33
|
-
return unresolved("this is not a discovered candidate") unless known_candidate?
|
|
34
|
-
|
|
35
|
-
klass = model_class
|
|
36
|
-
return unresolved("the model could not be resolved") unless klass
|
|
37
|
-
|
|
38
|
-
resolve(klass)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
private
|
|
42
|
-
|
|
43
|
-
def known_candidate?
|
|
44
|
-
@discovery_result.candidates.any? do |candidate|
|
|
45
|
-
candidate.model_name == @model_name && candidate.method_name.to_s == @method_name
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def model_class
|
|
50
|
-
return nil unless defined?(ActiveRecord::Base)
|
|
51
|
-
|
|
52
|
-
ActiveRecord::Base.descendants.find { |klass| klass.name == @model_name }
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def resolve(klass)
|
|
56
|
-
method_name = @method_name
|
|
57
|
-
return missing_scope unless scope_exists?(klass, method_name)
|
|
58
|
-
|
|
59
|
-
population = CandidatePopulation.resolve(
|
|
60
|
-
name: method_name.to_sym, callable: -> { klass.public_send(method_name) },
|
|
61
|
-
source_klass: klass, limit: PREVIEW_LIMIT
|
|
62
|
-
)
|
|
63
|
-
return unresolved("did not resolve to a usable ActiveRecord::Relation for #{@model_name}") unless population
|
|
64
|
-
|
|
65
|
-
Result.new(model_name: @model_name, method_name: @method_name, resolved: true, records: population.records,
|
|
66
|
-
error: nil)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def scope_exists?(klass, method_name)
|
|
70
|
-
klass.respond_to?(method_name)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def missing_scope
|
|
74
|
-
unresolved("the discovered scope no longer exists on #{@model_name}")
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def unresolved(error)
|
|
78
|
-
Result.new(model_name: @model_name, method_name: @method_name, resolved: false, records: [].freeze,
|
|
79
|
-
error: error)
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "active_support/core_ext/string/inflections"
|
|
4
|
-
require_relative "../value"
|
|
5
|
-
require_relative "../identity"
|
|
6
|
-
|
|
7
|
-
module Karst
|
|
8
|
-
module Access
|
|
9
|
-
# Given one exact resource (the specific record a route addresses by id)
|
|
10
|
-
# and one specific principal (typically the successful outcome from an
|
|
11
|
-
# Access::Sweep), reports simple, directly observed foreign-key
|
|
12
|
-
# relationships between those two records. This is evidence, not an
|
|
13
|
-
# authorization claim: it never states or implies *why* an outcome
|
|
14
|
-
# occurred, only which foreign-key columns, if any, point from one given
|
|
15
|
-
# record to the other's id.
|
|
16
|
-
#
|
|
17
|
-
# Deliberately narrow, matching the v1 scope this class was built for:
|
|
18
|
-
# - only foreign-key-shaped columns (ending in "_id") on the two given
|
|
19
|
-
# records are ever inspected -- never an arbitrary attribute, so no
|
|
20
|
-
# other column value (name, email, token, ...) is ever read or shown;
|
|
21
|
-
# - only a direct column-value comparison between the two given records,
|
|
22
|
-
# never a join, a has_many traversal, or any multi-hop graph walk;
|
|
23
|
-
# - resource resolution from a route path is attempted only through
|
|
24
|
-
# Rails' own route recognition plus its controller-to-model naming
|
|
25
|
-
# convention, and only trusted when every step succeeds unambiguously
|
|
26
|
-
# (a recognized route with an :id segment, a controller name that
|
|
27
|
-
# classifies to a real loaded Active Record class, and a record that
|
|
28
|
-
# actually exists for that id). Anything softer -- an unrecognized
|
|
29
|
-
# route, a controller with no conventional model, a missing record --
|
|
30
|
-
# is reported as a limitation rather than guessed at.
|
|
31
|
-
# rubocop:disable Metrics/ClassLength
|
|
32
|
-
class ResourceEvidence
|
|
33
|
-
class Error < StandardError; end
|
|
34
|
-
|
|
35
|
-
# The resource side never gets Identity::PrincipalDescriptor's
|
|
36
|
-
# configurable display_label hook -- there is no equivalent concept
|
|
37
|
-
# for "the current route's resource" -- so it gets its own minimal,
|
|
38
|
-
# equally attribute-free descriptor.
|
|
39
|
-
ResourceDescriptor = Value.define(:model_name, :id)
|
|
40
|
-
|
|
41
|
-
# from_model/from_id is whichever of the resource/principal actually
|
|
42
|
-
# holds the foreign-key column; to_model/to_id is the other side.
|
|
43
|
-
Relationship = Value.define(:column, :from_model, :from_id, :to_model, :to_id)
|
|
44
|
-
|
|
45
|
-
Result = Value.define(:principal, :resource, :relationships, :observed_status, :observed_redirect,
|
|
46
|
-
:limitation) do
|
|
47
|
-
# Plain-text rendering deliberately kept free of causal wording
|
|
48
|
-
# ("owns", "is authorized", "grants") -- see class comment above.
|
|
49
|
-
def to_text
|
|
50
|
-
lines = [principal.display_label]
|
|
51
|
-
lines << observed_line if observed_status || observed_redirect
|
|
52
|
-
lines << "" << "Related state:"
|
|
53
|
-
lines.concat(related_state_lines)
|
|
54
|
-
lines.join("\n")
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
private
|
|
58
|
-
|
|
59
|
-
def observed_line
|
|
60
|
-
observed_redirect ? "Observed #{observed_status} → #{observed_redirect}" : "Observed #{observed_status}"
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def related_state_lines
|
|
64
|
-
return [" Unavailable: #{limitation}"] if limitation
|
|
65
|
-
return [no_relationship_line] if relationships.empty?
|
|
66
|
-
|
|
67
|
-
grouped_relationship_lines
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def no_relationship_line
|
|
71
|
-
" No observed foreign-key relationship to #{resource.model_name} ##{resource.id}."
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def grouped_relationship_lines
|
|
75
|
-
relationships.group_by { |rel| [rel.from_model, rel.from_id] }.flat_map do |(model, id), grouped|
|
|
76
|
-
["#{model} ##{id}"] + grouped.map { |rel| " #{rel.column} → #{rel.to_model} ##{rel.to_id}" }
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
class << self
|
|
82
|
-
# Resolves the resource, resolves the principal (from a
|
|
83
|
-
# Sweep::Outcome's PrincipalDescriptor), and reports relationships in
|
|
84
|
-
# one call. Either resolution step may fail safely -- see
|
|
85
|
-
# #resolve_resource and #resolve_principal -- in which case the
|
|
86
|
-
# Result carries a limitation instead of relationships.
|
|
87
|
-
def for_outcome(outcome:, path:, http_method: "GET", application: nil)
|
|
88
|
-
resource, resource_limitation = resolve_resource(path: path, http_method: http_method,
|
|
89
|
-
application: application)
|
|
90
|
-
principal, principal_limitation = resolve_principal(outcome.principal)
|
|
91
|
-
limitation = [resource_limitation, principal_limitation].compact.join("; ")
|
|
92
|
-
|
|
93
|
-
return unresolved_result(outcome, limitation) if resource.nil? || principal.nil?
|
|
94
|
-
|
|
95
|
-
new(resource: resource, principal: principal).call(
|
|
96
|
-
observed_status: outcome.status, observed_redirect: outcome.redirect
|
|
97
|
-
)
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
# Attempts to resolve the exact record a route addresses, trusting
|
|
101
|
-
# only Rails' own route recognition and controller naming
|
|
102
|
-
# convention, and only when every step is unambiguous. Returns
|
|
103
|
-
# [record, nil] on success or [nil, reason] when any step is not
|
|
104
|
-
# reliable -- never a guessed record.
|
|
105
|
-
def resolve_resource(path:, http_method: "GET", application: nil)
|
|
106
|
-
app = application || rails_application
|
|
107
|
-
return [nil, "no Rails application is available to recognize the route"] unless app
|
|
108
|
-
|
|
109
|
-
params = recognize(app, path, http_method)
|
|
110
|
-
return [nil, "the route could not be recognized"] unless params
|
|
111
|
-
|
|
112
|
-
id = params[:id]
|
|
113
|
-
return [nil, "the recognized route has no :id segment addressing one specific resource"] unless id
|
|
114
|
-
|
|
115
|
-
find_by_controller(params[:controller], id)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# Resolves the actual record behind a Karst::Identity::PrincipalDescriptor
|
|
119
|
-
# only through the configured principal source. A valid model/id outside
|
|
120
|
-
# that source is deliberately unresolved.
|
|
121
|
-
def resolve_principal(descriptor)
|
|
122
|
-
record = Identity.resolve(model_name: descriptor.model_name, id: descriptor.id)
|
|
123
|
-
return [nil, "principal is not available from the configured principal source"] unless record
|
|
124
|
-
|
|
125
|
-
[record, nil]
|
|
126
|
-
rescue Identity::Error
|
|
127
|
-
[nil, "the configured principal source is unavailable"]
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
private
|
|
131
|
-
|
|
132
|
-
def rails_application
|
|
133
|
-
defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def find_by_controller(controller, id)
|
|
137
|
-
klass = active_record_class(controller.to_s.classify)
|
|
138
|
-
return [nil, "the route's controller does not map to a loaded Active Record model by convention"] unless klass
|
|
139
|
-
|
|
140
|
-
record = klass.find_by(klass.primary_key => id)
|
|
141
|
-
return [nil, "no #{klass.name} record exists for id #{id.inspect}"] unless record
|
|
142
|
-
|
|
143
|
-
[record, nil]
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def unresolved_result(outcome, limitation)
|
|
147
|
-
Result.new(principal: outcome.principal, resource: nil, relationships: [].freeze,
|
|
148
|
-
observed_status: outcome.status, observed_redirect: outcome.redirect,
|
|
149
|
-
limitation: limitation.empty? ? "the resource or principal could not be resolved" : limitation)
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def recognize(app, path, http_method)
|
|
153
|
-
app.routes.recognize_path(path, method: http_method)
|
|
154
|
-
rescue StandardError
|
|
155
|
-
nil
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def active_record_class(name)
|
|
159
|
-
klass = name.to_s.safe_constantize
|
|
160
|
-
return nil unless defined?(ActiveRecord::Base) && klass.is_a?(Class) && klass < ActiveRecord::Base
|
|
161
|
-
|
|
162
|
-
klass
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def initialize(resource:, principal:)
|
|
167
|
-
@resource = resource
|
|
168
|
-
@principal = principal
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def call(observed_status: nil, observed_redirect: nil)
|
|
172
|
-
Result.new(
|
|
173
|
-
principal: Identity.describe(@principal),
|
|
174
|
-
resource: ResourceDescriptor.new(model_name: model_name(@resource), id: primary_key_value(@resource)),
|
|
175
|
-
relationships: relationships.freeze,
|
|
176
|
-
observed_status: observed_status,
|
|
177
|
-
observed_redirect: observed_redirect,
|
|
178
|
-
limitation: nil
|
|
179
|
-
)
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
private
|
|
183
|
-
|
|
184
|
-
def relationships
|
|
185
|
-
foreign_keys_from(@resource, @principal) + foreign_keys_from(@principal, @resource)
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def foreign_keys_from(source, target)
|
|
189
|
-
return [] unless active_record?(source) && active_record?(target)
|
|
190
|
-
|
|
191
|
-
source.class.columns_hash.values.filter_map { |column| relationship_for(source, target, column) }
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def relationship_for(source, target, column)
|
|
195
|
-
return unless foreign_key_column?(source.class, column)
|
|
196
|
-
return unless targets?(column, target.class)
|
|
197
|
-
|
|
198
|
-
value = source.public_send(column.name)
|
|
199
|
-
return if value.nil? || value != primary_key_value(target)
|
|
200
|
-
|
|
201
|
-
Relationship.new(column: column.name, from_model: model_name(source), from_id: primary_key_value(source),
|
|
202
|
-
to_model: model_name(target), to_id: primary_key_value(target))
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
def foreign_key_column?(klass, column)
|
|
206
|
-
column.name.end_with?("_id") && column.name != klass.primary_key
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
# "Obvious" is judged purely from the column's own name against the
|
|
210
|
-
# target's actual class (including its Active Record ancestry, so a
|
|
211
|
-
# single-table-inherited subclass still matches its base class's
|
|
212
|
-
# conventional foreign key) -- never a declared association, never any
|
|
213
|
-
# other column's value, and never a second hop through another model.
|
|
214
|
-
def targets?(column, target_klass)
|
|
215
|
-
candidate = column.name.delete_suffix("_id").classify.safe_constantize
|
|
216
|
-
candidate.is_a?(Class) && (target_klass <= candidate || candidate <= target_klass)
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def active_record?(record)
|
|
220
|
-
defined?(ActiveRecord::Base) && record.is_a?(ActiveRecord::Base)
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def model_name(record)
|
|
224
|
-
record.class.respond_to?(:model_name) ? record.class.model_name.name.to_s : record.class.name.to_s
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
def primary_key_value(record)
|
|
228
|
-
record.public_send(record.class.primary_key)
|
|
229
|
-
end
|
|
230
|
-
end
|
|
231
|
-
# rubocop:enable Metrics/ClassLength
|
|
232
|
-
end
|
|
233
|
-
end
|
data/lib/karst/spec/catalog.rb
DELETED
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require_relative "principal"
|
|
5
|
-
require_relative "scenario"
|
|
6
|
-
|
|
7
|
-
module Karst
|
|
8
|
-
module Spec
|
|
9
|
-
# Read-only index over the JSON artifact Karst::Spec::Observer writes,
|
|
10
|
-
# answering "what scenarios have been observed for this controller/action"
|
|
11
|
-
# without requiring RSpec, Rails, or the host application's database to be
|
|
12
|
-
# loaded. This is pure data ingestion: JSON primitives in, immutable
|
|
13
|
-
# Scenario objects out. It never deserializes arbitrary Ruby objects,
|
|
14
|
-
# never constantizes a principal type, and never queries anything.
|
|
15
|
-
#
|
|
16
|
-
# A Catalog is always in exactly one of three states:
|
|
17
|
-
#
|
|
18
|
-
# - :missing -- the artifact does not exist yet (the suite has never
|
|
19
|
-
# been run with the observer installed).
|
|
20
|
-
# - :invalid -- the artifact exists but could not be read as the expected
|
|
21
|
-
# JSON array (empty file, malformed JSON, or an incompatible top-level
|
|
22
|
-
# shape). `error` holds a human-readable reason.
|
|
23
|
-
# - :ready -- the artifact was a valid JSON array. `scenarios` may still
|
|
24
|
-
# be empty (a suite that observed zero browser-facing requests), which
|
|
25
|
-
# is deliberately distinct from :missing: "no scenarios cover this
|
|
26
|
-
# route" is not the same claim as "the catalog was never generated."
|
|
27
|
-
#
|
|
28
|
-
# Malformed individual entries inside an otherwise valid array (a request
|
|
29
|
-
# missing its controller, an example with no requests at all) are skipped
|
|
30
|
-
# individually rather than invalidating the whole artifact.
|
|
31
|
-
# rubocop:disable Metrics/ClassLength
|
|
32
|
-
class Catalog
|
|
33
|
-
DEFAULT_RELATIVE_PATH = File.join("tmp", "karst", "scenarios.json")
|
|
34
|
-
private_constant :DEFAULT_RELATIVE_PATH
|
|
35
|
-
|
|
36
|
-
EMPTY = [].freeze
|
|
37
|
-
private_constant :EMPTY
|
|
38
|
-
|
|
39
|
-
OUTCOMES = %w[passed failed pending].freeze
|
|
40
|
-
private_constant :OUTCOMES
|
|
41
|
-
|
|
42
|
-
class << self
|
|
43
|
-
# Rails' own tmp/ convention when available, otherwise a plain
|
|
44
|
-
# relative path so this stays usable from a bare Ruby process (tests,
|
|
45
|
-
# a future CLI) with no Rails loaded.
|
|
46
|
-
def default_path
|
|
47
|
-
return DEFAULT_RELATIVE_PATH unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
48
|
-
|
|
49
|
-
File.join(Rails.root.to_s, DEFAULT_RELATIVE_PATH)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def load(path: default_path)
|
|
53
|
-
new(*read(path))
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
private
|
|
57
|
-
|
|
58
|
-
def read(path)
|
|
59
|
-
return [:missing, EMPTY, nil] unless File.exist?(path)
|
|
60
|
-
|
|
61
|
-
raw = File.read(path)
|
|
62
|
-
return [:invalid, EMPTY, "scenario artifact at #{path} is empty"] if raw.strip.empty?
|
|
63
|
-
|
|
64
|
-
parse(raw, path)
|
|
65
|
-
rescue SystemCallError => e
|
|
66
|
-
[:invalid, EMPTY, "scenario artifact at #{path} could not be read: #{e.message}"]
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def parse(raw, path)
|
|
70
|
-
parsed = JSON.parse(raw)
|
|
71
|
-
return [:invalid, EMPTY, "scenario artifact at #{path} is not a JSON array"] unless parsed.is_a?(Array)
|
|
72
|
-
|
|
73
|
-
[:ready, build_scenarios(parsed), nil]
|
|
74
|
-
rescue JSON::ParserError => e
|
|
75
|
-
[:invalid, EMPTY, "scenario artifact at #{path} is not valid JSON: #{e.message}"]
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# Ordered deterministically so two reads of an unchanged artifact
|
|
79
|
-
# agree: passed examples before failed/pending ones, then file path,
|
|
80
|
-
# line number, example id, and finally request sequence (the
|
|
81
|
-
# tie-breaker that keeps multiple scenarios from the same example in
|
|
82
|
-
# their original request order). Explicitly labelled QA scenarios lead
|
|
83
|
-
# discovered scenarios while preserving those guarantees within each
|
|
84
|
-
# group.
|
|
85
|
-
def build_scenarios(examples)
|
|
86
|
-
scenarios = examples.flat_map { |example| scenarios_from_example(example) }
|
|
87
|
-
scenarios.sort_by { |scenario| sort_key(scenario) }.freeze
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def sort_key(scenario)
|
|
91
|
-
[scenario.explicit? ? 0 : 1, scenario.passed? ? 0 : 1, scenario.file_path, scenario.line_number,
|
|
92
|
-
scenario.example_id, scenario.sequence]
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def scenarios_from_example(example)
|
|
96
|
-
return EMPTY unless example.is_a?(Hash) && valid_example?(example)
|
|
97
|
-
|
|
98
|
-
example["requests"].filter_map { |request| scenario_from_request(request, example) }
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def valid_example?(example)
|
|
102
|
-
example["example_id"].is_a?(String) && example["file_path"].is_a?(String) &&
|
|
103
|
-
example["line_number"].is_a?(Integer) && example["description_parts"].is_a?(Array) &&
|
|
104
|
-
example["full_description"].is_a?(String) && example["requests"].is_a?(Array)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
# A request only becomes a Scenario once it carries enough evidence to
|
|
108
|
-
# be indexed and shown: a browser-facing (HTML) response, a resolved
|
|
109
|
-
# controller/action, an HTTP method, and its position within the
|
|
110
|
-
# example. Everything else observed about the request (route pattern,
|
|
111
|
-
# path, status, redirect target, principal before/after) is retained
|
|
112
|
-
# as-is, including nil, since a nil there is itself real evidence
|
|
113
|
-
# (e.g. the request never reached `process_action`).
|
|
114
|
-
def scenario_from_request(request, example)
|
|
115
|
-
return nil unless request.is_a?(Hash) && request["format"] == "html" && valid_request?(request)
|
|
116
|
-
|
|
117
|
-
build_scenario(request, example)
|
|
118
|
-
rescue StandardError
|
|
119
|
-
nil
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def valid_request?(request)
|
|
123
|
-
required = %w[controller action method].all? { |key| non_empty_string?(request[key]) }
|
|
124
|
-
required && request["sequence"].is_a?(Integer)
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def non_empty_string?(value)
|
|
128
|
-
value.is_a?(String) && !value.empty?
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
132
|
-
def build_scenario(request, example)
|
|
133
|
-
Scenario.new(
|
|
134
|
-
example_id: example["example_id"], file_path: example["file_path"], line_number: example["line_number"],
|
|
135
|
-
description_parts: example["description_parts"].freeze, full_description: example["full_description"],
|
|
136
|
-
karst_explicit: example["karst_explicit"] == true,
|
|
137
|
-
karst_name: non_empty_string?(example["karst_name"]) ? example["karst_name"] : nil,
|
|
138
|
-
example_outcome: outcome_for(example["outcome"]),
|
|
139
|
-
controller: request["controller"], action: request["action"], http_method: request["method"],
|
|
140
|
-
route_pattern: request["route_pattern"], observed_path: request["path"],
|
|
141
|
-
observed_status: request["status"], observed_redirect: request["redirect_location"],
|
|
142
|
-
principal_before: principal_from(request["principal_before"]),
|
|
143
|
-
principal_after: principal_from(request["principal_after"]),
|
|
144
|
-
principal_changed: request["principal_changed"] == true, sequence: request["sequence"]
|
|
145
|
-
).freeze
|
|
146
|
-
end
|
|
147
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
148
|
-
|
|
149
|
-
def outcome_for(raw)
|
|
150
|
-
OUTCOMES.include?(raw) ? raw.to_sym : :unknown
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def principal_from(raw)
|
|
154
|
-
return nil unless raw.is_a?(Hash)
|
|
155
|
-
|
|
156
|
-
Principal.new(type: raw["type"], id: raw["id"], scope: raw["scope"])
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
private_class_method :new
|
|
160
|
-
|
|
161
|
-
attr_reader :status, :scenarios, :error
|
|
162
|
-
|
|
163
|
-
def initialize(status, scenarios, error)
|
|
164
|
-
@status = status
|
|
165
|
-
@scenarios = scenarios
|
|
166
|
-
@error = error
|
|
167
|
-
@index = build_index(scenarios)
|
|
168
|
-
freeze
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def ready?
|
|
172
|
-
status == :ready
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
# Indexed primarily by controller/action -- Rails' own stable routing
|
|
176
|
-
# identity -- so "/things/1" and "/things/2" report as the same
|
|
177
|
-
# capability instead of fragmenting by dynamic id. `http_method`
|
|
178
|
-
# narrows further for the (uncommon) case where one controller/action
|
|
179
|
-
# legitimately answers more than one verb; omitted, every scenario for
|
|
180
|
-
# that controller/action is returned regardless of method.
|
|
181
|
-
def scenarios_for(controller:, action:, http_method: nil)
|
|
182
|
-
matches = @index.fetch([controller, action], EMPTY)
|
|
183
|
-
return matches if http_method.nil?
|
|
184
|
-
|
|
185
|
-
normalized = http_method.to_s.upcase
|
|
186
|
-
matches.select { |scenario| scenario.http_method == normalized }.freeze
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
private
|
|
190
|
-
|
|
191
|
-
def build_index(scenarios)
|
|
192
|
-
scenarios.group_by { |scenario| [scenario.controller, scenario.action] }
|
|
193
|
-
.transform_values(&:freeze)
|
|
194
|
-
.freeze
|
|
195
|
-
end
|
|
196
|
-
end
|
|
197
|
-
# rubocop:enable Metrics/ClassLength
|
|
198
|
-
end
|
|
199
|
-
end
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../value"
|
|
4
|
-
|
|
5
|
-
module Karst
|
|
6
|
-
module Spec
|
|
7
|
-
# One RSpec example's complete observed request history: every request it
|
|
8
|
-
# issued, in order, plus enough of RSpec's own metadata (file/line,
|
|
9
|
-
# nested description, stable example id, pass/fail outcome) to present
|
|
10
|
-
# and re-locate the example without re-parsing spec source.
|
|
11
|
-
ExampleObservation = Value.define(
|
|
12
|
-
:example_id,
|
|
13
|
-
:file_path,
|
|
14
|
-
:line_number,
|
|
15
|
-
:spec_type,
|
|
16
|
-
:description_parts,
|
|
17
|
-
:full_description,
|
|
18
|
-
:karst_explicit,
|
|
19
|
-
:karst_name,
|
|
20
|
-
:outcome,
|
|
21
|
-
:requests
|
|
22
|
-
) do
|
|
23
|
-
# An example is part of Karst's route/page catalog only if at least one
|
|
24
|
-
# of its requests rendered HTML -- an examples whose only requests are
|
|
25
|
-
# JSON API calls has nothing to show a person exercising a page.
|
|
26
|
-
def browser_facing?
|
|
27
|
-
requests.any? { |request| request.format == "html" }
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|