inferno_core 0.4.39 → 0.4.40

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c569fc01e07576cb3aa512c37a69d730bc7b9848bd24ed95935b23a0d52759c
4
- data.tar.gz: 41165f4f56dcf7c7e552ec0c391b868e5020feed0957f12d508489835a91d140
3
+ metadata.gz: 62cf688066c4ae8b8855f9ba04cd376c78ec6ae3238d74c4e34f4fdf3a926aef
4
+ data.tar.gz: 301885b06d03b494d74f7b2f053b741d0fc5d546371fc2eaf602f5b73e00462c
5
5
  SHA512:
6
- metadata.gz: 03cb4fec0a859ba0967d891b62d4741b6a241839bce017ac0a9f212c06cabbec45e3d8c9464d8a75421c697c6e547af19e5bdbe8055ab7ea630344a781dfdf5e
7
- data.tar.gz: ad2fc2e5f42777535e912e4bc8217f48d9d90babb074b071b7437fb3b987f4f8470f505f9e1bd29b1b893f7b6ccc36800145ad62fe82664188e0eab965b71cf5
6
+ metadata.gz: 6081f531d0f06172ed695265ac0c772182fc1481a97290441758d40e1013b08f1d684683e38e496a4d8fae4ac05210ec11c07ee7d2ca5e4513eda0cc5eae3f43
7
+ data.tar.gz: 5fe4104c0e129a061f81ebaffe2bb6f993a8424aabe1b0118aeae49f1eea29e72f1c1ac1120c8d480e54514547386e47faa04b750988af1aaa7b8499965ef275
@@ -53,6 +53,9 @@ module Inferno
53
53
  # Should not need Content-Type header but GitHub Codespaces will not work without them.
54
54
  # This could be investigated and likely removed if addressed properly elsewhere.
55
55
  get '/', to: ->(_env) { [200, { 'Content-Type' => 'text/html' }, [client_page]] }
56
+ get '/jwks.json', to: lambda { |_env|
57
+ [200, { 'Content-Type' => 'application/json' }, [Inferno::JWKS.jwks_json]]
58
+ }, as: :jwks
56
59
 
57
60
  Inferno.routes.each do |route|
58
61
  cleaned_id = route[:suite].id.gsub(/[^a-zA-Z\d\-._~]/, '_')
@@ -13,6 +13,7 @@ module Inferno
13
13
  raw_js_host = ENV.fetch('JS_HOST', '')
14
14
  base_path = ENV.fetch('BASE_PATH', '')
15
15
  public_path = base_path.blank? ? '/public' : "/#{base_path}/public"
16
+ jwks_path = base_path.blank? ? '/jwks.json' : "/#{base_path}/jwks.json"
16
17
  js_host = raw_js_host.present? ? "#{raw_js_host}/public" : public_path
17
18
 
18
19
  Application.register('js_host', js_host)
@@ -21,6 +22,7 @@ module Inferno
21
22
  Application.register('async_jobs', ENV['ASYNC_JOBS'] != 'false')
22
23
  Application.register('inferno_host', ENV.fetch('INFERNO_HOST', 'http://localhost:4567'))
23
24
  Application.register('base_url', URI.join(Application['inferno_host'], base_path).to_s)
25
+ Application.register('jwks_url', URI.join(Application['inferno_host'], jwks_path).to_s)
24
26
  Application.register('cache_bust_token', SecureRandom.uuid)
25
27
 
26
28
  configure do |config|
@@ -1,4 +1,5 @@
1
1
  require_relative '../entities/attributes'
2
+ require_relative 'jwks'
2
3
 
3
4
  module Inferno
4
5
  module DSL
@@ -19,7 +20,7 @@ module Inferno
19
20
  # to normal inputs.
20
21
  #
21
22
  # The AuthInfo input type supports two different modes in the UI. Different
22
- # fields will be presented to the user dependengi on which mode is selected.
23
+ # fields will be presented to the user depending on which mode is selected.
23
24
  # - `auth` - This presents the inputs needed to perform authorization, and
24
25
  # is appropriate to use as an input to test groups which perform
25
26
  # authorization
@@ -161,13 +162,148 @@ module Inferno
161
162
 
162
163
  # @private
163
164
  def add_to_client(client)
164
- # TODO
165
- # client.auth = self
166
- # self.client = client
165
+ client.auth_info = self
166
+ self.client = client
167
+ # TODO: do we want to perform authorization if no access_token or rely on SMART/ other auth tests?
168
+ return unless access_token.present?
167
169
 
168
- # return unless access_token.present?
170
+ client.set_bearer_token(access_token)
171
+ end
172
+
173
+ # @private
174
+ def need_to_refresh?
175
+ return false if access_token.blank? || (!backend_services? && refresh_token.blank?)
176
+
177
+ return true if expires_in.blank?
178
+
179
+ issue_time.to_i + expires_in.to_i - DateTime.now.to_i < 60
180
+ end
181
+
182
+ # @private
183
+ def able_to_refresh?
184
+ token_url.present? && (backend_services? || refresh_token.present?)
185
+ end
186
+
187
+ # @private
188
+ def backend_services?
189
+ auth_type == 'backend_services'
190
+ end
191
+
192
+ # @private
193
+ def oauth2_refresh_params
194
+ case auth_type
195
+ when 'public'
196
+ public_auth_refresh_params
197
+ when 'symmetric'
198
+ symmetric_auth_refresh_params
199
+ when 'asymmetric'
200
+ asymmetric_auth_refresh_params
201
+ when 'backend_services'
202
+ backend_services_auth_refresh_params
203
+ end
204
+ end
205
+
206
+ # @private
207
+ def symmetric_auth_refresh_params
208
+ {
209
+ 'grant_type' => 'refresh_token',
210
+ 'refresh_token' => refresh_token
211
+ }
212
+ end
213
+
214
+ # @private
215
+ def public_auth_refresh_params
216
+ symmetric_auth_refresh_params.merge('client_id' => client_id)
217
+ end
218
+
219
+ # @private
220
+ def asymmetric_auth_refresh_params
221
+ symmetric_auth_refresh_params.merge(
222
+ 'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
223
+ 'client_assertion' => client_assertion
224
+ )
225
+ end
226
+
227
+ # @private
228
+ def backend_services_auth_refresh_params
229
+ {
230
+ 'grant_type' => 'client_credentials',
231
+ 'scope' => requested_scopes,
232
+ 'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
233
+ 'client_assertion' => client_assertion
234
+ }
235
+ end
236
+
237
+ # @private
238
+ def oauth2_refresh_headers
239
+ base_headers = { 'Content-Type' => 'application/x-www-form-urlencoded' }
240
+
241
+ return base_headers unless auth_type == 'symmetric'
242
+
243
+ credentials = "#{client_id}:#{client_secret}"
244
+
245
+ base_headers.merge(
246
+ 'Authorization' => "Basic #{Base64.strict_encode64(credentials)}"
247
+ )
248
+ end
249
+
250
+ # @private
251
+ def private_key
252
+ @private_key ||= JWKS.jwks(user_jwks: jwks)
253
+ .select { |key| key[:key_ops]&.include?('sign') }
254
+ .select { |key| key[:alg] == encryption_algorithm }
255
+ .find { |key| !kid || key[:kid] == kid }
256
+ end
257
+
258
+ # @private
259
+ def signing_key
260
+ if private_key.nil?
261
+ raise Inferno::Exceptions::AssertionException,
262
+ "No signing key found for inputs: encryption method = '#{encryption_algorithm}' and kid = '#{kid}'"
263
+ end
264
+
265
+ @private_key.signing_key
266
+ end
267
+
268
+ # @private
269
+ def auth_jwt_header
270
+ {
271
+ 'alg' => encryption_algorithm,
272
+ 'kid' => private_key['kid'],
273
+ 'typ' => 'JWT',
274
+ 'jku' => Inferno::Application['jwks_url']
275
+ }
276
+ end
277
+
278
+ # @private
279
+ def auth_jwt_claims
280
+ {
281
+ 'iss' => client_id,
282
+ 'sub' => client_id,
283
+ 'aud' => token_url,
284
+ 'exp' => 5.minutes.from_now.to_i,
285
+ 'jti' => SecureRandom.hex(32)
286
+ }
287
+ end
288
+
289
+ # @private
290
+ def client_assertion
291
+ JWT.encode auth_jwt_claims, signing_key, encryption_algorithm, auth_jwt_header
292
+ end
293
+
294
+ # @private
295
+ def update_from_response_body(request)
296
+ token_response_body = JSON.parse(request.response_body)
297
+
298
+ expires_in = token_response_body['expires_in'].is_a?(Numeric) ? token_response_body['expires_in'] : nil
299
+
300
+ self.access_token = token_response_body['access_token']
301
+ self.refresh_token = token_response_body['refresh_token'] if token_response_body['refresh_token'].present?
302
+ self.expires_in = expires_in
303
+ self.issue_time = DateTime.now
169
304
 
170
- # client.set_bearer_token(access_token)
305
+ add_to_client(client)
306
+ self
171
307
  end
172
308
  end
173
309
  end
@@ -347,7 +347,7 @@ module Inferno
347
347
 
348
348
  # @private
349
349
  def perform_refresh(client)
350
- credentials = client.oauth_credentials
350
+ credentials = client.auth_info || client.oauth_credentials
351
351
 
352
352
  post(
353
353
  credentials.token_url,
@@ -363,7 +363,7 @@ module Inferno
363
363
  Inferno::Repositories::SessionData.new.save(
364
364
  name: credentials.name,
365
365
  value: credentials,
366
- type: 'oauth_credentials',
366
+ type: credentials.is_a?(Inferno::DSL::AuthInfo) ? 'auth_info' : 'oauth_credentials',
367
367
  test_session_id:
368
368
  )
369
369
  end
@@ -20,6 +20,20 @@ module Inferno
20
20
  # url :url
21
21
  # bearer_token :access_token
22
22
  # end
23
+ #
24
+ # @example
25
+ # input :url
26
+ # input :fhir_auth,
27
+ # type: :auth_info,
28
+ # options: {
29
+ # mode: 'access'
30
+ # }
31
+ #
32
+ # fhir_client do
33
+ # url :url
34
+ # headers 'My-Custom_header' => 'CUSTOM_HEADER_VALUE'
35
+ # auth_info :fhir_auth
36
+ # end
23
37
  class FHIRClientBuilder
24
38
  attr_accessor :runnable
25
39
 
@@ -33,6 +47,7 @@ module Inferno
33
47
  client.default_json
34
48
  client.set_bearer_token bearer_token if bearer_token
35
49
  oauth_credentials&.add_to_client(client)
50
+ auth_info&.add_to_client(client)
36
51
  end
37
52
  end
38
53
 
@@ -80,6 +95,20 @@ module Inferno
80
95
  end
81
96
  end
82
97
 
98
+ # Define auth info for a client. Auth info contains info needed for client
99
+ # to perform authorization and refresh access token when necessary
100
+ #
101
+ # @param auth_info [Inferno::DSL::AuthInfo, Symbol]
102
+ # @return [void]
103
+ def auth_info(auth_info = nil)
104
+ @auth_info ||=
105
+ if auth_info.is_a? Symbol
106
+ runnable.send(auth_info)
107
+ else
108
+ auth_info
109
+ end
110
+ end
111
+
83
112
  # Define custom headers for a client
84
113
  #
85
114
  # @param headers [Hash]
@@ -277,32 +277,36 @@ module Inferno
277
277
  end
278
278
 
279
279
  # @private
280
- def operation_outcome_from_hl7_wrapped_response(response)
281
- res = JSON.parse(response)
282
- if res['sessionId'] != @session_id
283
- validator_session_repo.save(test_suite_id:, validator_session_id: res['sessionId'],
280
+ def operation_outcome_from_hl7_wrapped_response(response_hash)
281
+ if response_hash['sessionId'] && response_hash['sessionId'] != @session_id
282
+ validator_session_repo.save(test_suite_id:, validator_session_id: response_hash['sessionId'],
284
283
  validator_name: name.to_s, suite_options: requirements)
285
- @session_id = res['sessionId']
284
+ @session_id = response_hash['sessionId']
286
285
  end
287
286
 
288
287
  # assume for now that one resource -> one request
289
- issues = res['outcomes'][0]['issues']&.map do |i|
288
+ issues = response_hash['outcomes'][0]['issues']&.map do |i|
290
289
  { severity: i['level'].downcase, expression: i['location'], details: { text: i['message'] } }
291
290
  end
292
291
  # this is circuitous, ideally we would map this response directly to message_hashes
293
292
  FHIR::OperationOutcome.new(issue: issues)
294
293
  end
295
294
 
295
+ # @private
296
+ def remove_invalid_characters(string)
297
+ string.gsub(/[^[:print:]\r\n]+/, '')
298
+ end
299
+
296
300
  # @private
297
301
  def operation_outcome_from_validator_response(response, runnable)
298
- if response.body.start_with? '{'
299
- operation_outcome_from_hl7_wrapped_response(response.body)
300
- else
301
- runnable.add_message('error', "Validator Response: HTTP #{response.status}\n#{response.body}")
302
- raise Inferno::Exceptions::ErrorInValidatorException,
303
- 'Validator response was an unexpected format. ' \
304
- 'Review Messages tab or validator service logs for more information.'
305
- end
302
+ sanitized_body = remove_invalid_characters(response.body)
303
+
304
+ operation_outcome_from_hl7_wrapped_response(JSON.parse(sanitized_body))
305
+ rescue JSON::ParserError
306
+ runnable.add_message('error', "Validator Response: HTTP #{response.status}\n#{sanitized_body}")
307
+ raise Inferno::Exceptions::ErrorInValidatorException,
308
+ 'Validator response was an unexpected format. ' \
309
+ 'Review Messages tab or validator service logs for more information.'
306
310
  end
307
311
  end
308
312
 
@@ -124,7 +124,7 @@ module Inferno
124
124
  runnable.add_message('error', e.message)
125
125
  raise Inferno::Exceptions::ErrorInValidatorException, "Unable to connect to validator at #{url}."
126
126
  end
127
- outcome = operation_outcome_from_validator_response(response.body, runnable)
127
+ outcome = operation_outcome_from_validator_response(response, runnable)
128
128
 
129
129
  message_hashes = message_hashes_from_outcome(outcome, resource, profile_url)
130
130
 
@@ -212,16 +212,21 @@ module Inferno
212
212
  ).post('validate', resource.source_contents)
213
213
  end
214
214
 
215
+ # @private
216
+ def remove_invalid_characters(string)
217
+ string.gsub(/[^[:print:]\r\n]+/, '')
218
+ end
219
+
215
220
  # @private
216
221
  def operation_outcome_from_validator_response(response, runnable)
217
- if response.start_with? '{'
218
- FHIR::OperationOutcome.new(JSON.parse(response))
219
- else
220
- runnable.add_message('error', "Validator Response:\n#{response}")
221
- raise Inferno::Exceptions::ErrorInValidatorException,
222
- 'Validator response was an unexpected format. '\
223
- 'Review Messages tab or validator service logs for more information.'
224
- end
222
+ sanitized_body = remove_invalid_characters(response.body)
223
+
224
+ FHIR::OperationOutcome.new(JSON.parse(sanitized_body))
225
+ rescue JSON::ParserError
226
+ runnable.add_message('error', "Validator Response: HTTP #{response.status}\n#{sanitized_body}")
227
+ raise Inferno::Exceptions::ErrorInValidatorException,
228
+ 'Validator response was an unexpected format. ' \
229
+ 'Review Messages tab or validator service logs for more information.'
225
230
  end
226
231
  end
227
232
 
@@ -0,0 +1,121 @@
1
+ module Inferno
2
+ module DSL
3
+ # This module contains the methods needed to perform FHIRPath evaluations
4
+ # on FHIR resources/elements. The actual evaluation is typically performed by an external
5
+ # FHIRPath evaluation service.
6
+ #
7
+ # Tests can leverage the evaluation functionality by calling `evaluate_fhirpath` to retrieve
8
+ # results of FHIRPath expressions.
9
+ #
10
+ # @example
11
+ #
12
+ # results = evaluate_fhirpath(resource: patient_resource, path: 'Patient.name.given')
13
+ #
14
+ # results will be an array representing the result of evaluating the given
15
+ # expression against the given root element. Each "result" in the returned
16
+ # array will be in the form
17
+ # `{ "type": "[FHIR datatype of the result]", "element": "[result value of the FHIRPath expression]" }`.
18
+ # @note the `element` field can either be a primitive value (string, boolean, etc.) or a FHIR::Model.
19
+ module FhirpathEvaluation
20
+ def self.included(klass)
21
+ klass.extend ClassMethods
22
+ end
23
+
24
+ # Evaluates a fhirpath expression for a given FHIR resource
25
+ #
26
+ # @param resource [FHIR::Model] the root FHIR resource to use when evaluating the fhirpath expression.
27
+ # @param path [String] The FHIRPath expression to evaluate.
28
+ # @param url [String] the url of the fhirpath service to use.
29
+ # @return [Array<Hash>] An array of hashes representing the result of evaluating the given expression against
30
+ # the given root resource.
31
+ def evaluate_fhirpath(resource:, path:, url: nil)
32
+ self.class.evaluator(url).evaluate_fhirpath(resource, path, self)
33
+ end
34
+
35
+ class Evaluator
36
+ # @private
37
+ def initialize(url = nil)
38
+ url(url)
39
+ end
40
+
41
+ # @private
42
+ def default_fhirpath_url
43
+ ENV.fetch('FHIRPATH_URL')
44
+ end
45
+
46
+ # Set/Get the url of the fhirpath service
47
+ #
48
+ # @param fhirpath_url [String]
49
+ # @return [String]
50
+ def url(fhirpath_url = nil)
51
+ @url ||= fhirpath_url || default_fhirpath_url
52
+ end
53
+
54
+ # Evaluates a fhirpath expression for a given FHIR resource
55
+ #
56
+ # @param fhir_resource [FHIR::Model] the root FHIR resource to use when evaluating the fhirpath expression.
57
+ # @param fhirpath_expression [String] The FHIRPath expression to evaluate.
58
+ # @param runnable [Inferno::Test] to add any error message that occurs.
59
+ # @return [Array<Hash>] An array hashes representing the result of evaluating the given expression against
60
+ # the given root resource. Each "result" in the returned array will be in the form
61
+ # `{ "type": "[FHIR datatype of the result]", "element": "[result value of the FHIRPath expression]" }`.
62
+ # @note the `element` field can either be a primitive value (string, boolean, etc.) or a FHIR::Model.
63
+ def evaluate_fhirpath(fhir_resource, fhirpath_expression, runnable)
64
+ begin
65
+ response = call_fhirpath_service(fhir_resource, fhirpath_expression)
66
+ rescue StandardError => e
67
+ # This could be a complete failure to connect (fhirpath service isn't running)
68
+ # or a timeout (fhirpath service took too long to respond).
69
+ runnable.add_message('error', e.message)
70
+ raise Inferno::Exceptions::ErrorInFhirpathException, "Unable to connect to FHIRPath service at #{url}."
71
+ end
72
+
73
+ sanitized_body = remove_invalid_characters(response.body)
74
+ return transform_fhirpath_results(JSON.parse(sanitized_body)) if response.status.to_s.start_with? '2'
75
+
76
+ runnable.add_message('error', "FHIRPath service Response: HTTP #{response.status}\n#{sanitized_body}")
77
+ raise Inferno::Exceptions::ErrorInFhirpathException,
78
+ 'FHIRPath service call failed. Review Messages tab for more information.'
79
+ rescue JSON::ParserError
80
+ runnable.add_message('error', "Invalid FHIRPath service response format:\n#{sanitized_body}")
81
+ raise Inferno::Exceptions::ErrorInFhirpathException,
82
+ 'Error occurred in the FHIRPath service. Review Messages tab for more information.'
83
+ end
84
+
85
+ # @private
86
+ def transform_fhirpath_results(fhirpath_results)
87
+ fhirpath_results.each do |result|
88
+ klass = FHIR.const_get(result['type'])
89
+ result['element'] = klass.new(result['element'])
90
+ rescue NameError
91
+ next
92
+ end
93
+ fhirpath_results
94
+ end
95
+
96
+ def call_fhirpath_service(fhir_resource, fhirpath_expression)
97
+ Faraday.new(
98
+ url,
99
+ request: { timeout: 600 }
100
+ ).post(
101
+ "evaluate?path=#{fhirpath_expression}",
102
+ fhir_resource.to_json,
103
+ content_type: 'application/json'
104
+ )
105
+ end
106
+
107
+ # @private
108
+ def remove_invalid_characters(string)
109
+ string.gsub(/[^[:print:]\r\n]+/, '')
110
+ end
111
+ end
112
+
113
+ module ClassMethods
114
+ # @private
115
+ def evaluator(url = nil)
116
+ @evaluator ||= Inferno::DSL::FhirpathEvaluation::Evaluator.new(url)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,79 @@
1
+ module Inferno
2
+ module DSL
3
+ # The JWKS class provides methods to handle JSON Web Key Sets (JWKS)
4
+ # within Inferno.
5
+ #
6
+ # This class allows users to fetch, parse, and manage JWKS, ensuring
7
+ # that the necessary keys for verifying tokens are available.
8
+ class JWKS
9
+ class << self
10
+ # Returns a formatted JSON string of the JWKS public keys that are used for verification.
11
+ # This method filters out keys that do not have the 'verify' operation.
12
+ #
13
+ # @return [String] The formatted JSON string of the JWKS public keys.
14
+ #
15
+ # @example
16
+ # jwks_json = Inferno::JWKS.jwks_json
17
+ # puts jwks_json
18
+ def jwks_json
19
+ @jwks_json ||=
20
+ JSON.pretty_generate(
21
+ { keys: jwks.export[:keys].select { |key| key[:key_ops]&.include?('verify') } }
22
+ )
23
+ end
24
+
25
+ # Provides the default file path to the JWKS file.
26
+ # This method is primarily used internally to locate the default JWKS file.
27
+ #
28
+ # @return [String] The default JWKS file path.
29
+ #
30
+ # @private
31
+ def default_jwks_path
32
+ @default_jwks_path ||= File.join(__dir__, 'jwks.json')
33
+ end
34
+
35
+ # Fetches the JWKS file path from the environment variable `INFERNO_JWKS_PATH`.
36
+ # If the environment variable is not set, it falls back to the default path
37
+ # provided by `.default_jwks_path`.
38
+ #
39
+ # @return [String] The JWKS file path.
40
+ #
41
+ # @private
42
+ def jwks_path
43
+ @jwks_path ||=
44
+ ENV.fetch('INFERNO_JWKS_PATH', default_jwks_path)
45
+ end
46
+
47
+ # Reads the JWKS content from the file located at the JWKS path.
48
+ #
49
+ # @return [String] The json content of the JWKS file.
50
+ #
51
+ # @private
52
+ def default_jwks_json
53
+ @default_jwks_json ||= File.read(jwks_path)
54
+ end
55
+
56
+ # Parses and returns a `JWT::JWK::Set` object from the provided JWKS string
57
+ # or from the file located at the JWKS path. If a user-provided JWKS string
58
+ # is not available, it reads the JWKS from the file.
59
+ #
60
+ # @param user_jwks [String, nil] An optional json containing the JWKS.
61
+ # If not provided, the method reads from the file.
62
+ # @return [JWT::JWK::Set] The parsed JWKS set.
63
+ #
64
+ # @example
65
+ # # Using a user-provided JWKS string
66
+ # user_jwks = '{"keys":[...]}'
67
+ # jwks_set = Inferno::JWKS.jwks(user_jwks: user_jwks)
68
+ #
69
+ # # Using the default JWKS file
70
+ # jwks_set = Inferno::JWKS.jwks
71
+ def jwks(user_jwks: nil)
72
+ JWT::JWK::Set.new(JSON.parse(user_jwks.presence || default_jwks_json))
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ JWKS = DSL::JWKS
79
+ end
@@ -0,0 +1,71 @@
1
+ require_relative '../utils/markdown_formatter'
2
+
3
+ module Inferno
4
+ module DSL
5
+ # This module contains methods to add meessages to runnable results
6
+ module Messages
7
+ include Inferno::Utils::MarkdownFormatter
8
+ # @private
9
+ def messages
10
+ @messages ||= []
11
+ end
12
+
13
+ # Add a message to the result.
14
+ #
15
+ # @param type [String] error, warning, or info
16
+ # @param message [String]
17
+ # @return [void]
18
+ def add_message(type, message)
19
+ messages << { type: type.to_s, message: format_markdown(message) }
20
+ end
21
+
22
+ # Add an informational message to the results of a test. If passed a
23
+ # block, a failed assertion will become an info message and test execution
24
+ # will continue.
25
+ #
26
+ # @param message [String]
27
+ # @return [void]
28
+ # @example
29
+ # # Add an info message
30
+ # info 'This message will be added to the test results'
31
+ #
32
+ # # The message for the failed assertion will be treated as an info
33
+ # # message. Test exection will continue.
34
+ # info { assert false == true }
35
+ def info(message = nil)
36
+ unless block_given?
37
+ add_message('info', message) unless message.nil?
38
+ return
39
+ end
40
+
41
+ yield
42
+ rescue Exceptions::AssertionException => e
43
+ add_message('info', e.message)
44
+ end
45
+
46
+ # Add a warning message to the results of a test. If passed a block, a
47
+ # failed assertion will become a warning message and test execution will
48
+ # continue.
49
+ #
50
+ # @param message [String]
51
+ # @return [void]
52
+ # @example
53
+ # # Add a warning message
54
+ # warning 'This message will be added to the test results'
55
+ #
56
+ # # The message for the failed assertion will be treated as a warning
57
+ # # message. Test exection will continue.
58
+ # warning { assert false == true }
59
+ def warning(message = nil)
60
+ unless block_given?
61
+ add_message('warning', message) unless message.nil?
62
+ return
63
+ end
64
+
65
+ yield
66
+ rescue Exceptions::AssertionException => e
67
+ add_message('warning', e.message)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -291,6 +291,18 @@ module Inferno
291
291
  to_s
292
292
  end
293
293
 
294
+ # Set/Get the block that is executed when a runnable is run
295
+ #
296
+ # @param block [Proc]
297
+ # @return [Proc] the block that is executed when a runnable is run
298
+ def block(&block)
299
+ return @block unless block_given?
300
+
301
+ @block = block
302
+ end
303
+
304
+ alias run block
305
+
294
306
  # @private
295
307
  def all_children
296
308
  @all_children ||= []