inferno_core 0.4.39 → 0.4.40
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/lib/inferno/apps/web/router.rb +3 -0
- data/lib/inferno/config/application.rb +2 -0
- data/lib/inferno/dsl/auth_info.rb +142 -6
- data/lib/inferno/dsl/fhir_client.rb +2 -2
- data/lib/inferno/dsl/fhir_client_builder.rb +29 -0
- data/lib/inferno/dsl/fhir_resource_validation.rb +18 -14
- data/lib/inferno/dsl/fhir_validation.rb +14 -9
- data/lib/inferno/dsl/fhirpath_evaluation.rb +121 -0
- data/lib/inferno/dsl/jwks.rb +79 -0
- data/lib/inferno/dsl/messages.rb +71 -0
- data/lib/inferno/dsl/runnable.rb +12 -0
- data/lib/inferno/dsl.rb +5 -1
- data/lib/inferno/entities/test.rb +0 -76
- data/lib/inferno/entities/test_group.rb +13 -1
- data/lib/inferno/entities/test_suite.rb +15 -0
- data/lib/inferno/exceptions.rb +19 -0
- data/lib/inferno/ext/fhir_client.rb +3 -3
- data/lib/inferno/jobs/invoke_validator_session.rb +9 -10
- data/lib/inferno/public/bundle.js +18 -18
- data/lib/inferno/result_collection.rb +72 -0
- data/lib/inferno/result_summarizer.rb +9 -10
- data/lib/inferno/test_runner.rb +55 -27
- data/lib/inferno/version.rb +1 -1
- data/spec/fixtures/auth_info_constants.rb +71 -0
- metadata +7 -2
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: 62cf688066c4ae8b8855f9ba04cd376c78ec6ae3238d74c4e34f4fdf3a926aef
         | 
| 4 | 
            +
              data.tar.gz: 301885b06d03b494d74f7b2f053b741d0fc5d546371fc2eaf602f5b73e00462c
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 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  | 
| 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 | 
            -
                     | 
| 165 | 
            -
                     | 
| 166 | 
            -
                    #  | 
| 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 | 
            -
                     | 
| 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 | 
            -
                     | 
| 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( | 
| 281 | 
            -
                       | 
| 282 | 
            -
             | 
| 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 =  | 
| 284 | 
            +
                        @session_id = response_hash['sessionId']
         | 
| 286 285 | 
             
                      end
         | 
| 287 286 |  | 
| 288 287 | 
             
                      # assume for now that one resource -> one request
         | 
| 289 | 
            -
                      issues =  | 
| 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 | 
            -
                       | 
| 299 | 
            -
             | 
| 300 | 
            -
                       | 
| 301 | 
            -
             | 
| 302 | 
            -
             | 
| 303 | 
            -
             | 
| 304 | 
            -
             | 
| 305 | 
            -
             | 
| 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 | 
| 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 | 
            -
                       | 
| 218 | 
            -
             | 
| 219 | 
            -
                       | 
| 220 | 
            -
             | 
| 221 | 
            -
             | 
| 222 | 
            -
             | 
| 223 | 
            -
             | 
| 224 | 
            -
             | 
| 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
         | 
    
        data/lib/inferno/dsl/runnable.rb
    CHANGED
    
    | @@ -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 ||= []
         |