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
data/lib/karst/spec/observer.rb
DELETED
|
@@ -1,300 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "logger" # see lib/karst.rb for why this must precede active_support on Rails 6.1
|
|
4
|
-
require "active_support"
|
|
5
|
-
require "active_support/notifications"
|
|
6
|
-
require_relative "../execution_context"
|
|
7
|
-
require_relative "principal"
|
|
8
|
-
require_relative "request_observation"
|
|
9
|
-
require_relative "example_observation"
|
|
10
|
-
require_relative "reporter"
|
|
11
|
-
|
|
12
|
-
module Karst
|
|
13
|
-
module Spec
|
|
14
|
-
class InvalidMetadataError < StandardError; end
|
|
15
|
-
|
|
16
|
-
# Turns real RSpec execution into Karst's route/scenario catalog.
|
|
17
|
-
#
|
|
18
|
-
# Karst never parses spec source, route-helper arguments, or FactoryBot
|
|
19
|
-
# calls to build this catalog. It observes the same runtime facts the
|
|
20
|
-
# request-evidence engine already relies on -- ActiveSupport::Notifications
|
|
21
|
-
# for controller/render events, and Warden's public hooks for the
|
|
22
|
-
# authenticated principal -- while an example actually runs, and records
|
|
23
|
-
# only what those events say. An example that never issues an HTTP
|
|
24
|
-
# request produces no observation at all.
|
|
25
|
-
#
|
|
26
|
-
# Explicitly out of scope here: provisioning scenario state, database
|
|
27
|
-
# cloning or isolation, browser/session switching, source analysis, and
|
|
28
|
-
# any UI. This module only builds and writes the catalog artifact.
|
|
29
|
-
# rubocop:disable Metrics/ModuleLength
|
|
30
|
-
module Observer
|
|
31
|
-
KEY = :karst_spec_observer_current
|
|
32
|
-
private_constant :KEY
|
|
33
|
-
|
|
34
|
-
# Mutable accumulator for the example currently running. `principal` is
|
|
35
|
-
# updated in place by the Warden hooks as the example progresses, so
|
|
36
|
-
# each request observes the principal that was active when it happened.
|
|
37
|
-
Current = Struct.new(:requests, :principal, keyword_init: true)
|
|
38
|
-
private_constant :Current
|
|
39
|
-
|
|
40
|
-
# Mutable request-in-progress. Frozen into a RequestObservation once the
|
|
41
|
-
# example finishes; never exposed outside this module. Named
|
|
42
|
-
# `http_method`, not `method`, so it never shadows Object#method.
|
|
43
|
-
RequestBuilder = Struct.new(
|
|
44
|
-
:sequence, :http_method, :path, :route_pattern, :controller, :action, :format,
|
|
45
|
-
:status, :redirect_location, :principal_before, :principal_after,
|
|
46
|
-
keyword_init: true
|
|
47
|
-
)
|
|
48
|
-
private_constant :RequestBuilder
|
|
49
|
-
|
|
50
|
-
# rubocop:disable Metrics/ClassLength
|
|
51
|
-
class << self
|
|
52
|
-
attr_reader :reporter, :output_path
|
|
53
|
-
|
|
54
|
-
# rubocop:disable Metrics/MethodLength
|
|
55
|
-
def install!(output:)
|
|
56
|
-
@install_mutex ||= Mutex.new
|
|
57
|
-
@install_mutex.synchronize do
|
|
58
|
-
return @reporter if @installed
|
|
59
|
-
|
|
60
|
-
raise "Karst::Spec::Observer requires RSpec to already be loaded" unless defined?(RSpec)
|
|
61
|
-
|
|
62
|
-
@output_path = output
|
|
63
|
-
@reporter = Reporter.new
|
|
64
|
-
subscribe_notifications
|
|
65
|
-
subscribe_warden
|
|
66
|
-
configure_rspec
|
|
67
|
-
@installed = true
|
|
68
|
-
@reporter
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
# rubocop:enable Metrics/MethodLength
|
|
72
|
-
|
|
73
|
-
# The one seam RSpec's `around` hook calls into: start tracking,
|
|
74
|
-
# run the example, then convert whatever was tracked into an
|
|
75
|
-
# immutable ExampleObservation and hand it to the Reporter.
|
|
76
|
-
def wrap_example(example)
|
|
77
|
-
karst_explicit, karst_name = karst_metadata(example)
|
|
78
|
-
start_example!
|
|
79
|
-
yield
|
|
80
|
-
ensure
|
|
81
|
-
finish_and_record!(example, karst_explicit: karst_explicit, karst_name: karst_name)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
private
|
|
85
|
-
|
|
86
|
-
def current
|
|
87
|
-
Karst::ExecutionContext[KEY]
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def start_example!
|
|
91
|
-
Karst::ExecutionContext[KEY] = Current.new(requests: [], principal: nil)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def finish_example!
|
|
95
|
-
state = current
|
|
96
|
-
Karst::ExecutionContext.delete(KEY)
|
|
97
|
-
state
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def subscribe_notifications
|
|
101
|
-
ActiveSupport::Notifications.subscribe("start_processing.action_controller") do |*args|
|
|
102
|
-
on_start_processing(args.last)
|
|
103
|
-
end
|
|
104
|
-
ActiveSupport::Notifications.subscribe("redirect_to.action_controller") do |*args|
|
|
105
|
-
on_redirect(args.last)
|
|
106
|
-
end
|
|
107
|
-
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
|
|
108
|
-
on_process_action(args.last)
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
113
|
-
def on_start_processing(payload)
|
|
114
|
-
state = current
|
|
115
|
-
return unless state
|
|
116
|
-
|
|
117
|
-
state.requests << RequestBuilder.new(
|
|
118
|
-
sequence: state.requests.size,
|
|
119
|
-
http_method: payload[:method],
|
|
120
|
-
path: strip_query(payload[:path]),
|
|
121
|
-
route_pattern: route_pattern_for(payload[:request]),
|
|
122
|
-
controller: payload[:controller],
|
|
123
|
-
action: payload[:action],
|
|
124
|
-
format: payload[:format]&.to_s,
|
|
125
|
-
status: nil,
|
|
126
|
-
redirect_location: nil,
|
|
127
|
-
principal_before: state.principal,
|
|
128
|
-
principal_after: state.principal
|
|
129
|
-
)
|
|
130
|
-
end
|
|
131
|
-
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
|
132
|
-
|
|
133
|
-
def on_redirect(payload)
|
|
134
|
-
builder = current&.requests&.last
|
|
135
|
-
return unless builder
|
|
136
|
-
|
|
137
|
-
builder.redirect_location = strip_query(strip_host(payload[:location]))
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def on_process_action(payload)
|
|
141
|
-
state = current
|
|
142
|
-
builder = state&.requests&.last
|
|
143
|
-
return unless builder
|
|
144
|
-
|
|
145
|
-
builder.status = payload[:status]
|
|
146
|
-
builder.principal_after = state.principal
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
# Query strings can carry tokens (password resets, OAuth callbacks,
|
|
150
|
-
# signed URLs) and are never retained, on request paths or on
|
|
151
|
-
# redirect targets below -- either can leak the same class of secret
|
|
152
|
-
# into the catalog artifact.
|
|
153
|
-
def strip_query(value)
|
|
154
|
-
value.to_s.split("?").first
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def strip_host(location)
|
|
158
|
-
location.to_s.sub(%r{\Ahttps?://[^/]+}, "")
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# Uses Rails' own routing engine to recover the declared path pattern
|
|
162
|
-
# (e.g. "/things/:id(.:format)") for the exact request that was
|
|
163
|
-
# already routed, rather than guessing from route-helper call sites
|
|
164
|
-
# in spec source. Falls back to nil -- never a guess -- if routing
|
|
165
|
-
# metadata is unavailable.
|
|
166
|
-
def route_pattern_for(request)
|
|
167
|
-
return nil unless request && defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
168
|
-
|
|
169
|
-
pattern = nil
|
|
170
|
-
Rails.application.routes.router.recognize(request) do |route, _params|
|
|
171
|
-
pattern = route.path.spec.to_s
|
|
172
|
-
break
|
|
173
|
-
end
|
|
174
|
-
pattern
|
|
175
|
-
rescue StandardError
|
|
176
|
-
nil
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
# Warden is optional: an application with no Warden-based
|
|
180
|
-
# authentication simply never populates `principal`, and every
|
|
181
|
-
# request is recorded with `principal: nil` and role :subject.
|
|
182
|
-
# rubocop:disable Metrics/MethodLength
|
|
183
|
-
def subscribe_warden
|
|
184
|
-
return unless defined?(Warden::Manager)
|
|
185
|
-
|
|
186
|
-
Warden::Manager.after_set_user do |user, _auth, opts|
|
|
187
|
-
state = current
|
|
188
|
-
next unless state
|
|
189
|
-
|
|
190
|
-
state.principal = Principal.new(type: user.class.name, id: user.id, scope: opts[:scope])
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
Warden::Manager.before_logout do |_user, _auth, _opts|
|
|
194
|
-
state = current
|
|
195
|
-
next unless state
|
|
196
|
-
|
|
197
|
-
state.principal = nil
|
|
198
|
-
end
|
|
199
|
-
end
|
|
200
|
-
# rubocop:enable Metrics/MethodLength
|
|
201
|
-
|
|
202
|
-
def configure_rspec
|
|
203
|
-
RSpec.configure do |config|
|
|
204
|
-
config.around do |example|
|
|
205
|
-
Observer.wrap_example(example) { example.run }
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
config.after(:suite) do
|
|
209
|
-
Observer.reporter.write(Observer.output_path)
|
|
210
|
-
end
|
|
211
|
-
end
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
215
|
-
def finish_and_record!(example, karst_explicit:, karst_name:)
|
|
216
|
-
state = finish_example!
|
|
217
|
-
return unless state
|
|
218
|
-
return if state.requests.empty?
|
|
219
|
-
|
|
220
|
-
reporter.record(
|
|
221
|
-
ExampleObservation.new(
|
|
222
|
-
example_id: example.id,
|
|
223
|
-
file_path: example.metadata[:file_path],
|
|
224
|
-
line_number: example.metadata[:line_number],
|
|
225
|
-
spec_type: example.metadata[:type],
|
|
226
|
-
description_parts: description_parts(example),
|
|
227
|
-
full_description: example.full_description,
|
|
228
|
-
karst_explicit: karst_explicit,
|
|
229
|
-
karst_name: karst_name,
|
|
230
|
-
outcome: outcome_for(example),
|
|
231
|
-
requests: freeze_requests(state.requests)
|
|
232
|
-
).freeze
|
|
233
|
-
)
|
|
234
|
-
end
|
|
235
|
-
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
|
236
|
-
|
|
237
|
-
# RSpec finalizes `execution_result.status` in Example#finish, which
|
|
238
|
-
# runs strictly after the around-hook chain returns -- it is always
|
|
239
|
-
# nil at this point, however this method is reached. `exception` and
|
|
240
|
-
# `pending_message` are set earlier, before `run_after_example`, so
|
|
241
|
-
# this mirrors RSpec's own status derivation instead of reading a
|
|
242
|
-
# field that has not been assigned yet.
|
|
243
|
-
def outcome_for(example)
|
|
244
|
-
return :failed if example.exception
|
|
245
|
-
return :pending if example.execution_result.pending_message
|
|
246
|
-
|
|
247
|
-
:passed
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
251
|
-
def freeze_requests(builders)
|
|
252
|
-
builders.map do |builder|
|
|
253
|
-
RequestObservation.new(
|
|
254
|
-
sequence: builder.sequence,
|
|
255
|
-
http_method: builder.http_method,
|
|
256
|
-
path: builder.path,
|
|
257
|
-
route_pattern: builder.route_pattern,
|
|
258
|
-
controller: builder.controller,
|
|
259
|
-
action: builder.action,
|
|
260
|
-
format: builder.format,
|
|
261
|
-
status: builder.status,
|
|
262
|
-
redirect_location: builder.redirect_location,
|
|
263
|
-
principal_before: builder.principal_before,
|
|
264
|
-
principal_after: builder.principal_after,
|
|
265
|
-
principal_changed: builder.principal_after != builder.principal_before
|
|
266
|
-
).freeze
|
|
267
|
-
end.freeze
|
|
268
|
-
end
|
|
269
|
-
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
|
270
|
-
|
|
271
|
-
def description_parts(example)
|
|
272
|
-
outer = example.example_group.parent_groups.reverse.map(&:description)
|
|
273
|
-
(outer + [example.description]).freeze
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
# rubocop:disable Metrics/MethodLength
|
|
277
|
-
def karst_metadata(example)
|
|
278
|
-
return [false, nil] unless example.metadata.key?(:karst)
|
|
279
|
-
|
|
280
|
-
value = example.metadata[:karst]
|
|
281
|
-
name = if value.is_a?(String)
|
|
282
|
-
value
|
|
283
|
-
elsif value.is_a?(Hash) && value.keys == [:name]
|
|
284
|
-
value[:name]
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
unless name.is_a?(String) && !name.strip.empty?
|
|
288
|
-
raise InvalidMetadataError,
|
|
289
|
-
"Invalid karst: metadata for #{example.id}; expected a non-empty String or { name: non_empty_string }"
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
[true, name]
|
|
293
|
-
end
|
|
294
|
-
# rubocop:enable Metrics/MethodLength
|
|
295
|
-
end
|
|
296
|
-
# rubocop:enable Metrics/ClassLength
|
|
297
|
-
end
|
|
298
|
-
# rubocop:enable Metrics/ModuleLength
|
|
299
|
-
end
|
|
300
|
-
end
|
data/lib/karst/spec/principal.rb
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../value"
|
|
4
|
-
|
|
5
|
-
module Karst
|
|
6
|
-
module Spec
|
|
7
|
-
# Minimal principal evidence observed via Warden's public hooks during an
|
|
8
|
-
# RSpec example: class name and primary key only, never a serialized user
|
|
9
|
-
# object, mirroring Karst's runtime-evidence principal model.
|
|
10
|
-
Principal = Value.define(:type, :id, :scope)
|
|
11
|
-
end
|
|
12
|
-
end
|
data/lib/karst/spec/reporter.rb
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "fileutils"
|
|
5
|
-
|
|
6
|
-
module Karst
|
|
7
|
-
module Spec
|
|
8
|
-
# Collects ExampleObservation instances as the suite runs and serializes
|
|
9
|
-
# them to one deterministic JSON artifact on disk. Never touches the host
|
|
10
|
-
# application's database: this is the only persistence Reporter performs.
|
|
11
|
-
class Reporter
|
|
12
|
-
def initialize
|
|
13
|
-
@examples = []
|
|
14
|
-
@mutex = Mutex.new
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def record(example_observation)
|
|
18
|
-
@mutex.synchronize { @examples << example_observation }
|
|
19
|
-
self
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def to_a
|
|
23
|
-
@mutex.synchronize { @examples.dup }
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Ordered by file path then line number, independent of RSpec run order
|
|
27
|
-
# (`--order random` reshuffles examples but must not reshuffle the
|
|
28
|
-
# artifact), so two runs of an unchanged suite produce identical JSON.
|
|
29
|
-
def write(path)
|
|
30
|
-
browser_facing = @mutex.synchronize { @examples.select(&:browser_facing?) }
|
|
31
|
-
ordered = browser_facing.sort_by { |example| [example.file_path.to_s, example.line_number.to_i] }
|
|
32
|
-
|
|
33
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
34
|
-
File.write(path, "#{JSON.pretty_generate(ordered.map { |example| serialize(example) })}\n")
|
|
35
|
-
path
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
private
|
|
39
|
-
|
|
40
|
-
# rubocop:disable Metrics/MethodLength
|
|
41
|
-
def serialize(example)
|
|
42
|
-
{
|
|
43
|
-
"example_id" => example.example_id,
|
|
44
|
-
"file_path" => example.file_path,
|
|
45
|
-
"line_number" => example.line_number,
|
|
46
|
-
"spec_type" => example.spec_type&.to_s,
|
|
47
|
-
"description_parts" => example.description_parts,
|
|
48
|
-
"full_description" => example.full_description,
|
|
49
|
-
"karst_explicit" => example.karst_explicit,
|
|
50
|
-
"karst_name" => example.karst_name,
|
|
51
|
-
"outcome" => example.outcome.to_s,
|
|
52
|
-
"requests" => example.requests.map { |request| serialize_request(request) }
|
|
53
|
-
}
|
|
54
|
-
end
|
|
55
|
-
# rubocop:enable Metrics/MethodLength
|
|
56
|
-
|
|
57
|
-
# rubocop:disable Metrics/MethodLength
|
|
58
|
-
def serialize_request(request)
|
|
59
|
-
{
|
|
60
|
-
"sequence" => request.sequence,
|
|
61
|
-
"method" => request.http_method,
|
|
62
|
-
"path" => request.path,
|
|
63
|
-
"route_pattern" => request.route_pattern,
|
|
64
|
-
"controller" => request.controller,
|
|
65
|
-
"action" => request.action,
|
|
66
|
-
"format" => request.format,
|
|
67
|
-
"status" => request.status,
|
|
68
|
-
"redirect_location" => request.redirect_location,
|
|
69
|
-
"principal_before" => serialize_principal(request.principal_before),
|
|
70
|
-
"principal_after" => serialize_principal(request.principal_after),
|
|
71
|
-
"principal_changed" => request.principal_changed
|
|
72
|
-
}
|
|
73
|
-
end
|
|
74
|
-
# rubocop:enable Metrics/MethodLength
|
|
75
|
-
|
|
76
|
-
def serialize_principal(principal)
|
|
77
|
-
return nil unless principal
|
|
78
|
-
|
|
79
|
-
{ "type" => principal.type, "id" => principal.id, "scope" => principal.scope&.to_s }
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../value"
|
|
4
|
-
|
|
5
|
-
module Karst
|
|
6
|
-
module Spec
|
|
7
|
-
# One HTTP request observed during a single RSpec example.
|
|
8
|
-
#
|
|
9
|
-
# `principal_before`/`principal_after` are the active Warden principal
|
|
10
|
-
# immediately before and immediately after this request was processed;
|
|
11
|
-
# `principal_changed` is true when they differ -- a login, a logout, or a
|
|
12
|
-
# switch from one principal to another. This is raw observed evidence,
|
|
13
|
-
# not an interpretation of what the request was FOR. Karst does not
|
|
14
|
-
# classify a request as "setup" or "the subject under test": a signup
|
|
15
|
-
# route, an invitation-acceptance route, or a checkout-completion route
|
|
16
|
-
# that happens to establish a session is a legitimate subject request,
|
|
17
|
-
# not authentication plumbing, and a single request carries no reliable
|
|
18
|
-
# signal for telling those apart. That classification, if Karst ever
|
|
19
|
-
# offers one, belongs to catalog-building logic downstream of this
|
|
20
|
-
# observer, informed by more context than one request can supply.
|
|
21
|
-
#
|
|
22
|
-
# Named `http_method`, not `method`, so it never shadows Object#method.
|
|
23
|
-
RequestObservation = Value.define(
|
|
24
|
-
:sequence,
|
|
25
|
-
:http_method,
|
|
26
|
-
:path,
|
|
27
|
-
:route_pattern,
|
|
28
|
-
:controller,
|
|
29
|
-
:action,
|
|
30
|
-
:format,
|
|
31
|
-
:status,
|
|
32
|
-
:redirect_location,
|
|
33
|
-
:principal_before,
|
|
34
|
-
:principal_after,
|
|
35
|
-
:principal_changed
|
|
36
|
-
)
|
|
37
|
-
end
|
|
38
|
-
end
|
data/lib/karst/spec/scenario.rb
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../value"
|
|
4
|
-
|
|
5
|
-
module Karst
|
|
6
|
-
module Spec
|
|
7
|
-
# One RSpec example exercising one specific browser-facing controller/action
|
|
8
|
-
# request, built entirely from evidence already present in the JSON artifact
|
|
9
|
-
# Karst::Spec::Observer writes.
|
|
10
|
-
#
|
|
11
|
-
# `observed_status`/`observed_redirect` describe what this spec execution
|
|
12
|
-
# observed while it ran, never what the example asserted -- a failing or
|
|
13
|
-
# pending example still produces a Scenario, and `example_outcome` is how
|
|
14
|
-
# a consumer tells "verified" apart from "merely observed."
|
|
15
|
-
#
|
|
16
|
-
# An example that issues several browser-facing requests (a denied attempt
|
|
17
|
-
# followed by an allowed retry, a sign-in followed by the page it unlocks)
|
|
18
|
-
# legitimately produces one Scenario per such request: Karst never
|
|
19
|
-
# collapses an example down to "its last request," and never labels any
|
|
20
|
-
# request as authentication setup versus the subject under test.
|
|
21
|
-
#
|
|
22
|
-
# `principal_before`/`principal_after` are both kept, not collapsed to
|
|
23
|
-
# whichever was active when the request began: a signup or checkout
|
|
24
|
-
# scenario that establishes a session is exactly the case where the
|
|
25
|
-
# identity a request produces matters as much as the identity it started
|
|
26
|
-
# with, and either side alone would discard real evidence.
|
|
27
|
-
Scenario = Value.define(
|
|
28
|
-
:example_id,
|
|
29
|
-
:file_path,
|
|
30
|
-
:line_number,
|
|
31
|
-
:description_parts,
|
|
32
|
-
:full_description,
|
|
33
|
-
:karst_explicit,
|
|
34
|
-
:karst_name,
|
|
35
|
-
:example_outcome,
|
|
36
|
-
:controller,
|
|
37
|
-
:action,
|
|
38
|
-
:http_method,
|
|
39
|
-
:route_pattern,
|
|
40
|
-
:observed_path,
|
|
41
|
-
:observed_status,
|
|
42
|
-
:observed_redirect,
|
|
43
|
-
:principal_before,
|
|
44
|
-
:principal_after,
|
|
45
|
-
:principal_changed,
|
|
46
|
-
:sequence
|
|
47
|
-
) do
|
|
48
|
-
# The most specific zero-config name available without repeating the
|
|
49
|
-
# whole describe chain: RSpec's own nesting already puts the most
|
|
50
|
-
# specific description last. Never invents a persona the spec itself
|
|
51
|
-
# did not name.
|
|
52
|
-
def name
|
|
53
|
-
karst_name || description_parts.last || full_description
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def passed?
|
|
57
|
-
example_outcome == :passed
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def explicit?
|
|
61
|
-
karst_explicit
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
data/lib/tasks/karst.rake
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
namespace :karst do
|
|
4
|
-
desc "List zero-argument scopes declared directly in application model source. Never executes them."
|
|
5
|
-
task populations: :environment do
|
|
6
|
-
require "karst/access/population_discovery"
|
|
7
|
-
require "karst/access/population_approvals"
|
|
8
|
-
|
|
9
|
-
result = Karst::Access::PopulationDiscovery.new.call
|
|
10
|
-
approvals = Karst::Access::PopulationApprovals.load
|
|
11
|
-
groups = result.model_groups.reject { |group| group.candidate_names.empty? }
|
|
12
|
-
|
|
13
|
-
puts "Warning: #{result.load_warning}\n\n" if result.load_warning
|
|
14
|
-
puts "Warning: #{approvals.error}\n\n" if approvals.error
|
|
15
|
-
puts "No candidate scopes were discovered." if groups.empty?
|
|
16
|
-
|
|
17
|
-
groups.each do |group|
|
|
18
|
-
label = group.principal_source ? " (principal source: #{group.principal_source})" : ""
|
|
19
|
-
puts "#{group.model_name}#{label} -- #{group.candidate_names.size} scope(s)"
|
|
20
|
-
group.candidate_names.each do |name|
|
|
21
|
-
puts " #{name}#{' [approved]' if approvals.approved?(group.model_name, name)}"
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
puts "", <<~NOTES
|
|
26
|
-
These are discovered scopes only -- Karst has not verified any of them return a usable relation,
|
|
27
|
-
and none of this grants access or is wired into sampling on its own.
|
|
28
|
-
Only scopes declared directly on application models are included; concern-defined scopes may not appear.
|
|
29
|
-
Approve the ones Karst may try at /karst/populations. Approvals are stored locally in
|
|
30
|
-
#{Karst::Access::PopulationApprovals.display_path}; delete that file to reset them.
|
|
31
|
-
config.principal_populations remains supported and always takes precedence.
|
|
32
|
-
NOTES
|
|
33
|
-
end
|
|
34
|
-
end
|