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