Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:

jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
strategy:
matrix:
ruby-version: ['3.3.6']
Expand All @@ -23,7 +23,7 @@ jobs:

test:
needs: build
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
strategy:
matrix:
ruby-version: ['3.3.6']
Expand All @@ -39,7 +39,7 @@ jobs:

lint:
needs: build
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
strategy:
matrix:
ruby-version: ['3.3.6']
Expand All @@ -55,7 +55,7 @@ jobs:

requirements:
needs: build
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
strategy:
matrix:
ruby-version: ['3.3.6']
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/run_inferno_execution_scripts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?, %(
Expand All @@ -84,7 +86,8 @@ def subscription_type
)
notification_header_verification(
request.request_headers,
subscription
subscription,
requested_subscription:
)
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading