diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index afac92d..02fd760 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -8,7 +8,7 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: matrix: ruby-version: ['3.3.6'] @@ -23,7 +23,7 @@ jobs: test: needs: build - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: matrix: ruby-version: ['3.3.6'] @@ -39,7 +39,7 @@ jobs: lint: needs: build - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: matrix: ruby-version: ['3.3.6'] @@ -55,7 +55,7 @@ jobs: requirements: needs: build - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: matrix: ruby-version: ['3.3.6'] diff --git a/.github/workflows/run_inferno_execution_scripts.yml b/.github/workflows/run_inferno_execution_scripts.yml index 501b003..dee497a 100644 --- a/.github/workflows/run_inferno_execution_scripts.yml +++ b/.github/workflows/run_inferno_execution_scripts.yml @@ -12,7 +12,7 @@ on: jobs: find-execution-scripts: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: scripts: ${{ steps.list.outputs.scripts }} steps: @@ -24,7 +24,7 @@ jobs: test: needs: find-execution-scripts - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false # let all files run even if one fails matrix: diff --git a/lib/subscriptions_test_kit/common/notification_conformance_verification.rb b/lib/subscriptions_test_kit/common/notification_conformance_verification.rb index 34c8199..b149527 100644 --- a/lib/subscriptions_test_kit/common/notification_conformance_verification.rb +++ b/lib/subscriptions_test_kit/common/notification_conformance_verification.rb @@ -308,19 +308,34 @@ def id_only_event_notification_verification(notification_bundle, criteria_resour verify_id_only_notification_bundle_entries(bundle_entries) end - def notification_header_verification(request_headers, subscription) - mime_type = subscription&.dig('channel', 'payload') + # The requested headers come from requested_subscription, the Subscription as the client submitted it. + def notification_header_verification(request_headers, subscription, requested_subscription: subscription) + mime_type = notification_subscription_channel(subscription)['payload'] # Verification for hl7.fhir.uv.subscriptions_1.1.0@26 - if mime_type.present? + if mime_type.is_a?(String) && mime_type.present? content_type_header = request_headers.find { |header| header.name.downcase == 'content-type' } handle_content_type_header_errors(mime_type, content_type_header) end # Verification for hl7.fhir.uv.subscriptions_1.1.0@27 - subscription.dig('channel', 'header').each do |requested_header| + requested_channel = notification_subscription_channel(requested_subscription) + notification_requested_headers(requested_channel).each do |requested_header| handle_sent_header_errors(request_headers, requested_header) end end + def notification_subscription_channel(subscription) + subscription_channel = subscription['channel'] if subscription.is_a?(Hash) + subscription_channel.is_a?(Hash) ? subscription_channel : {} + end + + def notification_requested_headers(subscription_channel) + headers = subscription_channel['header'] + headers = [headers] if headers.is_a?(String) + return [] unless headers.is_a?(Array) + + headers.grep(String).select { |header| notification_header_name_and_value(header).first.present? } + end + def handle_content_type_header_errors(mime_type, content_type_header) if content_type_header.blank? add_message('error', "Content type header not sent, subscription requested #{mime_type}\n") @@ -331,9 +346,14 @@ def handle_content_type_header_errors(mime_type, content_type_header) end end + # HTTP allows optional whitespace after the colon and around the value + def notification_header_name_and_value(header) + name, _separator, value = header.partition(':') + [name.strip.downcase, value.strip] + end + def handle_sent_header_errors(request_headers, requested_header) - requested_name = requested_header.partition(': ').first.downcase - requested_value = requested_header.partition(': ').last + requested_name, requested_value = notification_header_name_and_value(requested_header) sent_header = request_headers.find { |header| header.name.downcase == requested_name } if sent_header.blank? add_message('error', "Requested header not sent: #{requested_header}\n") diff --git a/lib/subscriptions_test_kit/common/subscription_conformance_verification.rb b/lib/subscriptions_test_kit/common/subscription_conformance_verification.rb index 6753039..f990434 100644 --- a/lib/subscriptions_test_kit/common/subscription_conformance_verification.rb +++ b/lib/subscriptions_test_kit/common/subscription_conformance_verification.rb @@ -72,10 +72,13 @@ def subscription_verification(subscription_resource, validator: :default) def server_check_channel(subscription, access_token) subscription_channel = subscription['channel'] + return subscription unless subscription_channel.is_a?(Hash) + unless channel_field_matches?(subscription_channel, 'endpoint', subscription_channel_url) - add_message('warning', %( - The subscription url was changed from #{subscription_channel['endpoint']} to - #{subscription_channel_url})) + add_message('warning', <<~MESSAGE) + The subscription url was changed from #{subscription_channel['endpoint']} to + #{subscription_channel_url} + MESSAGE subscription_channel['endpoint'] = subscription_channel_url end @@ -93,17 +96,56 @@ def server_check_channel(subscription, access_token) )) end - unless subscription_channel['header'].present? && - subscription_channel['header'].include?("Authorization: Bearer #{access_token}") + server_check_channel_authorization_header(subscription_channel, access_token) + subscription['channel'] = subscription_channel + subscription + end + + def server_check_channel_authorization_header(subscription_channel, access_token) + headers = server_channel_header_list(subscription_channel) + return if headers.nil? + + authorization_headers = headers.select { |header| channel_authorization_header?(header) } + return if authorization_headers.length == 1 && + channel_authorization_header_matches?(authorization_headers.first, access_token) + + action = authorization_headers.empty? ? 'Added' : 'Replaced' + add_message('warning', %( + Tester-provided Subscription updated before use: #{action} the Authorization header entry in the `header` + field with the Bearer token `#{access_token}` provided in the "Notification Access Token" input. + )) + subscription_channel['header'] = (headers - authorization_headers) + ["Authorization: Bearer #{access_token}"] + end + + def server_channel_header_list(subscription_channel) + headers = subscription_channel['header'] + case headers + when nil + [] + when Array + headers + when String add_message('warning', %( - Added the Authorization header field with a Bearer token set to #{access_token} to the `header` field on the - Subscription resource in order to connect successfully with the Inferno subscription channel. + Tester-provided Subscription updated before use: incorrect string value for element `header` converted into + a list containing the provided string. + )) + subscription_channel['header'] = [headers] + else + add_message('error', %( + The `header` element on the tester-provided Subscription must be a list of strings. )) - subscription_channel['header'] = [] unless subscription_channel['header'].present? - subscription_channel['header'].append("Authorization: Bearer #{access_token}") + nil end - subscription['channel'] = subscription_channel - subscription + end + + def channel_authorization_header?(header) + header.is_a?(String) && header.split(':', 2).first.to_s.strip.casecmp?('authorization') + end + + # Accepts only the canonical form, which notification header verification parses to the same name and value + def channel_authorization_header_matches?(header, access_token) + name, _separator, value = header.partition(': ') + name.casecmp?('authorization') && value == "Bearer #{access_token}" end end end diff --git a/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/common/interaction/subscription_conformance_test.rb b/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/common/interaction/subscription_conformance_test.rb index 7e1fb7e..fe36e29 100644 --- a/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/common/interaction/subscription_conformance_test.rb +++ b/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/common/interaction/subscription_conformance_test.rb @@ -20,14 +20,16 @@ class SubscriptionConformanceTest < Inferno::Test `#{Inferno::Application['base_url']}/custom/subscriptions_r5_backport_r4_server#{SUBSCRIPTION_CHANNEL_PATH}`. The test will add the correct url to this field if it is not properly set. - The `type` element must be set to `rest-hook`, as the Inferno subscription workflow tests use a `rest-hook` - subscription channel to receive incoming notifications. The test will add the correct type to this field if it - is not properly set. + subscription channel to receive incoming notifications. The test will fail if this field is not set to + `rest-hook`. - The `payload` element should be `application/fhir+json`. Inferno will update it to that value unless the provided value is `application/json`, which Inferno also supports receiving as the `content-type` HTTP header on notifications. - The `header` element must include the `Authorization` header with a Bearer token set to the inputted Inferno access token to direct the server to send that header to identify the notifications are for this session. - Inferno will add the entry if it is not present. + Inferno will add the entry if it is not present, replacing any other `Authorization` entry. The `header` + element must be a list of strings: Inferno converts a single string into a list, and the test fails for any + other value. ) simulation_verification diff --git a/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/common/interaction_verification/notification_conformance_test.rb b/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/common/interaction_verification/notification_conformance_test.rb index 90f102f..539b708 100644 --- a/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/common/interaction_verification/notification_conformance_test.rb +++ b/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/common/interaction_verification/notification_conformance_test.rb @@ -67,6 +67,8 @@ def subscription_type assert_valid_json(latest_subscription.response_body) subscription = JSON.parse(latest_subscription.response_body) + assert(subscription.is_a?(Hash), 'The Subscription the server returned is not a JSON object.') + requested_subscription = JSON.parse(latest_subscription.request_body) requests = load_tagged_requests('event-notification', subscription['id']) skip_if requests.empty?, %( @@ -84,7 +86,8 @@ def subscription_type ) notification_header_verification( request.request_headers, - subscription + subscription, + requested_subscription: ) end diff --git a/spec/subscriptions_test_kit/common/notification_conformance_verification_spec.rb b/spec/subscriptions_test_kit/common/notification_conformance_verification_spec.rb index 8db7c51..95c4a5a 100644 --- a/spec/subscriptions_test_kit/common/notification_conformance_verification_spec.rb +++ b/spec/subscriptions_test_kit/common/notification_conformance_verification_spec.rb @@ -761,6 +761,83 @@ def entity_result_message_type(runnable) 'Expected application/json, received application/fhir+json ' \ "Requested header not sent: accept: application/json\n") end + + it 'matches a requested header written without a space after the colon' do + subscription_resource['channel']['header'] = ['Authorization:Bearer SAMPLE_TOKEN'] + inputs = { subscription: subscription_resource.to_json } + result = run(test, inputs) + expect(result.result).to eq('pass') + end + + it 'ignores optional whitespace around a requested header value' do + subscription_resource['channel']['header'] = ['content-type: application/fhir+json '] + inputs = { subscription: subscription_resource.to_json } + result = run(test, inputs) + expect(result.result).to eq('pass') + end + + it 'reports the parsed name and value when a requested header written without a space does not match' do + subscription_resource['channel']['header'] = ['Authorization:Bearer OTHER_TOKEN'] + inputs = { subscription: subscription_resource.to_json } + result = run(test, inputs) + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to eq( + 'Requested value for header authorization does not match the request sent. ' \ + 'Expected Bearer OTHER_TOKEN, received Bearer SAMPLE_TOKEN' + ) + end + + [5, {}, ['application/fhir+json']].each do |malformed_payload| + it "does not check the content type when the payload is not a string: #{malformed_payload.inspect}" do + subscription_resource['channel']['payload'] = malformed_payload + inputs = { subscription: subscription_resource.to_json } + result = run(test, inputs) + expect(result.result).to eq('pass') + end + end + + it 'skips requested header entries with a blank name' do + subscription_resource['channel']['header'] = ['', ': value', 'Authorization: Bearer SAMPLE_TOKEN'] + inputs = { subscription: subscription_resource.to_json } + result = run(test, inputs) + expect(result.result).to eq('pass') + end + + it 'passes when the Subscription has no header element' do + subscription_resource['channel'].delete('header') + inputs = { subscription: subscription_resource.to_json } + result = run(test, inputs) + expect(result.result).to eq('pass') + end + + it 'checks a header element that is a single string' do + subscription_resource['channel']['header'] = 'accept: application/fhir+json' + inputs = { subscription: subscription_resource.to_json } + result = run(test, inputs) + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to eq("Requested header not sent: accept: application/fhir+json\n") + end + + it 'ignores header entries that are not strings' do + subscription_resource['channel']['header'] = [42, { 'name' => 'value' }, 'Authorization: Bearer SAMPLE_TOKEN'] + inputs = { subscription: subscription_resource.to_json } + result = run(test, inputs) + expect(result.result).to eq('pass') + end + + it 'ignores a header element that is neither a list nor a string' do + subscription_resource['channel']['header'] = { 'Authorization' => 'Bearer SAMPLE_TOKEN' } + inputs = { subscription: subscription_resource.to_json } + result = run(test, inputs) + expect(result.result).to eq('pass') + end + + it 'does not error when the channel is not an object' do + subscription_resource['channel'] = 'rest-hook' + inputs = { subscription: subscription_resource.to_json } + result = run(test, inputs) + expect(result.result).to eq('pass') + end end describe 'Notification Header Verification Error Handling' do diff --git a/spec/subscriptions_test_kit/common/subscription_conformance_verification_spec.rb b/spec/subscriptions_test_kit/common/subscription_conformance_verification_spec.rb index 858d859..548212b 100644 --- a/spec/subscriptions_test_kit/common/subscription_conformance_verification_spec.rb +++ b/spec/subscriptions_test_kit/common/subscription_conformance_verification_spec.rb @@ -197,9 +197,11 @@ def entity_result_message(runnable) end input :subscription_resource, :access_token + output :checked_subscription run do - server_check_channel(JSON.parse(subscription_resource), access_token) + checked_subscription = server_check_channel(JSON.parse(subscription_resource), access_token) + output(checked_subscription: checked_subscription.to_json) no_error_verification('There were verification errors') end end @@ -209,6 +211,11 @@ def entity_result_message(runnable) Inferno::Repositories::Tests.new.insert(test) end + def checked_headers(result) + checked_subscription = JSON.parse(result.output_json).find { |o| o['name'] == 'checked_subscription' }['value'] + JSON.parse(checked_subscription)['channel']['header'] + end + it 'warns if subscription does not contain the correct channel endpoint' do allow(test).to receive(:suite).and_return(suite) @@ -222,6 +229,17 @@ def entity_result_message(runnable) ) end + it 'renders the channel endpoint warning as prose if the endpoint contains a newline' do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel']['endpoint'] = "https://incorrect-url.com\nhttps://other-url.com" + + run(test, subscription_resource: subscription_resource.to_json, access_token:) + message = entity_result_message(test) + expect(message).to match('The subscription url was changed from https://incorrect-url.com') + expect(message.lines.grep(/\A {4,}\S/)).to be_empty + end + it 'warns if subscription does not contain the correct channel payload' do allow(test).to receive(:suite).and_return(suite) @@ -254,7 +272,46 @@ def entity_result_message(runnable) ) end - it 'warns if subscription does not contain the correct channel header' do + it 'does not warn or change the header if it already has the correct Authorization header' do + allow(test).to receive(:suite).and_return(suite) + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + expect(result.result).to eq('pass') + expect(entity_result_message(test)).to_not match(/Authorization header entry/) + expect(checked_headers(result)).to eq(['Authorization: Bearer SAMPLE_TOKEN']) + end + + it 'accepts a correct Authorization header whose name has different casing' do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel']['header'] = ['authorization: Bearer SAMPLE_TOKEN'] + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + expect(result.result).to eq('pass') + expect(entity_result_message(test)).to_not match(/Authorization header entry/) + expect(checked_headers(result)).to eq(['authorization: Bearer SAMPLE_TOKEN']) + end + + it 'warns and adds the Authorization header if the subscription has no header' do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel'].delete('header') + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + expect(result.result).to eq('pass') + expect(entity_result_message(test)).to match( + /Tester-provided Subscription updated before use: Added the Authorization header entry in the `header`/ + ) + expect(entity_result_message(test)).to match( + /field with the Bearer token `SAMPLE_TOKEN` provided in the "Notification Access Token" input/ + ) + expect(checked_headers(result)).to eq(['Authorization: Bearer SAMPLE_TOKEN']) + end + + it 'warns and replaces an Authorization header with a different token' do allow(test).to receive(:suite).and_return(suite) subscription_resource['channel']['header'] = ['Authorization: Bearer INCORRECT_TOKEN'] @@ -263,8 +320,194 @@ def entity_result_message(runnable) access_token:) expect(result.result).to eq('pass') expect(entity_result_message(test)).to match( - /Added the Authorization header field with a Bearer token set to SAMPLE_TOKEN to the `header` field/ + /Tester-provided Subscription updated before use: Replaced the Authorization header entry in the `header`/ + ) + expect(entity_result_message(test)).to match( + /field with the Bearer token `SAMPLE_TOKEN` provided in the "Notification Access Token" input/ ) + expect(entity_result_message(test)).to_not include('INCORRECT_TOKEN') + expect(checked_headers(result)).to eq(['Authorization: Bearer SAMPLE_TOKEN']) + end + + it 'replaces an Authorization header regardless of the casing of its name' do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel']['header'] = ['AUTHORIZATION : Bearer INCORRECT_TOKEN'] + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + expect(result.result).to eq('pass') + expect(entity_result_message(test)).to match(/Replaced the Authorization header entry/) + expect(checked_headers(result)).to eq(['Authorization: Bearer SAMPLE_TOKEN']) + end + + it 'replaces every Authorization header and keeps other headers' do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel']['header'] = [ + 'Authorization: Bearer SAMPLE_TOKEN', + 'X-Custom-Header: value', + 'authorization: Bearer INCORRECT_TOKEN' + ] + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + expect(result.result).to eq('pass') + expect(entity_result_message(test)).to match(/Replaced the Authorization header entry/) + expect(checked_headers(result)).to eq(['X-Custom-Header: value', 'Authorization: Bearer SAMPLE_TOKEN']) + end + + [ + 'Authorization:Bearer SAMPLE_TOKEN', + 'Authorization: Bearer SAMPLE_TOKEN ', + 'Authorization : Bearer SAMPLE_TOKEN', + ' Authorization: Bearer SAMPLE_TOKEN' + ].each do |malformed_header| + it "replaces a malformed Authorization header with the correct token: '#{malformed_header}'" do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel']['header'] = [malformed_header] + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + expect(result.result).to eq('pass') + expect(entity_result_message(test)).to match(/Replaced the Authorization header entry/) + expect(checked_headers(result)).to eq(['Authorization: Bearer SAMPLE_TOKEN']) + end + end + + it 'replaces duplicate correct Authorization headers with a single entry' do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel']['header'] = ['Authorization: Bearer SAMPLE_TOKEN'] * 2 + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + expect(result.result).to eq('pass') + expect(entity_result_message(test)).to match(/Replaced the Authorization header entry/) + expect(checked_headers(result)).to eq(['Authorization: Bearer SAMPLE_TOKEN']) + end + + it 'replaces an Authorization header whose scheme Inferno does not recognize' do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel']['header'] = ['Authorization: bearer SAMPLE_TOKEN'] + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + expect(result.result).to eq('pass') + expect(entity_result_message(test)).to match(/Replaced the Authorization header entry/) + expect(checked_headers(result)).to eq(['Authorization: Bearer SAMPLE_TOKEN']) + end + + it 'ignores header entries that are not strings when looking for the Authorization header' do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel']['header'] = ['', 42, 'Authorization: Bearer SAMPLE_TOKEN'] + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + expect(result.result).to eq('pass') + expect(checked_headers(result)).to eq(['', 42, 'Authorization: Bearer SAMPLE_TOKEN']) + end + + it 'warns and converts a header string with the correct Authorization header into a list' do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel']['header'] = 'Authorization: Bearer SAMPLE_TOKEN' + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + expect(result.result).to eq('pass') + expect(entity_result_message(test)).to match( + /Tester-provided Subscription updated before use: incorrect string value for element `header` converted into/ + ) + expect(entity_result_message(test)).to_not match(/Authorization header entry/) + expect(checked_headers(result)).to eq(['Authorization: Bearer SAMPLE_TOKEN']) + end + + it 'converts a header string into a list and replaces an Authorization header with a different token' do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel']['header'] = 'Authorization: Bearer INCORRECT_TOKEN' + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + expect(result.result).to eq('pass') + expect(entity_result_message(test)).to match(/incorrect string value for element `header` converted into/) + expect(entity_result_message(test)).to match(/Replaced the Authorization header entry/) + expect(checked_headers(result)).to eq(['Authorization: Bearer SAMPLE_TOKEN']) + end + + it 'converts a header string into a list and keeps it when adding the Authorization header' do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel']['header'] = 'X-Custom-Header: value' + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + expect(result.result).to eq('pass') + expect(entity_result_message(test)).to match(/incorrect string value for element `header` converted into/) + expect(entity_result_message(test)).to match(/Added the Authorization header entry/) + expect(checked_headers(result)).to eq(['X-Custom-Header: value', 'Authorization: Bearer SAMPLE_TOKEN']) + end + + [{ 'Authorization' => 'Bearer SAMPLE_TOKEN' }, 42].each do |malformed_header| + it "fails and leaves the header unchanged if it is neither a list nor a string: #{malformed_header.inspect}" do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel']['header'] = malformed_header + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match( + /The `header` element on the tester-provided Subscription must be a list of strings\./ + ) + expect(checked_headers(result)).to eq(malformed_header) + end + end + + [nil, 'rest-hook', []].each do |malformed_channel| + it "returns the subscription unchanged if the channel is not an object: #{malformed_channel.inspect}" do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel'] = malformed_channel + subscription_resource.compact! + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + expect(result.result).to eq('pass') + expect(entity_result_message(test)).to be_empty + checked_subscription = JSON.parse(result.output_json).find { |o| o['name'] == 'checked_subscription' }['value'] + expect(JSON.parse(checked_subscription)).to eq(subscription_resource) + end + end + + it 'keeps headers whose names only contain Authorization' do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel']['header'] = ['X-Authorization: Bearer SAMPLE_TOKEN', 'Authorization-Info: value'] + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + expect(result.result).to eq('pass') + expect(entity_result_message(test)).to match(/Added the Authorization header entry/) + expect(checked_headers(result)).to eq( + ['X-Authorization: Bearer SAMPLE_TOKEN', 'Authorization-Info: value', 'Authorization: Bearer SAMPLE_TOKEN'] + ) + end + + it 'accepts a correct Authorization header whose token contains a colon' do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel']['header'] = ['Authorization: Bearer abc:def'] + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token: 'abc:def') + expect(result.result).to eq('pass') + expect(entity_result_message(test)).to_not match(/Authorization header entry/) + expect(checked_headers(result)).to eq(['Authorization: Bearer abc:def']) end end end diff --git a/spec/subscriptions_test_kit/subscription_r4_server/common/interaction/subscription_conformance_test_spec.rb b/spec/subscriptions_test_kit/subscription_r4_server/common/interaction/subscription_conformance_test_spec.rb index 219ee5c..bd50ef7 100644 --- a/spec/subscriptions_test_kit/subscription_r4_server/common/interaction/subscription_conformance_test_spec.rb +++ b/spec/subscriptions_test_kit/subscription_r4_server/common/interaction/subscription_conformance_test_spec.rb @@ -98,5 +98,35 @@ def no_custom_extensions_request?(request) expect(result.result).to eq('fail') expect(verification_request).to have_been_made end + + [nil, 'rest-hook', []].each do |malformed_channel| + it "fails if the channel is not an object: #{malformed_channel.inspect}" do + allow(test).to receive(:suite).and_return(suite) + + subscription_resource['channel'] = malformed_channel + subscription_resource.compact! + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('The Subscription must include a `channel` element.') + end + end + + it 'fails if the channel header is neither a list nor a string' do + allow(test).to receive(:suite).and_return(suite) + + verification_request = stub_request(:post, validation_url) + .to_return(status: 200, body: operation_outcome_success.to_json) + subscription_resource['channel']['header'] = { 'Authorization' => 'Bearer SAMPLE_TOKEN' } + + result = run(test, subscription_resource: subscription_resource.to_json, + access_token:) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Subscription resource is not conformant.') + expect(verification_request).to have_been_made + end end end diff --git a/spec/subscriptions_test_kit/subscription_r4_server/common/interaction_verification/notification_conformance_test_spec.rb b/spec/subscriptions_test_kit/subscription_r4_server/common/interaction_verification/notification_conformance_test_spec.rb index e1db2b7..5a64ab1 100644 --- a/spec/subscriptions_test_kit/subscription_r4_server/common/interaction_verification/notification_conformance_test_spec.rb +++ b/spec/subscriptions_test_kit/subscription_r4_server/common/interaction_verification/notification_conformance_test_spec.rb @@ -64,7 +64,8 @@ let(:access_token) { 'SAMPLE_TOKEN' } let(:subscription_id) { '123' } - def create_request(url: subscription_channel, direction: 'incoming', tags: nil, body: nil, status: 200, headers: nil) + def create_request(url: subscription_channel, direction: 'incoming', tags: nil, body: nil, status: 200, headers: nil, + response_body: body) headers ||= [ { type: 'request', @@ -84,7 +85,7 @@ def create_request(url: subscription_channel, direction: 'incoming', tags: nil, url:, result:, test_session_id: test_session.id, - response_body: body.is_a?(Hash) ? body.to_json : body, + response_body: response_body.is_a?(Hash) ? response_body.to_json : response_body, request_body: body.is_a?(Hash) ? body.to_json : body, status:, headers:, @@ -200,6 +201,84 @@ def create_request(url: subscription_channel, direction: 'incoming', tags: nil, expect(verification_request).to have_been_made end + it 'fails if a requested header is not sent and the server response has no header element' do + subscription_resource.dig('channel', 'header') << 'accept: application/fhir+json' + response_subscription = subscription_resource.deep_dup + response_subscription['channel'].delete('header') + verification_request = stub_request(:post, validation_url) + .to_return(status: 200, body: operation_outcome_success.to_json) + + create_request(url: server_endpoint, direction: 'outgoing', tags: ['subscription_creation', 'empty'], + body: subscription_resource, response_body: response_subscription, status: 201) + create_request(tags: ['event-notification', subscription_id], body: empty_notification_bundle) + result = run(test) + expect(result.result).to eq('fail') + messages = Inferno::Repositories::Messages.new.messages_for_result(result.id).map(&:message) + expect(messages).to include(a_string_including('Requested header not sent: accept: application/fhir+json')) + expect(verification_request).to have_been_made + end + + it 'does not check headers the server response adds that Inferno did not request' do + response_subscription = subscription_resource.deep_dup + response_subscription.dig('channel', 'header') << 'accept: application/fhir+json' + verification_request = stub_request(:post, validation_url) + .to_return(status: 200, body: operation_outcome_success.to_json) + + create_request(url: server_endpoint, direction: 'outgoing', tags: ['subscription_creation', 'empty'], + body: subscription_resource, response_body: response_subscription, status: 201) + create_request(tags: ['event-notification', subscription_id], body: empty_notification_bundle) + result = run(test) + expect(result.result).to eq('pass') + expect(verification_request).to have_been_made + end + + it 'passes if the notification sends the requested Authorization header the server response does not match' do + response_subscription = subscription_resource.deep_dup + response_subscription['channel']['header'] = ['Authorization: Bearer OTHER_TOKEN'] + verification_request = stub_request(:post, validation_url) + .to_return(status: 200, body: operation_outcome_success.to_json) + + create_request(url: server_endpoint, direction: 'outgoing', tags: ['subscription_creation', 'empty'], + body: subscription_resource, response_body: response_subscription, status: 201) + create_request(tags: ['event-notification', subscription_id], body: empty_notification_bundle) + result = run(test) + expect(result.result).to eq('pass') + expect(verification_request).to have_been_made + end + + it 'fails if the notification sends the Authorization header from the server response, not the requested one' do + response_subscription = subscription_resource.deep_dup + response_subscription['channel']['header'] = ['Authorization: Bearer OTHER_TOKEN'] + verification_request = stub_request(:post, validation_url) + .to_return(status: 200, body: operation_outcome_success.to_json) + notification_headers = [ + { type: 'request', name: 'Authorization', value: 'Bearer OTHER_TOKEN' }, + { type: 'request', name: 'content-type', value: 'application/fhir+json' } + ] + + create_request(url: server_endpoint, direction: 'outgoing', tags: ['subscription_creation', 'empty'], + body: subscription_resource, response_body: response_subscription, status: 201) + create_request(tags: ['event-notification', subscription_id], body: empty_notification_bundle, + headers: notification_headers) + result = run(test) + expect(result.result).to eq('fail') + messages = Inferno::Repositories::Messages.new.messages_for_result(result.id).map(&:message) + expect(messages).to include( + a_string_including('Requested value for header authorization does not match the request sent. ' \ + "Expected Bearer #{access_token}, received Bearer OTHER_TOKEN") + ) + expect(verification_request).to have_been_made + end + + it 'fails if the server returned a Subscription body that is not a JSON object' do + create_request(url: server_endpoint, direction: 'outgoing', tags: ['subscription_creation', 'empty'], + body: subscription_resource, response_body: '[1]', status: 201) + create_request(tags: ['event-notification', subscription_id], body: empty_notification_bundle) + result = run(test) + expect(result.result).to eq('fail') + expect(result.result_message).to match(/The Subscription the server returned is not a JSON object/) + end + it 'fails if wrong mime type is sent' do subscription_resource['channel']['payload'] = 'application/json' verification_request = stub_request(:post, validation_url)