diff --git a/.travis.yml b/.travis.yml index e9432c0bb..09f39ae1e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,8 +3,6 @@ cache: bundler services: - docker before_install: - - gem update --system - - gem install bundler - docker-compose build before_script: - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter diff --git a/Gemfile b/Gemfile index 704d2e0a1..435658736 100644 --- a/Gemfile +++ b/Gemfile @@ -10,7 +10,7 @@ gem 'colorize' gem 'data_mapper' gem 'dm-sqlite-adapter' gem 'fhir_client' -gem 'json-jwt' +gem 'jwt' gem 'kramdown' gem 'parser' gem 'pry' @@ -18,6 +18,7 @@ gem 'pry-byebug' gem 'rake' gem 'rb-readline' gem 'rest-client' +gem 'rubyXL' gem 'selenium-webdriver' gem 'sinatra' gem 'sinatra-contrib' diff --git a/Gemfile.lock b/Gemfile.lock index 1af06dc04..32b3e9399 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,7 +8,6 @@ GEM tzinfo (~> 1.1) addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) - aes_key_wrap (1.0.1) ast (2.4.0) backports (3.15.0) base62-rb (0.3.1) @@ -17,7 +16,6 @@ GEM bcrypt (3.1.13) bcrypt-ruby (3.1.5) bcrypt (>= 3.1.3) - bindata (2.4.4) bitarray (1.2.0) bloomer (1.0.0) bitarray @@ -121,10 +119,6 @@ GEM concurrent-ruby (~> 1.0) jaro_winkler (1.5.3) json (1.8.6) - json-jwt (1.11.0) - activesupport (>= 4.2) - aes_key_wrap - bindata json_pure (1.8.6) jwt (2.2.1) kramdown (2.1.0) @@ -158,7 +152,7 @@ GEM byebug (~> 11.0) pry (~> 0.10) public_suffix (4.0.1) - rack (2.0.7) + rack (2.0.8) rack-protection (2.0.7) rack rack-test (1.1.0) @@ -179,6 +173,9 @@ GEM ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 1.7) ruby-progressbar (1.10.1) + rubyXL (3.4.9) + nokogiri (>= 1.4.4) + rubyzip (>= 1.3.0) rubyzip (1.3.0) safe_yaml (1.0.5) selenium-webdriver (3.142.4) @@ -235,7 +232,7 @@ DEPENDENCIES data_mapper dm-sqlite-adapter fhir_client - json-jwt + jwt kramdown minitest parser @@ -246,6 +243,7 @@ DEPENDENCIES rb-readline rest-client rubocop + rubyXL selenium-webdriver simplecov sinatra diff --git a/config.yml b/config.yml index d53c75156..eb4f3e803 100644 --- a/config.yml +++ b/config.yml @@ -37,6 +37,10 @@ include_extras: true badge_text: Community +# Resource validator options: must be one of "internal" or "external". external_resource_validator_url is only used if resource_validator is set to external. +resource_validator: internal +external_resource_validator_url: http://validator_service:4567 + # module options: one or more must be set. The first option in the list will be checked by default modules: - onc @@ -44,7 +48,6 @@ modules: - bdt - argonaut - uscore_v3.1.0 - - bulk_data # preset fhir servers: optional. Minimally requires name, uri, module, optional inferno_uri, client_id, client_secret, scopes, instructions link presets: diff --git a/docker-compose.yml b/docker-compose.yml index 322cf7924..00dbd2662 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: - bdt_service bdt_service: image: infernocommunity/inferno-bdt-service + validator_service: + image: infernocommunity/fhir-validator-wrapper nginx_server: image: nginx volumes: diff --git a/generator/uscore/metadata_extractor.rb b/generator/uscore/metadata_extractor.rb index 320c08811..77fe88e9c 100644 --- a/generator/uscore/metadata_extractor.rb +++ b/generator/uscore/metadata_extractor.rb @@ -5,10 +5,6 @@ module Generator module USCoreMetadataExtractor PROFILE_URIS = Inferno::ValidationUtil::US_CORE_R4_URIS - def profile_uri(profile) - "http://hl7.org/fhir/us/core/StructureDefinition/#{profile}" - end - def search_param_path(resource, param) param = 'id' if param == '_id' "SearchParameter/us-core-#{resource.downcase}-#{param}" @@ -27,6 +23,7 @@ def extract_metadata capability_statement_json = capability_statement('server') add_metadata_from_ig(metadata, ig_resource) add_metadata_from_resources(metadata, capability_statement_json['rest'][0]['resource']) + fix_metadata_errors(metadata) add_special_cases(metadata) end @@ -53,8 +50,16 @@ def generate_unique_test_id_prefix(title) test_id_prefix end + def get_base_path(profile) + if profile.include? 'us/core/' + profile.split('us/core/').last + else + profile.split('fhir/').last + end + end + def build_new_sequence(resource, profile) - base_path = profile.split('us/core/').last + base_path = get_base_path(profile) base_name = profile.split('StructureDefinition/').last profile_json = @resource_by_path[base_path] reformatted_version = ig_resource['version'].delete('.') @@ -64,15 +69,15 @@ def build_new_sequence(resource, profile) # In case the profile doesn't start with US Core class_name = "USCore#{reformatted_version}#{class_name}" unless class_name.start_with? 'USCore' - { name: base_name.tr('-', '_'), class_name: class_name, test_id_prefix: test_id_prefix, resource: resource['type'], - profile: profile_uri(base_name), # link in capability statement is incorrect, + profile: profile, title: profile_title, interactions: [], + operations: [], searches: [], search_param_descriptions: {}, element_descriptions: {}, @@ -94,10 +99,11 @@ def add_metadata_from_resources(metadata, resources) add_basic_searches(resource, new_sequence) add_combo_searches(resource, new_sequence) add_interactions(resource, new_sequence) + add_operations(resource, new_sequence) add_include_search(resource, new_sequence) add_revinclude_targets(resource, new_sequence) - base_path = new_sequence[:profile].split('us/core/').last + base_path = get_base_path(supported_profile) profile_definition = @resource_by_path[base_path] add_must_support_elements(profile_definition, new_sequence) add_search_param_descriptions(profile_definition, new_sequence) @@ -152,6 +158,17 @@ def add_interactions(resource, sequence) end end + def add_operations(resource, sequence) + operations = resource['operation'] + operations&.each do |operation| + new_operation = { + operation: operation['name'], + expectation: operation['extension'][0]['valueCode'] + } + sequence[:operations] << new_operation + end + end + def add_include_search(resource, sequence) sequence[:include_params] = resource['searchInclude'] || [] end @@ -180,7 +197,7 @@ def add_must_support_elements(profile_definition, sequence) sequence[:must_supports] << { type: 'element', - path: path.gsub('[x]', type['code'].slice(0).capitalize + type['code'].slice(1..-1)) + path: path.gsub('[x]', capitalize_first_letter(type['code'])) } end else @@ -196,13 +213,10 @@ def add_must_support_elements(profile_definition, sequence) def add_search_param_descriptions(profile_definition, sequence) sequence[:search_param_descriptions].each_key do |param| search_param_definition = @resource_by_path[search_param_path(sequence[:resource], param.to_s)] - path_parts = search_param_definition['xpath'].split('/f:') - if param.to_s != '_id' - path_parts[0] = sequence[:resource] - path = path_parts.join('.') - else - path = path_parts[0] - end + path = search_param_definition['expression'] + path = path.gsub(/.where\((.*)/, '') + as_type = path.scan(/.as\((.*?)\)/).flatten.first + path = path.gsub(/.as\((.*?)\)/, capitalize_first_letter(as_type)) if as_type.present? profile_element = profile_definition['snapshot']['element'].select { |el| el['id'] == path }.first param_metadata = { path: path, @@ -224,6 +238,8 @@ def add_search_param_descriptions(profile_definition, sequence) expectation = expectation_extension[index]['extension'].first['valueCode'] unless expectation_extension.nil? param_metadata[:comparators][comparator.to_sym] = expectation end + multiple_or_expectation = search_param_definition['_multipleOr']['extension'].first['valueCode'] + param_metadata[:multiple_or] = multiple_or_expectation sequence[:search_param_descriptions][param] = param_metadata end end @@ -285,6 +301,24 @@ def add_element_definitions(profile_definition, sequence) end end + def fix_metadata_errors(metadata) + # Procedure's date search param definition says Procedure.occurenceDateTime even though Procedure doesn't have an occurenceDateTime + procedure_sequence = metadata[:sequences].find { |sequence| sequence[:resource] == 'Procedure' } + procedure_sequence[:search_param_descriptions][:date][:path] = 'Procedure.performed' + + goal_sequence = metadata[:sequences].find { |sequence| sequence[:resource] == 'Goal' } + goal_sequence[:search_param_descriptions][:'target-date'][:path] = 'Goal.target.dueDate' + goal_sequence[:search_param_descriptions][:'target-date'][:type] = 'date' + + # add the ge comparator - the metadata is missing it for some reason + metadata[:sequences].each do |sequence| + sequence[:search_param_descriptions].each do |_param, description| + param_comparators = description[:comparators] + param_comparators[:ge] = param_comparators[:le] if param_comparators.key? :le + end + end + end + def add_special_cases(metadata) category_first_profiles = [ PROFILE_URIS[:lab_results] @@ -320,6 +354,10 @@ def set_first_search(sequence, params) sequence[:searches].delete(search) sequence[:searches].unshift(search) end + + def capitalize_first_letter(str) + str.slice(0).capitalize + str.slice(1..-1) + end end end end diff --git a/generator/uscore/templates/module.yml.erb b/generator/uscore/templates/module.yml.erb index 3fce5e556..dbacf83dc 100644 --- a/generator/uscore/templates/module.yml.erb +++ b/generator/uscore/templates/module.yml.erb @@ -7,21 +7,18 @@ test_sets: ad_hoc_testing: view: default tests: - - name: Discovery + - name: SMART App Launch sequences: - - UsCoreR4CapabilityStatementSequence - SMARTDiscoverySequence - run_all: true - - name: Authorization and Authentication - sequences: - DynamicRegistrationSequence - ManualRegistrationSequence - StandaloneLaunchSequence - EHRLaunchSequence - - name: US Core R4 Patient Based Profiles + - name: US Core v3.1.0 Profiles run_all: true - sequences:<% non_delayed_sequences.each do |sequence| %> + sequences: + - UsCoreR4CapabilityStatementSequence + - USCore310PatientSequence<% non_delayed_sequences.each do |sequence| %> - <%=sequence[:class_name]%><% end %> - - R4ProvenanceSequence - USCoreR4ClinicalNotesSequence<% delayed_sequences.each do |sequence| %> - <%=sequence[:class_name]%><% end %> diff --git a/generator/uscore/templates/unit_tests/authorization_unit_test.rb.erb b/generator/uscore/templates/unit_tests/authorization_unit_test.rb.erb index f7535d032..c85f334d4 100644 --- a/generator/uscore/templates/unit_tests/authorization_unit_test.rb.erb +++ b/generator/uscore/templates/unit_tests/authorization_unit_test.rb.erb @@ -15,6 +15,10 @@ describe 'unauthorized search test' do it 'skips if the <%= resource_type %> search interaction is not supported' do @instance.server_capabilities.destroy + Inferno::Models::ServerCapabilities.create( + testing_instance_id: @instance.id, + capabilities: FHIR::CapabilityStatement.new.to_json + ) @instance.reload exception = assert_raises(Inferno::SkipException) { @sequence.run_test(@test) } diff --git a/generator/uscore/templates/unit_tests/resource_read_unit_test.rb.erb b/generator/uscore/templates/unit_tests/resource_read_unit_test.rb.erb index 69453d056..47b86dada 100644 --- a/generator/uscore/templates/unit_tests/resource_read_unit_test.rb.erb +++ b/generator/uscore/templates/unit_tests/resource_read_unit_test.rb.erb @@ -10,6 +10,10 @@ describe '<%= resource_type %> read test' do it 'skips if the <%= resource_type %> read interaction is not supported' do @instance.server_capabilities.destroy + Inferno::Models::ServerCapabilities.create( + testing_instance_id: @instance.id, + capabilities: FHIR::CapabilityStatement.new.to_json + ) @instance.reload exception = assert_raises(Inferno::SkipException) { @sequence.run_test(@test) } diff --git a/generator/uscore/templates/unit_tests/search_unit_test.rb.erb b/generator/uscore/templates/unit_tests/search_unit_test.rb.erb new file mode 100644 index 000000000..e90691fee --- /dev/null +++ b/generator/uscore/templates/unit_tests/search_unit_test.rb.erb @@ -0,0 +1,154 @@ +describe '<%= resource_type %> search by <%= search_params.keys.join('+') %> test' do + before do + @test = @sequence_class[:<%= test_key %>] + @sequence = @sequence_class.new(@instance, @client) + @<%= resource_var_name %> = FHIR.from_contents(load_fixture(:<%= sequence_name %>)) + @<%= resource_var_name %>_ary = [@<%= resource_var_name %>] + @sequence.instance_variable_set(:'@<%= resource_var_name %>', @<%= resource_var_name %>) + @sequence.instance_variable_set(:'@<%= resource_var_name %>_ary', @<%= resource_var_name %>_ary) +<% unless is_first_search %> + @sequence.instance_variable_set(:'@resources_found', true) +<% end %> + @query = { + <%= search_param_string %> + } + end + +<% unless is_first_search %> + it 'skips if no <%= resource_type %> resources have been found' do + @sequence.instance_variable_set(:'@resources_found', false) + + exception = assert_raises(Inferno::SkipException) { @sequence.run_test(@test) } + + assert_equal 'No <%= resource_type %> resources appear to be available.<%=' Please use patients with more information.' unless delayed_sequence%>', exception.message + end + + <% if has_dynamic_search_params %> + it 'skips if a value for one of the search parameters cannot be found' do + @sequence.instance_variable_set(:'@<%= resource_var_name %>_ary', [FHIR::<%= resource_type %>.new]) + + exception = assert_raises(Inferno::SkipException) { @sequence.run_test(@test) } + + assert_match(/Could not resolve [\w-]+ in given resource/, exception.message) + end + <% end %> +<% end %> + + it 'fails if a non-success response code is received' do +<% if is_first_search && is_fixed_value_search %> + [<%= fixed_value_search_string %>].each do |value| + query_params = { + 'patient': @instance.patient_id, + '<%= fixed_value_search_param %>': value + } + stub_request(:get, "#{@base_url}/<%= resource_type %>") + .with(query: query_params, headers: @auth_header) + .to_return(status: 401) + end +<% else %> + stub_request(:get, "#{@base_url}/<%= resource_type %>") + .with(query: @query, headers: @auth_header) + .to_return(status: 401) +<% end %> + + exception = assert_raises(Inferno::AssertionException) { @sequence.run_test(@test) } + + assert_equal 'Bad response code: expected 200, 201, but found 401. ', exception.message + end + + it 'fails if a Bundle is not received' do +<% if is_first_search && is_fixed_value_search %> + [<%= fixed_value_search_string %>].each do |value| + query_params = { + 'patient': @instance.patient_id, + '<%= fixed_value_search_param %>': value + } + stub_request(:get, "#{@base_url}/<%= resource_type %>") + .with(query: query_params, headers: @auth_header) + .to_return(status: 200, body: FHIR::<%= resource_type %>.new.to_json) + end +<% else %> + stub_request(:get, "#{@base_url}/<%= resource_type %>") + .with(query: @query, headers: @auth_header) + .to_return(status: 200, body: FHIR::<%= resource_type %>.new.to_json) +<% end %> + + exception = assert_raises(Inferno::AssertionException) { @sequence.run_test(@test) } + + assert_equal 'Expected FHIR Bundle but found: <%= resource_type %>', exception.message + end + +<% if is_first_search %> + it 'skips if an empty Bundle is received' do + <% if is_fixed_value_search %> + [<%= fixed_value_search_string %>].each do |value| + query_params = { + 'patient': @instance.patient_id, + '<%= fixed_value_search_param %>': value + } + stub_request(:get, "#{@base_url}/<%= resource_type %>") + .with(query: query_params, headers: @auth_header) + .to_return(status: 200, body: FHIR::Bundle.new.to_json) + end + <% else %> + stub_request(:get, "#{@base_url}/<%= resource_type %>") + .with(query: @query, headers: @auth_header) + .to_return(status: 200, body: FHIR::Bundle.new.to_json) + <% end %> + + exception = assert_raises(Inferno::SkipException) { @sequence.run_test(@test) } + + assert_equal 'No <%= resource_type %> resources appear to be available.<%=' Please use patients with more information.' unless delayed_sequence%>', exception.message + end +<% end %> + + it 'fails if the bundle contains a resource which does not conform to the base FHIR spec' do +<% if is_first_search && is_fixed_value_search %> + [<%= fixed_value_search_string %>].each do |value| + query_params = { + 'patient': @instance.patient_id, + '<%= fixed_value_search_param %>': value + } + stub_request(:get, "#{@base_url}/<%= resource_type %>") + .with(query: query_params, headers: @auth_header) + .to_return(status: 200, body: wrap_resources_in_bundle(FHIR::<%= resource_type %>.new(id: '!@#$%')).to_json) + end +<% else %> + stub_request(:get, "#{@base_url}/<%= resource_type %>") + .with(query: @query, headers: @auth_header) + .to_return(status: 200, body: wrap_resources_in_bundle(FHIR::<%= resource_type %>.new(id: '!@#$%')).to_json) +<% end %> + + exception = assert_raises(Inferno::AssertionException) { @sequence.run_test(@test) } + + assert_match(/Invalid \w+:/, exception.message) + end + +<% unless has_comparator_tests %> + it 'succeeds when a bundle containing a valid resource matching the search parameters is returned' do + <% if is_first_search && is_fixed_value_search %> + [<%= fixed_value_search_string %>].each do |value| + query_params = { + 'patient': @instance.patient_id, + '<%= fixed_value_search_param %>': value + } + body = + if @sequence.resolve_element_from_path(@<%= resource_var_name %>, '<%= fixed_value_search_path %>') == value + wrap_resources_in_bundle(@<%= resource_var_name %>_ary).to_json + else + FHIR::Bundle.new.to_json + end + stub_request(:get, "#{@base_url}/<%= resource_type %>") + .with(query: query_params, headers: @auth_header) + .to_return(status: 200, body: body) + end + <% else %> + stub_request(:get, "#{@base_url}/<%= resource_type %>") + .with(query: @query, headers: @auth_header) + .to_return(status: 200, body: wrap_resources_in_bundle(@<%= resource_var_name %>_ary).to_json) + <% end %> + + @sequence.run_test(@test) + end +<% end %> +end diff --git a/generator/uscore/templates/unit_tests/unit_test.rb.erb b/generator/uscore/templates/unit_tests/unit_test.rb.erb index 944efe2be..001c1cc8f 100644 --- a/generator/uscore/templates/unit_tests/unit_test.rb.erb +++ b/generator/uscore/templates/unit_tests/unit_test.rb.erb @@ -12,7 +12,7 @@ describe Inferno::Sequence::<%= class_name %> do @client = FHIR::Client.new(@base_url) @token = 'ABC' @instance = Inferno::Models::TestingInstance.create(token: @token, selected_module: '<%= module_name %>') - @patient_id = '123' + @patient_id = 'example' @instance.patient_id = @patient_id set_resource_support(@instance, '<%= resource_type %>') @auth_header = { 'Authorization' => "Bearer #{@token}" } diff --git a/generator/uscore/us_core_unit_test_generator.rb b/generator/uscore/us_core_unit_test_generator.rb index 4070b4ff2..2260722b0 100644 --- a/generator/uscore/us_core_unit_test_generator.rb +++ b/generator/uscore/us_core_unit_test_generator.rb @@ -26,8 +26,45 @@ def generate(sequence, path, module_name) File.write(file_name, unit_tests) end + def generate_search_test( + test_key:, + resource_type:, + search_params:, + is_first_search:, + is_fixed_value_search:, + has_comparator_tests:, + fixed_value_search_param:, + class_name:, + sequence_name:, + delayed_sequence: + ) + + template = ERB.new(File.read(File.join(__dir__, 'templates', 'unit_tests', 'search_unit_test.rb.erb'))) + + resource_var_name = resource_type.underscore + + test = template.result_with_hash( + test_key: test_key, + resource_type: resource_type, + resource_var_name: resource_var_name, + search_params: search_params, + search_param_string: search_params_to_string(search_params), + sequence_name: sequence_name, + is_first_search: is_first_search, + is_fixed_value_search: is_fixed_value_search, + has_comparator_tests: has_comparator_tests, + has_dynamic_search_params: dynamic_search_params(search_params).present?, + fixed_value_search_param: fixed_value_search_param&.dig(:name), + fixed_value_search_string: fixed_value_search_param&.dig(:values)&.map { |value| "'#{value}'" }&.join(', '), + fixed_value_search_path: fixed_value_search_param&.dig(:path), + delayed_sequence: delayed_sequence + ) + tests[class_name] << test + end + def generate_authorization_test(test_key:, resource_type:, search_params:, class_name:, sequence_name:) template = ERB.new(File.read(File.join(__dir__, 'templates', 'unit_tests', 'authorization_unit_test.rb.erb'))) + test = template.result_with_hash( test_key: test_key, resource_type: resource_type, @@ -35,6 +72,7 @@ def generate_authorization_test(test_key:, resource_type:, search_params:, class dynamic_search_params: dynamic_search_params(search_params), sequence_name: sequence_name ) + tests[class_name] << test end diff --git a/generator/uscore/uscore_generator.rb b/generator/uscore/uscore_generator.rb index fa307b888..6cc4a09ac 100644 --- a/generator/uscore/uscore_generator.rb +++ b/generator/uscore/uscore_generator.rb @@ -72,10 +72,17 @@ def generate_tests(metadata) create_interaction_test(sequence, interaction) end + sequence[:operations] + .select { |operation| operation[:expectation] == 'SHALL' } + .each do |operation| + create_docref_test(sequence) if operation[:operation] == 'docref' + end + create_include_test(sequence) if sequence[:include_params].any? create_revinclude_test(sequence) if sequence[:revincludes].any? create_resource_profile_test(sequence) create_must_support_test(sequence) + create_multiple_or_test(sequence) create_references_resolved_test(sequence) end end @@ -85,7 +92,7 @@ def mark_delayed_sequences(metadata) sequence[:delayed_sequence] = sequence[:resource] != 'Patient' && sequence[:searches].none? { |search| search[:names].include? 'patient' } end metadata[:delayed_sequences] = metadata[:sequences].select { |seq| seq[:delayed_sequence] } - metadata[:non_delayed_sequences] = metadata[:sequences].reject { |seq| seq[:delayed_sequence] } + metadata[:non_delayed_sequences] = metadata[:sequences].reject { |seq| seq[:resource] == 'Patient' || seq[:delayed_sequence] } end def find_first_search(sequence) @@ -106,7 +113,7 @@ def generate_sequence(sequence) def create_read_test(sequence) test_key = :resource_read read_test = { - tests_that: "Can read #{sequence[:resource]} from the server", + tests_that: "Server returns correct #{sequence[:resource]} resource from the #{sequence[:resource]} read interaction", key: test_key, index: sequence[:tests].length + 1, link: 'https://www.hl7.org/fhir/us/core/CapabilityStatement-us-core-server.html', @@ -114,16 +121,18 @@ def create_read_test(sequence) } read_test[:test_code] = %( - skip_if_not_supported(:#{sequence[:resource]}, [:read]) + skip_if_known_not_supported(:#{sequence[:resource]}, [:read]) - #{sequence[:resource].underscore}_id = @instance.resource_references.find { |reference| reference.resource_type == '#{sequence[:resource]}' }&.resource_id - skip 'No #{sequence[:resource]} references found from the prior searches' if #{sequence[:resource].underscore}_id.nil? + #{sequence[:resource].underscore}_references = @instance.resource_references.select { |reference| reference.resource_type == '#{sequence[:resource]}' } + skip 'No #{sequence[:resource]} references found from the prior searches' if #{sequence[:resource].underscore}_references.blank? - @#{sequence[:resource].underscore} = validate_read_reply( - FHIR::#{sequence[:resource]}.new(id: #{sequence[:resource].underscore}_id), - FHIR::#{sequence[:resource]} - ) - @#{sequence[:resource].underscore}_ary = Array.wrap(@#{sequence[:resource].underscore}).compact + @#{sequence[:resource].underscore}_ary = #{sequence[:resource].underscore}_references.map do |reference| + validate_read_reply( + FHIR::#{sequence[:resource]}.new(id: reference.resource_id), + FHIR::#{sequence[:resource]} + ) + end + @#{sequence[:resource].underscore} = @#{sequence[:resource].underscore}_ary.first @resources_found = @#{sequence[:resource].underscore}.present?) sequence[:tests] << read_test @@ -152,7 +161,7 @@ def create_authorization_test(sequence) unit_test_params = get_search_param_hash(search_parameters, sequence, true) authorization_test[:test_code] = %( - skip_if_not_supported(:#{sequence[:resource]}, [:search]) + skip_if_known_not_supported(:#{sequence[:resource]}, [:search]) @client.set_no_auth omit 'Do not test if no bearer token set' if @instance.token.blank? @@ -172,6 +181,24 @@ def create_authorization_test(sequence) ) end + def create_docref_test(sequence) + docref_test = { + tests_that: 'The server is capable of returning a reference to a generated CDA document in response to the $docref operation', + index: sequence[:tests].length + 1, + link: 'http://hl7.org/fhir/us/core/2019Sep/CapabilityStatement-us-core-server.html#documentreference', + description: 'A server SHALL be capable of responding to a $docref operation and capable of returning at least a reference to a generated CCD document, if available.' + } + + docref_test[:test_code] = %( + skip_if_known_not_supported(:#{sequence[:resource]}, [], [:docref]) + search_string = "/DocumentReference/$docref?patient=\#{@instance.patient_id}" + reply = @client.get(search_string, @client.fhir_headers) + assert_response_ok(reply) + ) + + sequence[:tests] << docref_test + end + def create_include_test(sequence) include_test = { tests_that: "Server returns the appropriate resource from the following _includes: #{sequence[:include_params].join(', ')}", @@ -199,14 +226,16 @@ def create_include_test(sequence) end def create_revinclude_test(sequence) + first_search = find_first_search(sequence) + return if first_search.blank? + revinclude_test = { - tests_that: "Server returns the appropriate resources from the following _revincludes: #{sequence[:revincludes].join(',')}", + tests_that: "Server returns Provenance resources from #{sequence[:resource]} search by #{first_search[:names].join(' + ')} + _revIncludes: Provenance:target", index: sequence[:tests].length + 1, link: 'https://www.hl7.org/fhir/search.html#revinclude', description: "A Server SHALL be capable of supporting the following _revincludes: #{sequence[:revincludes].join(', ')}" } - first_search = find_first_search(sequence) - search_params = first_search.nil? ? "\nsearch_params = {}" : get_search_params(first_search[:names], sequence) + search_params = get_search_params(first_search[:names], sequence) revinclude_test[:test_code] = search_params sequence[:revincludes].each do |revinclude| resource_name = revinclude.split(':').first @@ -216,16 +245,19 @@ def create_revinclude_test(sequence) reply = get_resource_by_params(versioned_resource_class('#{sequence[:resource]}'), search_params) assert_response_ok(reply) assert_bundle_response(reply) - #{resource_variable} = reply&.resource&.entry&.map(&:resource)&.any? { |resource| resource.resourceType == '#{resource_name}' } - assert #{resource_variable}, 'No #{resource_name} resources were returned from this search' + #{resource_variable} = fetch_all_bundled_resources(reply.resource).select { |resource| resource.resourceType == '#{resource_name}'} + skip 'No #{resource_name} resources were returned from this search' unless #{resource_variable}.present? + #{resource_variable}.each { |reference| @instance.save_resource_reference('#{resource_name}', reference.id) } ) end sequence[:tests] << revinclude_test end def create_search_test(sequence, search_param) + test_key = :"search_by_#{search_param[:names].map(&:underscore).join('_')}" search_test = { tests_that: "Server returns expected results from #{sequence[:resource]} search by #{search_param[:names].join('+')}", + key: test_key, index: sequence[:tests].length + 1, link: 'https://www.hl7.org/fhir/us/core/CapabilityStatement-us-core-server.html', optional: search_param[:expectation] != 'SHALL', @@ -246,15 +278,32 @@ def create_search_test(sequence, search_param) get_first_search(search_param[:names], sequence) else %( - skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found - assert !@#{sequence[:resource].underscore}.nil?, 'Expected valid #{sequence[:resource]} resource to be present' - #{get_search_params(search_param[:names], sequence)} + #{skip_if_not_found(sequence)} + #{get_search_params(search_param[:names], sequence)} reply = get_resource_by_params(versioned_resource_class('#{sequence[:resource]}'), search_params) validate_search_reply(versioned_resource_class('#{sequence[:resource]}'), reply, search_params) - assert_response_ok(reply)) + assert_response_ok(reply) + ) end - search_test[:test_code] += get_comparator_searches(search_param[:names], sequence) + comparator_search_code = get_comparator_searches(search_param[:names], sequence) + search_test[:test_code] += comparator_search_code sequence[:tests] << search_test + + is_fixed_value_search = fixed_value_search?(search_param[:names], sequence) + fixed_value_search_param = is_fixed_value_search ? fixed_value_search_param(search_param[:names], sequence) : nil + + unit_test_generator.generate_search_test( + test_key: test_key, + resource_type: sequence[:resource], + search_params: get_search_param_hash(search_param[:names], sequence), + is_first_search: is_first_search, + is_fixed_value_search: is_fixed_value_search, + has_comparator_tests: comparator_search_code.present?, + fixed_value_search_param: fixed_value_search_param, + class_name: sequence[:class_name], + sequence_name: sequence[:name], + delayed_sequence: sequence[:delayed_sequence] + ) end def create_interaction_test(sequence, interaction) @@ -262,15 +311,16 @@ def create_interaction_test(sequence, interaction) test_key = :"#{interaction[:code]}_interaction" interaction_test = { - tests_that: "#{sequence[:resource]} #{interaction[:code]} interaction supported", + tests_that: "Server returns correct #{sequence[:resource]} resource from #{sequence[:resource]} #{interaction[:code]} interaction", key: test_key, index: sequence[:tests].length + 1, link: 'https://www.hl7.org/fhir/us/core/CapabilityStatement-us-core-server.html', - description: "A server #{interaction[:expectation]} support the #{sequence[:resource]} #{interaction[:code]} interaction." + description: "A server #{interaction[:expectation]} support the #{sequence[:resource]} #{interaction[:code]} interaction.", + optional: interaction[:expectation] != 'SHALL' } interaction_test[:test_code] = %( - skip_if_not_supported(:#{sequence[:resource]}, [:#{interaction[:code]}]) + skip_if_known_not_supported(:#{sequence[:resource]}, [:#{interaction[:code]}]) skip 'No #{sequence[:resource]} resources could be found for this patient. Please use patients with more information.' unless @resources_found validate_#{interaction[:code]}_reply(@#{sequence[:resource].underscore}, versioned_resource_class('#{sequence[:resource]}'))) @@ -289,7 +339,7 @@ def create_interaction_test(sequence, interaction) def create_must_support_test(sequence) test = { - tests_that: "At least one of every must support element is provided in any #{sequence[:resource]} for this patient.", + tests_that: "All must support elements are provided in the #{sequence[:resource]} resources returned.", index: sequence[:tests].length + 1, link: 'http://www.hl7.org/fhir/us/core/general-guidance.html#must-support', test_code: '', @@ -299,68 +349,80 @@ def create_must_support_test(sequence) ) } - sequence[:must_supports].select { |must_support| must_support[:type] == 'element' }.each do |element| + must_support_elements = sequence[:must_supports].select { |must_support| must_support[:type] == 'element' } + must_support_elements.each do |element| test[:description] += %( #{element[:path]} ) + # class is mapped to local_class in fhir_models. Update this after it + # has been added to the description so that the description contains + # the original path + element[:path] = element[:path].gsub('.class', '.local_class') + end + + must_support_extensions = sequence[:must_supports].select { |must_support| must_support[:type] == 'extension' } + must_support_extensions.each do |extension| + test[:description] += %( + #{extension[:id]} + ) end - test[:test_code] += %( - skip 'No resources appear to be available for this patient. Please use patients with more information' unless @#{sequence[:resource].underscore}_ary&.any?) test[:test_code] += %( - must_support_confirmed = {}) + #{skip_if_not_found(sequence)} + ) + + if must_support_extensions.present? + extensions_list = must_support_extensions.map { |extension| "'#{extension[:id]}': '#{extension[:url]}'" } - extensions_list = [] - sequence[:must_supports].select { |must_support| must_support[:type] == 'extension' }.each do |extension| - extensions_list << "'#{extension[:id]}': '#{extension[:url]}'" - end - if extensions_list.any? test[:test_code] += %( - extensions_list = { - #{extensions_list.join(",\n ")} - } - extensions_list.each do |id, url| - @#{sequence[:resource].underscore}_ary&.each do |resource| - must_support_confirmed[id] = true if resource.extension.any? { |extension| extension.url == url } - break if must_support_confirmed[id] - end - skip_notification = "Could not find \#{id} in any of the \#{@#{sequence[:resource].underscore}_ary.length} provided #{sequence[:resource]} resource(s)" - skip skip_notification unless must_support_confirmed[id] + must_support_extensions = { + #{extensions_list.join(",\n ")} + } + missing_must_support_extensions = must_support_extensions.reject do |_id, url| + @#{sequence[:resource].underscore}_ary&.any? do |resource| + resource.extension.any? { |extension| extension.url == url } end - ) - end - elements_list = [] - sequence[:must_supports].select { |must_support| must_support[:type] == 'element' }.each do |element| - element[:path] = element[:path].gsub('.class', '.local_class') # class is mapped to local_class in fhir_models - elements_list << "'#{element[:path]}'" + end + ) end - if elements_list.any? + if must_support_elements.present? + elements_list = must_support_elements.map { |element| "'#{element[:path]}'" } + test[:test_code] += %( - must_support_elements = [ - #{elements_list.join(",\n ")} - ] - must_support_elements.each do |path| - @#{sequence[:resource].underscore}_ary&.each do |resource| - truncated_path = path.gsub('#{sequence[:resource]}.', '') - must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) - break if must_support_confirmed[path] - end - resource_count = @#{sequence[:resource].underscore}_ary.length + must_support_elements = [ + #{elements_list.join(",\n ")} + ] + + missing_must_support_elements = must_support_elements.reject do |path| + truncated_path = path.gsub('#{sequence[:resource]}.', '') + @#{sequence[:resource].underscore}_ary&.any? do |resource| + resolve_element_from_path(resource, truncated_path).present? + end + end + ) - skip "Could not find \#{path} in any of the \#{resource_count} provided #{sequence[:resource]} resource(s)" unless must_support_confirmed[path] - end) + if must_support_extensions.present? + test[:test_code] += %( + missing_must_support_elements += missing_must_support_extensions.keys + ) + end + + test[:test_code] += %( + skip_if missing_must_support_elements.present?, + "Could not find \#{missing_must_support_elements.join(', ')} in the \#{@#{sequence[:resource].underscore}_ary&.length} provided #{sequence[:resource]} resource(s)" + ) end test[:test_code] += %( - @instance.save!) + @instance.save!) sequence[:tests] << test end def create_resource_profile_test(sequence) test = { - tests_that: "#{sequence[:resource]} resources associated with Patient conform to US Core R4 profiles", + tests_that: "#{sequence[:resource]} resources returned conform to US Core R4 profiles", index: sequence[:tests].length + 1, link: sequence[:profile], description: %( @@ -369,33 +431,68 @@ def create_resource_profile_test(sequence) ) } test[:test_code] = %( - skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found + #{skip_if_not_found(sequence)} test_resources_against_profile('#{sequence[:resource]}'#{', ' + validation_profile_uri(sequence) if validation_profile_uri(sequence)})) sequence[:tests] << test end + def create_multiple_or_test(sequence) + test = { + tests_that: 'The server returns expected results when parameters use composite-or', + index: sequence[:tests].length + 1, + link: sequence[:profile], + test_code: '' + } + + multiple_or_params = get_multiple_or_params(sequence) + + multiple_or_params.each do |param| + multiple_or_search = sequence[:searches].find { |search| (search[:names].include? param) && search[:expectation] == 'SHALL' } + next if multiple_or_search.blank? + + second_val_var = "second_#{param}_val" + resolve_el_str = "#{resolve_element_path(sequence[:search_param_descriptions][param.to_sym])} { |el| get_value_for_search_param(el) != #{param_value_name(param)} }" + test[:test_code] += %( + #{get_search_params(multiple_or_search[:names], sequence)} + #{second_val_var} = #{resolve_el_str} + skip 'Cannot find second value for #{param} to perform a multipleOr search' if #{second_val_var}.nil? + #{param_value_name(param)} += ',' + get_value_for_search_param(#{second_val_var}) + reply = get_resource_by_params(versioned_resource_class('#{sequence[:resource]}'), search_params) + validate_search_reply(versioned_resource_class('#{sequence[:resource]}'), reply, search_params) + assert_response_ok(reply) + ) + end + sequence[:tests] << test if test[:test_code].present? + end + + def get_multiple_or_params(sequence) + sequence[:search_param_descriptions] + .select { |_param, description| description[:multiple_or] == 'SHALL' } + .map { |param, _description| param.to_s } + end + def create_references_resolved_test(sequence) test = { - tests_that: 'All references can be resolved', + tests_that: "Every reference within #{sequence[:resource]} resource is valid and can be read.", index: sequence[:tests].length + 1, link: 'http://hl7.org/fhir/references.html', description: 'This test checks if references found in resources from prior searches can be resolved.' } test[:test_code] = %( - skip_if_not_supported(:#{sequence[:resource]}, [:search, :read]) - skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found + skip_if_known_not_supported(:#{sequence[:resource]}, [:search, :read]) + #{skip_if_not_found(sequence)} validate_reference_resolutions(@#{sequence[:resource].underscore})) sequence[:tests] << test end - def resolve_element_path(search_param_description) + def resolve_element_path(search_param_description, resolve_block = '') element_path = search_param_description[:path].gsub('.class', '.local_class') # match fhir_models because class is protected keyword in ruby path_parts = element_path.split('.') resource_val = "@#{path_parts.shift.underscore}_ary" - "get_value_for_search_param(resolve_element_from_path(#{resource_val}, '#{path_parts.join('.')}'))" + "resolve_element_from_path(#{resource_val}, '#{path_parts.join('.')}') #{resolve_block}" end def get_value_path_by_type(type) @@ -431,12 +528,17 @@ def get_first_search(search_parameters, sequence) validation_profile_uri(sequence) ].compact.join(', ') - search_code = if search_parameters == ['patient'] || sequence[:delayed_sequence] || search_param_constants(search_parameters, sequence) - get_first_search_by_patient(sequence, search_parameters, save_resource_ids_in_bundle_arguments) - else - get_first_search_with_fixed_values(sequence, search_parameters, save_resource_ids_in_bundle_arguments) - end - search_code + if fixed_value_search?(search_parameters, sequence) + get_first_search_with_fixed_values(sequence, search_parameters, save_resource_ids_in_bundle_arguments) + else + get_first_search_by_patient(sequence, search_parameters, save_resource_ids_in_bundle_arguments) + end + end + + def fixed_value_search?(search_parameters, sequence) + search_parameters != ['patient'] && + !sequence[:delayed_sequence] && + !search_param_constants(search_parameters, sequence) end def get_first_search_by_patient(sequence, search_parameters, save_resource_ids_in_bundle_arguments) @@ -446,45 +548,69 @@ def get_first_search_by_patient(sequence, search_parameters, save_resource_ids_i assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply&.resource&.entry&.length || 0 - @resources_found = true if resource_count.positive? + @resources_found = reply&.resource&.entry&.any? { |entry| entry&.resource&.resourceType == '#{sequence[:resource]}' } - skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found + #{skip_if_not_found(sequence)} - @#{sequence[:resource].underscore} = reply&.resource&.entry&.first&.resource - @#{sequence[:resource].underscore}_ary = fetch_all_bundled_resources(reply&.resource) + @#{sequence[:resource].underscore} = reply.resource.entry + .find { |entry| entry&.resource&.resourceType == '#{sequence[:resource]}' } + .resource + @#{sequence[:resource].underscore}_ary = fetch_all_bundled_resources(reply.resource) save_resource_ids_in_bundle(#{save_resource_ids_in_bundle_arguments}) save_delayed_sequence_references(@#{sequence[:resource].underscore}_ary) validate_search_reply(versioned_resource_class('#{sequence[:resource]}'), reply, search_params) ) end + def fixed_value_search_param(search_parameters, sequence) + name = search_parameters.find { |param| param != 'patient' } + search_description = sequence[:search_param_descriptions][name.to_sym] + values = search_description[:values] + path = + search_description[:path] + .split('.') + .drop(1) + .map { |path_part| path_part == 'class' ? 'local_class' : path_part } + .join('.') + path += get_value_path_by_type(search_description[:type]) + + { + name: name, + path: path, + values: values + } + end + def get_first_search_with_fixed_values(sequence, search_parameters, save_resource_ids_in_bundle_arguments) # assume only patient + one other parameter - non_patient_search_param = search_parameters.find { |param| param != 'patient' } - non_patient_values = sequence[:search_param_descriptions][non_patient_search_param.to_sym][:values] - values_variable_name = "#{non_patient_search_param.tr('-', '_')}_val" + search_param = fixed_value_search_param(search_parameters, sequence) + find_two_values = get_multiple_or_params(sequence).include? search_param[:name] + values_variable_name = "#{search_param[:name].tr('-', '_')}_val" %( - #{values_variable_name} = [#{non_patient_values.map { |val| "'#{val}'" }.join(', ')}] + @#{sequence[:resource].underscore}_ary = [] + #{'values_found = 0' if find_two_values} + #{values_variable_name} = [#{search_param[:values].map { |val| "'#{val}'" }.join(', ')}] #{values_variable_name}.each do |val| - search_params = { 'patient': @instance.patient_id, '#{non_patient_search_param}': val } + search_params = { 'patient': @instance.patient_id, '#{search_param[:name]}': val } reply = get_resource_by_params(versioned_resource_class('#{sequence[:resource]}'), search_params) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply&.resource&.entry&.length || 0 - @resources_found = true if resource_count.positive? - next unless @resources_found + next unless reply&.resource&.entry&.any? { |entry| entry&.resource&.resourceType == '#{sequence[:resource]}' } - @#{sequence[:resource].underscore} = reply&.resource&.entry&.first&.resource - @#{sequence[:resource].underscore}_ary = fetch_all_bundled_resources(reply&.resource) + @resources_found = true + @#{sequence[:resource].underscore} = reply.resource.entry + .find { |entry| entry&.resource&.resourceType == '#{sequence[:resource]}' } + .resource + @#{sequence[:resource].underscore}_ary += fetch_all_bundled_resources(reply.resource) + #{'values_found += 1' if find_two_values} save_resource_ids_in_bundle(#{save_resource_ids_in_bundle_arguments}) save_delayed_sequence_references(@#{sequence[:resource].underscore}_ary) validate_search_reply(versioned_resource_class('#{sequence[:resource]}'), reply, search_params) - break + break#{' if values_found == 2' if find_two_values} end - skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found) + #{skip_if_not_found(sequence)}) end def get_search_params(search_parameters, sequence, grab_first_value = false) @@ -520,7 +646,7 @@ def get_search_param_hash(search_parameters, sequence, grab_first_value = false) elsif grab_first_value && !sequence[:delayed_sequence] sequence[:search_param_descriptions][param.to_sym][:values].first else - resolve_element_path(sequence[:search_param_descriptions][param.to_sym]) + "get_value_for_search_param(#{resolve_element_path(sequence[:search_param_descriptions][param.to_sym])})" end end end @@ -544,7 +670,6 @@ def get_comparator_searches(search_params, sequence) comparator_search_params = #{search_assignments_str.gsub(param_val_name, 'comparator_val')} reply = get_resource_by_params(versioned_resource_class('#{sequence[:resource]}'), comparator_search_params) validate_search_reply(versioned_resource_class('#{sequence[:resource]}'), reply, comparator_search_params) - assert_response_ok(reply) end) end end @@ -559,6 +684,11 @@ def find_comparators(search_params, sequence) end end + def skip_if_not_found(sequence) + use_other_patient = ' Please use patients with more information.' + "skip 'No #{sequence[:resource]} resources appear to be available.#{use_other_patient unless sequence[:delayed_sequence]}' unless @resources_found" + end + def search_param_constants(search_parameters, sequence) return { '_id': '@instance.patient_id' } if search_parameters == ['_id'] && sequence[:resource] == 'Patient' end @@ -573,19 +703,10 @@ def create_search_validation(sequence) path_parts = path_parts.map { |part| part == 'class' ? 'local_class' : part } path_parts.shift case type - when 'Period' - search_validators += %( - value_found = can_resolve_path(resource, '#{path_parts.join('.')}') do |period| - validate_period_search(value, period) - end - assert value_found, '#{element} on resource does not match #{element} requested' - ) - when 'date' + when 'Period', 'date' search_validators += %( - value_found = can_resolve_path(resource, '#{path_parts.join('.')}') do |date| - validate_date_search(value, date) - end - assert value_found, '#{element} on resource does not match #{element} requested' + value_found = resolve_element_from_path(resource, '#{path_parts.join('.')}') { |date| validate_date_search(value, date) } + assert value_found.present?, '#{element} on resource does not match #{element} requested' ) when 'HumanName' # When a string search parameter refers to the types HumanName and Address, @@ -593,39 +714,40 @@ def create_search_validation(sequence) # https://www.hl7.org/fhir/search.html#string search_validators += %( value = value.downcase - value_found = can_resolve_path(resource, '#{path_parts.join('.')}') do |name| + value_found = resolve_element_from_path(resource, '#{path_parts.join('.')}') do |name| name&.text&.start_with?(value) || name&.family&.downcase&.include?(value) || name&.given&.any? { |given| given.downcase.start_with?(value) } || name&.prefix&.any? { |prefix| prefix.downcase.start_with?(value) } || name&.suffix&.any? { |suffix| suffix.downcase.start_with?(value) } end - assert value_found, '#{element} on resource does not match #{element} requested' + assert value_found.present?, '#{element} on resource does not match #{element} requested' ) when 'Address' search_validators += %( - value_found = can_resolve_path(resource, '#{path_parts.join('.')}') do |address| + value_found = resolve_element_from_path(resource, '#{path_parts.join('.')}') do |address| address&.text&.start_with?(value) || address&.city&.start_with?(value) || address&.state&.start_with?(value) || address&.postalCode&.start_with?(value) || address&.country&.start_with?(value) end - assert value_found, '#{element} on resource does not match #{element} requested' + assert value_found.present?, '#{element} on resource does not match #{element} requested' ) else # searching by patient requires special case because we are searching by a resource identifier # references can also be URL's, so we made need to resolve those url's + path = path_parts.join('.') + get_value_path_by_type(type) search_validators += if ['subject', 'patient'].include? element.to_s %( - value_found = can_resolve_path(resource, '#{path_parts.join('.') + get_value_path_by_type(type)}') { |reference| [value, 'Patient/' + value].include? reference } - assert value_found, '#{element} on resource does not match #{element} requested' + value_found = resolve_element_from_path(resource, '#{path}') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found.present?, '#{element} on resource does not match #{element} requested' ) else %( - value_found = can_resolve_path(resource, '#{path_parts.join('.') + get_value_path_by_type(type)}') { |value_in_resource| value_in_resource == value } - assert value_found, '#{element} on resource does not match #{element} requested' + value_found = resolve_element_from_path(resource, '#{path}') { |value_in_resource| value.split(',').include? value_in_resource } + assert value_found.present?, '#{element} on resource does not match #{element} requested' ) end end diff --git a/lib/app.rb b/lib/app.rb index 073b8c1af..ee8a1ccb8 100644 --- a/lib/app.rb +++ b/lib/app.rb @@ -13,7 +13,6 @@ require 'dm-core' require 'dm-migrations' require 'jwt' -require 'json/jwt' require 'kramdown' require 'rack' diff --git a/lib/app/endpoint.rb b/lib/app/endpoint.rb index 9c446e0fa..0cf1a0ada 100644 --- a/lib/app/endpoint.rb +++ b/lib/app/endpoint.rb @@ -5,12 +5,14 @@ require 'sinatra/cookies' require_relative 'helpers/configuration' require_relative 'helpers/browser_logic' +require_relative 'utils/resource_validator_factory' + module Inferno class App class Endpoint < Sinatra::Base register Sinatra::ConfigFile - config_file '../../config.yml' + config_file File.join('..', '..', ENV['INFERNO_CONFIG_FILE'] || 'config.yml') OpenSSL::SSL::VERIFY_PEER = OpenSSL::SSL::VERIFY_NONE if settings.disable_verify_peer Inferno::BASE_PATH = "/#{settings.base_path.gsub(/[^0-9a-z_-]/i, '')}" @@ -18,6 +20,7 @@ class Endpoint < Sinatra::Base Inferno::ENVIRONMENT = settings.environment Inferno::PURGE_ON_RELOAD = settings.purge_database_on_reload Inferno::EXTRAS = settings.include_extras + Inferno::RESOURCE_VALIDATOR = Inferno::App::ResourceValidatorFactory.new_validator(settings.resource_validator, settings.external_resource_validator_url) if settings.logging_enabled $stdout.sync = true # output in Docker is heavily delayed without this diff --git a/lib/app/endpoint/home.rb b/lib/app/endpoint/home.rb index 8d6f05d70..934ce2f9c 100644 --- a/lib/app/endpoint/home.rb +++ b/lib/app/endpoint/home.rb @@ -74,12 +74,17 @@ class Home < Endpoint # Returns test details for a specific test including any applicable requests and responses. # This route is typically used for retrieving test metadata before the test has been run get '/test_details/:module/:sequence_name/:test_index?' do - sequence = Inferno::Module.get(params[:module]).sequences.find do |x| + inferno_module = Inferno::Module.get(params[:module]) + sequence = inferno_module.sequences.find do |x| x.sequence_name == params[:sequence_name] end + halt 404 unless sequence - @test = sequence.tests[params[:test_index].to_i] + + @test = sequence.tests(inferno_module)[params[:test_index].to_i] + halt 404 unless @test.present? + erb :test_details, layout: false end diff --git a/lib/app/endpoint/oauth2_endpoints.rb b/lib/app/endpoint/oauth2_endpoints.rb index 7ef193423..686d4f99c 100644 --- a/lib/app/endpoint/oauth2_endpoints.rb +++ b/lib/app/endpoint/oauth2_endpoints.rb @@ -89,13 +89,20 @@ def resume_execution submitted_test_cases_count = sequence_result.next_test_cases.split(',') total_tests = submitted_test_cases_count.reduce(first_test_count) do |total, set| - sequence_test_count = test_set.test_case_by_id(set).sequence.test_count + sequence_test_count = test_set.test_case_by_id(set).sequence.test_count(@instance.module) total + sequence_test_count end - sequence_result = sequence.resume(request, headers, request.params, @error_message) do |result| + sequence_result = sequence.resume(request, headers, request.params, @error_message) do count += 1 - out << js_update_result(sequence, test_set, result, count, sequence.test_count, count, total_tests) + out << js_update_result( + instance: @instance, + sequence: sequence, + test_set: test_set, + set_count: count, + count: count, + total: total_tests + ) @instance.save! end all_test_cases << test_case.id @@ -134,10 +141,17 @@ def resume_execution @instance.reload # ensure that we have all the latest data sequence = test_case.sequence.new(@instance, client, settings.disable_tls_tests) count = 0 - sequence_result = sequence.start do |result| + sequence_result = sequence.start do test_count += 1 count += 1 - out << js_update_result(sequence, test_set, result, count, sequence.test_count, test_count, total_tests) + out << js_update_result( + instance: @instance, + sequence: sequence, + test_set: test_set, + set_count: count, + count: test_count, + total: total_tests + ) end all_test_cases << test_case.id failed_test_cases << test_case.id if sequence_result.fail? diff --git a/lib/app/endpoint/test_set_endpoints.rb b/lib/app/endpoint/test_set_endpoints.rb index 85e76023a..b7f6db22f 100644 --- a/lib/app/endpoint/test_set_endpoints.rb +++ b/lib/app/endpoint/test_set_endpoints.rb @@ -64,8 +64,9 @@ def self.included(klass) # Cancels the currently running test get '/:id/test_sets/:test_set_id/sequence_result/:sequence_result_id/cancel' do sequence_result = Inferno::Models::SequenceResult.get(params[:sequence_result_id]) - halt 404 if sequence_result.testing_instance.id != params[:id] - test_set = sequence_result.testing_instance.module.test_sets[params[:test_set_id].to_sym] + instance = sequence_result.testing_instance + halt 404 if instance.id != params[:id] + test_set = instance.module.test_sets[params[:test_set_id].to_sym] halt 404 if test_set.nil? sequence_result.result = 'cancel' @@ -77,13 +78,13 @@ def self.included(klass) last_result.message = cancel_message end - sequence = sequence_result.testing_instance.module.sequences.find do |x| + sequence = instance.module.sequences.find do |x| x.sequence_name == sequence_result.name end current_test_count = sequence_result.result_count - sequence.tests.each_with_index do |test, index| + sequence.tests(instance.module).each_with_index do |test, index| next if index < current_test_count sequence_result.test_results << Inferno::Models::TestResult.new(test_id: test.id, @@ -131,7 +132,7 @@ def self.included(klass) instance.reload # ensure that we have all the latest data total_tests = submitted_test_cases.reduce(0) do |total, set| - sequence_test_count = test_set.test_case_by_id(set).sequence.test_count + sequence_test_count = test_set.test_case_by_id(set).sequence.test_count(instance.module) total + sequence_test_count end @@ -178,10 +179,17 @@ def self.included(klass) instance.reload # ensure that we have all the latest data sequence = test_case.sequence.new(instance, client, settings.disable_tls_tests) count = 0 - sequence_result = sequence.start(test_set.id, test_case.id) do |result| + sequence_result = sequence.start(test_set.id, test_case.id) do count += 1 test_count += 1 - out << js_update_result(sequence, test_set, result, count, sequence.test_count, test_count, total_tests) + out << js_update_result( + instance: instance, + sequence: sequence, + test_set: test_set, + set_count: count, + count: test_count, + total: total_tests + ) end sequence_result.next_test_cases = ([next_test_case] + submitted_test_cases).join(',') diff --git a/lib/app/ext/fhir_client.rb b/lib/app/ext/fhir_client.rb index 31f1c938a..7c1fd815c 100644 --- a/lib/app/ext/fhir_client.rb +++ b/lib/app/ext/fhir_client.rb @@ -4,9 +4,10 @@ module FHIR VERSIONS = [:dstu2, :stu3, :r4].freeze class Client - attr_accessor :requests + attr_accessor :requests, :testing_instance def record_requests(reply) + reply.response[:timestamp] = DateTime.now @requests ||= [] @requests << reply end @@ -19,6 +20,7 @@ def monitor_requests class_eval <<~RUBY, __FILE__, __LINE__ + 1 alias #{method}_original #{method} def #{method}(*args, &block) + refresh_token_if_needed reply = #{method}_original(*args, &block) record_requests(reply) return reply @@ -27,8 +29,67 @@ def #{method}(*args, &block) end end + def refresh_token_if_needed + return if testing_instance&.refresh_token.blank? + + perform_refresh if time_to_refresh? + end + + def time_to_refresh? + return true if testing_instance.token_expires_in.blank? + + testing_instance.token_expiration_time.to_i - DateTime.now.to_i < 60 + end + + def perform_refresh + oauth2_params = { + 'grant_type' => 'refresh_token', + 'refresh_token' => testing_instance.refresh_token + } + oauth2_headers = { 'Content-Type' => 'application/x-www-form-urlencoded' } + + if testing_instance.confidential_client + oauth2_headers['Authorization'] = encoded_secret(testing_instance.client_id, testing_instance.client_secret) + else + oauth2_params['client_id'] = testing_instance.client_id + end + + begin + token_response = Inferno::LoggedRestClient.post( + testing_instance.oauth_token_endpoint, + oauth2_params, + oauth2_headers + ) + + return if token_response.code != 200 + + token_response_body = JSON.parse(token_response.body) + + expires_in = token_response_body['expires_in'].is_a?(Numeric) ? token_response_body['expires_in'] : nil + + update_params = { + token: token_response_body['access_token'], + token_retrieved_at: DateTime.now, + token_expires_in: expires_in + } + + update_params[:refresh_token] = token_response_body['refresh_token'] if token_response_body['refresh_token'].present? + testing_instance.save + testing_instance.update(update_params) + + set_bearer_token(token_response_body['access_token']) + rescue StandardError => e + Inferno.logger.error "Unable to refresh token: #{e.message}" + end + end + + def encoded_secret(client_id, client_secret) + "Basic #{Base64.strict_encode64(client_id + ':' + client_secret)}" + end + def self.for_testing_instance(instance) new(instance.url).tap do |client| + client.testing_instance = instance case instance.fhir_version when 'stu3' client.use_stu3 diff --git a/lib/app/helpers/browser_logic.rb b/lib/app/helpers/browser_logic.rb index 053080902..20c3b9f73 100644 --- a/lib/app/helpers/browser_logic.rb +++ b/lib/app/helpers/browser_logic.rb @@ -16,17 +16,18 @@ def js_stayalive(time) "" end - def js_update_result(sequence, _test_set, _result, set_count, set_total, count, total) + def js_update_result(instance:, sequence:, test_set:, set_count:, count:, total:) cancel_button = if sequence.sequence_result - "Cancel Sequence" + cancel_link = "#{instance.base_url}#{base_path}/#{instance.id}/test_sets/#{test_set.id}/sequence_result/#{sequence.sequence_result.id}/cancel" + "Cancel Sequence" else '' end %(