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/docs/subscriptions_r5_backport_r4_client_suite_description.md b/lib/subscriptions_test_kit/docs/subscriptions_r5_backport_r4_client_suite_description.md index 95043f6..82a0f35 100644 --- a/lib/subscriptions_test_kit/docs/subscriptions_r5_backport_r4_client_suite_description.md +++ b/lib/subscriptions_test_kit/docs/subscriptions_r5_backport_r4_client_suite_description.md @@ -34,6 +34,13 @@ responses, to send and respond with as directed by the IG. Inferno will verify t provided event notification is conformant and that it matches the requirements for the Subscription sent by the client system. +Inferno responds to the `$status` operation via GET or POST, both on the Subscription +instance (`Subscription/[id]/$status`) and on the type (`Subscription/$status`). As the +IG specifies, the `id` and `status` inputs are ignored on the instance. On the type, +Inferno returns the status of the Subscription created by the client system if at least +one value of each input given matches it, and an empty Bundle otherwise. Inputs sent in a +`Parameters` body must use `valueId` and `valueCode`. + The content of all Subscription interactions sent by the client, as well as tester-provided notifications, will be checked for conformance to the Subscriptions IG requirements individually and used in diff --git a/lib/subscriptions_test_kit/endpoints/subscription_status_endpoint.rb b/lib/subscriptions_test_kit/endpoints/subscription_status_endpoint.rb index 1ddca5d..f36a91d 100644 --- a/lib/subscriptions_test_kit/endpoints/subscription_status_endpoint.rb +++ b/lib/subscriptions_test_kit/endpoints/subscription_status_endpoint.rb @@ -2,9 +2,13 @@ require_relative '../suites/subscriptions_r5_backport_r4_client/common/subscription_simulation_utils' module SubscriptionsTestKit + # Handles $status invoked on a Subscription instance (`Subscription/[id]/$status`) or on the type + # (`Subscription/$status`). The `id` and `status` inputs are ignored at the instance level. class SubscriptionStatusEndpoint < Inferno::DSL::SuiteEndpoint include SubscriptionsR5BackportR4Client::SubscriptionSimulationUtils + INPUT_VALUE_TYPES = { 'id' => 'valueId', 'status' => 'valueCode' }.freeze + error_response_format :operation_outcome def test_run_identifier @@ -13,55 +17,116 @@ def test_run_identifier def make_response response.format = :json - subscription = find_current_subscription(test_run.test_session_id) + type_level? ? make_type_response : make_instance_response + end - subscription_id = request.params[:id] - if subscription.blank? || (subscription_id.present? && subscription_id != subscription.id) + def make_instance_response + subscription = find_current_subscription(test_run.test_session_id) + if subscription.blank? || request.params[:id] != subscription.id fhir_error_response(404, 'not-found') return end - status_code = current_subscription_status_code(subscription) - - # Handle resource-level status params - unless subscription_id.present? - begin - params = FHIR.from_contents(request.body.string) - rescue StandardError - fhir_error_response(400, 'invalid', 'Invalid Parameters in request body') - return - end - - unless subscription_params_match?(params, subscription, status_code) - fhir_error_response(404, 'not-found') - return - end + respond_with_status(subscription) + end + + # Search semantics: no matching Subscription yields an empty searchset Bundle + def make_type_response + inputs = request.get? ? query_inputs : body_inputs + return if inputs.nil? + + subscription = find_current_subscription(test_run.test_session_id) + if subscription.present? && inputs_match?(inputs, subscription) + respond_with_status(subscription) + return end + response.status = 200 + response.body = empty_status_bundle(request.url).to_json + end + + # Repeated values of an input are joined via OR + def inputs_match?(inputs, subscription) + (inputs['id'].empty? || inputs['id'].include?(subscription.id)) && + (inputs['status'].empty? || inputs['status'].include?(current_subscription_status_code(subscription))) + end + + def respond_with_status(subscription) notification_json = notification_bundle_input(result) subscription_url = "#{base_subscription_url}/#{subscription.id}" subscription_topic = subscription.criteria event_count = determine_event_count(test_run.test_session_id) response.status = 200 - response.body = derive_status_bundle(notification_json, subscription_url, subscription_topic, status_code, - event_count, request.url).to_json + response.body = derive_status_bundle(notification_json, subscription_url, subscription_topic, + current_subscription_status_code(subscription), event_count, + request.url).to_json end - def subscription_params_match?(params, subscription, status_code) - id_params = find_params(params, 'id') + def type_level? + request.path.end_with?(FHIR_SUBSCRIPTION_RESOURCE_STATUS_PATH) + end + + # request.params keeps only the last value of a repeated query parameter. + # Renders an error response and returns nil if any input is given without a value. + def query_inputs + query = ::Rack::Utils.parse_query(request.query_string) + inputs = INPUT_VALUE_TYPES.keys.to_h { |name| [name, query.key?(name) ? [query[name]].flatten : []] } - return false if id_params&.any? && id_params&.none? { |p| p.valueString == subscription.id } + blank_inputs = inputs.select { |_name, values| values.any?(&:blank?) }.keys + return inputs if blank_inputs.empty? - status_params = find_params(params, 'status') - status_params.blank? || status_params.any? { |p| p.valueString == status_code } + fhir_error_response(400, 'invalid', blank_inputs.map do |name| + "The `#{name}` query parameter must have a value." + end.join(' ')) + nil end - def tags - [SUBSCRIPTION_STATUS_TAG] + # Renders an error response and returns nil if the body is not a Parameters resource with correctly typed inputs + def body_inputs + params = parse_parameters_body + return if params.nil? + + type_errors = input_type_errors(params) + if type_errors.any? + fhir_error_response(400, 'invalid', type_errors.join(' ')) + return + end + + INPUT_VALUE_TYPES.to_h do |name, value_type| + [name, find_all_parameters(params, name).map { |p| p.public_send(value_type) }] + end + end + + def input_type_errors(params) + INPUT_VALUE_TYPES.filter_map do |name, value_type| + next if find_all_parameters(params, name).all? { |p| valid_input_value?(p, value_type) } + + "The `#{name}` parameter must have a non-empty `#{value_type}` and no other value." + end end - def find_params(params, name) - params&.parameter&.filter { |p| p.name == name } + def valid_input_value?(param, value_type) + value = param.public_send(value_type) + value.is_a?(String) && value.present? && + param.to_hash.keys.grep(/\Avalue[A-Z]/) == [value_type] + end + + def parse_parameters_body + return FHIR::Parameters.new if request.body.string.blank? + + params = begin + FHIR.from_contents(request.body.string) + rescue StandardError + nil + end + return params if params.is_a?(FHIR::Parameters) + + fhir_error_response(400, 'invalid', 'Invalid Parameters in request body') + nil + end + + def tags + [SUBSCRIPTION_STATUS_TAG] end def base_subscription_url diff --git a/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client/common/subscription_simulation_utils.rb b/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client/common/subscription_simulation_utils.rb index d05e8bd..4c762de 100644 --- a/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client/common/subscription_simulation_utils.rb +++ b/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client/common/subscription_simulation_utils.rb @@ -64,6 +64,15 @@ def derive_status_bundle(notification_json, subscription_url, subscription_topic ) end + def empty_status_bundle(request_url) + FHIR::Bundle.new( + link: FHIR::Bundle::Link.new(relation: 'self', url: request_url), + total: 0, + type: 'searchset', + timestamp: Time.now.utc.iso8601 + ) + end + def operation_outcome(severity, code, text = nil) oo = FHIR::OperationOutcome.new(issue: FHIR::OperationOutcome::Issue.new(severity:, code:)) oo.issue.first.details = FHIR::CodeableConcept.new(text:) if text.present? diff --git a/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client_suite.rb b/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client_suite.rb index 90d47ef..dab4a25 100644 --- a/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client_suite.rb +++ b/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client_suite.rb @@ -85,6 +85,7 @@ class SubscriptionsR5BackportR4ClientSuite < Inferno::TestSuite suite_endpoint :post, FHIR_SUBSCRIPTION_INSTANCE_STATUS_PATH, SubscriptionStatusEndpoint suite_endpoint :get, FHIR_SUBSCRIPTION_INSTANCE_STATUS_PATH, SubscriptionStatusEndpoint suite_endpoint :post, FHIR_SUBSCRIPTION_RESOURCE_STATUS_PATH, SubscriptionStatusEndpoint + suite_endpoint :get, FHIR_SUBSCRIPTION_RESOURCE_STATUS_PATH, SubscriptionStatusEndpoint resume_test_route :get, RESUME_PASS_PATH do |request| request.query_parameters['test_run_identifier'] diff --git a/spec/subscriptions_test_kit/endpoints/error_response_format_spec.rb b/spec/subscriptions_test_kit/endpoints/error_response_format_spec.rb index 8a84ebc..7d402a8 100644 --- a/spec/subscriptions_test_kit/endpoints/error_response_format_spec.rb +++ b/spec/subscriptions_test_kit/endpoints/error_response_format_spec.rb @@ -11,6 +11,7 @@ [:patch, '/123'], [:delete, '/123'], [:get, '/123/$status'], + [:get, '/$status'], [:post, '/123/$status'], [:post, '/$status'] ].each do |verb, path| diff --git a/spec/subscriptions_test_kit/endpoints/subscription_status_endpoint_spec.rb b/spec/subscriptions_test_kit/endpoints/subscription_status_endpoint_spec.rb new file mode 100644 index 0000000..e75d3e9 --- /dev/null +++ b/spec/subscriptions_test_kit/endpoints/subscription_status_endpoint_spec.rb @@ -0,0 +1,240 @@ +RSpec.describe SubscriptionsTestKit::SubscriptionStatusEndpoint, :request, :runnable do + let(:suite_id) { 'subscriptions_r5_backport_r4_client' } + let(:test) do + find_test suite, SubscriptionsTestKit::SubscriptionsR5BackportR4Client::UpdateDeleteInteractionTest.id + end + let(:access_token) { '1234' } + let(:subscription_id) { 'abc-123' } + let(:type_status_url) { "/custom/#{suite_id}/fhir/Subscription/$status" } + let(:instance_status_url) { "/custom/#{suite_id}/fhir/Subscription/#{subscription_id}/$status" } + let(:subscription) do + subscription = JSON.parse(File.read(File.join(__dir__, '../..', 'fixtures', 'subscription_resource_example.json'))) + subscription['channel']['extension'].reject! { |e| e['url'].end_with?('backport-heartbeat-period') } + subscription.merge('id' => subscription_id) + end + let(:notification_json) do + File.read(File.join(__dir__, '../..', 'fixtures', 'empty_notification_bundle_example.json')) + end + + def post_parameters(url, *parameters) + header 'Content-Type', 'application/fhir+json' + post(url, FHIR::Parameters.new(parameter: parameters.map { |p| FHIR::Parameters::Parameter.new(p) }).to_json) + end + + def response_resource + FHIR.from_contents(last_response.body) + end + + def expect_current_subscription_status + expect(last_response.status).to eq(200) + bundle = response_resource + expect(bundle.type).to eq('searchset') + parameters = bundle.entry.first.resource.parameter + expect(parameters.find { |p| p.name == 'status' }.valueCode).to eq('requested') + expect(parameters.find { |p| p.name == 'subscription' }.valueReference.reference) + .to end_with("Subscription/#{subscription_id}") + end + + def expect_empty_status_bundle + expect(last_response.status).to eq(200) + bundle = response_resource + expect(bundle.type).to eq('searchset') + expect(bundle.total).to eq(0) + expect(bundle.entry).to be_empty + end + + def expect_error(status, code, text = nil) + expect(last_response.status).to eq(status) + issue = response_resource.issue.first + expect(issue.code).to eq(code) + expect(issue.details.text).to eq(text) if text + end + + before do + repo_create( + :request, + verb: 'POST', + url: "http://example.com/custom/#{suite_id}/fhir/Subscription", + direction: 'incoming', + status: 201, + request_body: subscription.except('id').to_json, + response_body: subscription.to_json, + result: repo_create(:result, test_session_id: test_session.id), + test_session_id: test_session.id, + tags: [SubscriptionsTestKit::SUBSCRIPTION_CREATE_TAG] + ) + session_data_repo.save(test_session_id: test_session.id, name: 'notification_bundle', + value: notification_json, type: 'textarea') + run(test, access_token:) + header 'Authorization', "Bearer #{access_token}" + end + + describe 'on the type' do + describe 'GET' do + it 'returns the status of the current Subscription when no inputs are given' do + get(type_status_url) + + expect_current_subscription_status + end + + # Matching values come first: request.params would keep only the last, non-matching value + it 'returns the status when repeated id and status query parameters include a match' do + get("#{type_status_url}?id=#{subscription_id}&id=other&status=requested&status=active") + + expect_current_subscription_status + end + + it 'returns an empty Bundle when the id query parameter does not match' do + get("#{type_status_url}?id=other") + + expect_empty_status_bundle + end + + it 'returns an empty Bundle when the status query parameter does not match' do + get("#{type_status_url}?status=active") + + expect_empty_status_bundle + end + + it 'rejects query parameters without a value' do + get("#{type_status_url}?id=#{subscription_id}&id=&status") + + expect_error(400, 'invalid', 'The `id` query parameter must have a value. ' \ + 'The `status` query parameter must have a value.') + end + end + + describe 'POST' do + it 'returns the status when valueId and valueCode inputs match' do + post_parameters(type_status_url, { name: 'id', valueId: subscription_id }, + { name: 'status', valueCode: 'requested' }) + + expect_current_subscription_status + end + + it 'returns the status when the body is empty' do + post(type_status_url) + + expect_current_subscription_status + end + + it 'returns an empty Bundle when the valueId input does not match' do + post_parameters(type_status_url, { name: 'id', valueId: 'other' }) + + expect_empty_status_bundle + end + + it 'returns an empty Bundle when the valueCode input does not match' do + post_parameters(type_status_url, { name: 'status', valueCode: 'active' }) + + expect_empty_status_bundle + end + + it 'rejects inputs sent as valueString' do + post_parameters(type_status_url, { name: 'id', valueString: subscription_id }, + { name: 'status', valueString: 'requested' }) + + expect_error(400, 'invalid', 'The `id` parameter must have a non-empty `valueId` and no other value. ' \ + 'The `status` parameter must have a non-empty `valueCode` and no other value.') + end + + it 'names only the mistyped input' do + post_parameters(type_status_url, { name: 'id', valueId: subscription_id }, + { name: 'id', valueString: 'other' }, + { name: 'status', valueCode: 'requested' }) + + expect_error(400, 'invalid', 'The `id` parameter must have a non-empty `valueId` and no other value.') + end + + it 'rejects an empty valueId' do + header 'Content-Type', 'application/fhir+json' + post(type_status_url, { resourceType: 'Parameters', parameter: [{ name: 'id', valueId: '' }] }.to_json) + + expect_error(400, 'invalid', 'The `id` parameter must have a non-empty `valueId` and no other value.') + end + + it 'rejects a valueId that is not a string' do + header 'Content-Type', 'application/fhir+json' + post(type_status_url, { resourceType: 'Parameters', parameter: [{ name: 'id', valueId: 123 }] }.to_json) + + expect_error(400, 'invalid', 'The `id` parameter must have a non-empty `valueId` and no other value.') + end + + it 'rejects an input with a second value' do + post_parameters(type_status_url, { name: 'id', valueId: subscription_id, valueString: 'other' }) + + expect_error(400, 'invalid', 'The `id` parameter must have a non-empty `valueId` and no other value.') + end + + it 'rejects a body that is not a Parameters resource' do + header 'Content-Type', 'application/fhir+json' + post(type_status_url, FHIR::Bundle.new(type: 'collection').to_json) + + expect_error(400, 'invalid', 'Invalid Parameters in request body') + end + + it 'rejects a body that cannot be parsed' do + header 'Content-Type', 'application/fhir+xml' + post(type_status_url, '