karst 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/ARCHITECTURE.md +59 -0
- data/CHANGELOG.md +67 -0
- data/CODE_OF_CONDUCT.md +29 -0
- data/CONTRIBUTING.md +45 -0
- data/LICENSE +21 -0
- data/README.md +140 -0
- data/SECURITY.md +11 -0
- data/docs/advanced-configuration.md +188 -0
- data/lib/generators/karst/install/install_generator.rb +88 -0
- data/lib/generators/karst/install/templates/karst_identity_controller.rb +19 -0
- data/lib/generators/karst/install/templates/karst_initializer.rb +18 -0
- data/lib/karst/access/approved_populations.rb +128 -0
- data/lib/karst/access/candidate_population.rb +86 -0
- data/lib/karst/access/database_isolation.rb +62 -0
- data/lib/karst/access/population_approvals.rb +195 -0
- data/lib/karst/access/population_config_snippet.rb +67 -0
- data/lib/karst/access/population_discovery.rb +271 -0
- data/lib/karst/access/population_preview.rb +83 -0
- data/lib/karst/access/principal_sampler.rb +241 -0
- data/lib/karst/access/principal_selection.rb +90 -0
- data/lib/karst/access/principal_source.rb +143 -0
- data/lib/karst/access/principal_source_selection.rb +161 -0
- data/lib/karst/access/probe_application.rb +164 -0
- data/lib/karst/access/resource_evidence.rb +233 -0
- data/lib/karst/access/search.rb +265 -0
- data/lib/karst/access/selected_principal_sources.rb +65 -0
- data/lib/karst/access/sensitive_attribute_names.rb +26 -0
- data/lib/karst/access/sweep.rb +198 -0
- data/lib/karst/cli/verification.rb +182 -0
- data/lib/karst/configuration.rb +223 -0
- data/lib/karst/execution_context.rb +83 -0
- data/lib/karst/identity/devise_support.rb +90 -0
- data/lib/karst/identity/warden_adapter.rb +130 -0
- data/lib/karst/identity.rb +479 -0
- data/lib/karst/mcp/server.rb +63 -0
- data/lib/karst/mcp/verify_access_tool.rb +68 -0
- data/lib/karst/railtie.rb +30 -0
- data/lib/karst/spec/catalog.rb +199 -0
- data/lib/karst/spec/example_observation.rb +31 -0
- data/lib/karst/spec/observer.rb +300 -0
- data/lib/karst/spec/principal.rb +12 -0
- data/lib/karst/spec/reporter.rb +83 -0
- data/lib/karst/spec/request_observation.rb +38 -0
- data/lib/karst/spec/scenario.rb +65 -0
- data/lib/karst/value.rb +35 -0
- data/lib/karst/version.rb +5 -0
- data/lib/karst/web/badge.rb +183 -0
- data/lib/karst/web/browser_identity.rb +103 -0
- data/lib/karst/web/locality.rb +64 -0
- data/lib/karst/web/middleware.rb +377 -0
- data/lib/karst/web/panel.rb +699 -0
- data/lib/karst/web/populations_panel.rb +391 -0
- data/lib/karst/web/route_lookup.rb +65 -0
- data/lib/karst.rb +56 -0
- data/lib/rails/commands/karst/boot.rb +24 -0
- data/lib/rails/commands/karst/mcp/mcp_command.rb +26 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +39 -0
- data/lib/tasks/karst.rake +34 -0
- metadata +138 -0
|
@@ -0,0 +1,199 @@
|
|
|
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
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
|
@@ -0,0 +1,300 @@
|
|
|
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
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|