From 2e8337003659e404b4ab760aecbb0e90c99fdf79 Mon Sep 17 00:00:00 2001 From: Lucas Tucker Date: Mon, 19 Oct 2026 16:51:40 -0400 Subject: [PATCH 1/5] ID-278: support GET and typed inputs on the client suite $status operation The simulated server now accepts the Subscription $status operation via GET at the type level, reads POST inputs from valueId and valueCode, and rejects inputs sent with any other value type with a 400 OperationOutcome. The status input filter now applies to instance-level invocations too. Co-Authored-By: Claude Opus 5.5 --- ...r5_backport_r4_client_suite_description.md | 5 + .../endpoints/subscription_status_endpoint.rb | 83 ++++++--- .../subscription_type_status_endpoint.rb | 12 ++ ...bscriptions_r5_backport_r4_client_suite.rb | 4 +- .../endpoints/error_response_format_spec.rb | 1 + .../subscription_status_endpoint_spec.rb | 171 ++++++++++++++++++ 6 files changed, 252 insertions(+), 24 deletions(-) create mode 100644 lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb create mode 100644 spec/subscriptions_test_kit/endpoints/subscription_status_endpoint_spec.rb 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..728ff96 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,11 @@ 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`). Any `id` and +`status` inputs must match the Subscription created by the client system, and inputs sent in a +`Parameters` body must use `valueId` and `valueCode` respectively. + 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..608e56d 100644 --- a/lib/subscriptions_test_kit/endpoints/subscription_status_endpoint.rb +++ b/lib/subscriptions_test_kit/endpoints/subscription_status_endpoint.rb @@ -2,9 +2,12 @@ require_relative '../suites/subscriptions_r5_backport_r4_client/common/subscription_simulation_utils' module SubscriptionsTestKit + # Handles $status invoked on a Subscription instance; SubscriptionTypeStatusEndpoint handles the type-level invocation 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 @@ -14,28 +17,18 @@ def test_run_identifier def make_response response.format = :json subscription = find_current_subscription(test_run.test_session_id) - - subscription_id = request.params[:id] - if subscription.blank? || (subscription_id.present? && subscription_id != subscription.id) + if subscription.blank? fhir_error_response(404, 'not-found') return end - status_code = current_subscription_status_code(subscription) + inputs = request.get? ? query_inputs : body_inputs + return if inputs.nil? - # 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 + status_code = current_subscription_status_code(subscription) + unless subscription_matches?(inputs, subscription, status_code) + fhir_error_response(404, 'not-found') + return end notification_json = notification_bundle_input(result) @@ -47,13 +40,57 @@ def make_response event_count, request.url).to_json end - def subscription_params_match?(params, subscription, status_code) - id_params = find_params(params, 'id') + def requested_ids(_inputs) + [request.params[:id]] + end + + def subscription_matches?(inputs, subscription, status_code) + ids = requested_ids(inputs) + (ids.empty? || ids.include?(subscription.id)) && + (inputs['status'].empty? || inputs['status'].include?(status_code)) + end + + # request.params keeps only the last value of a repeated query parameter + def query_inputs + query = ::Rack::Utils.parse_query(request.query_string) + INPUT_VALUE_TYPES.keys.to_h { |name| [name, Array(query[name])] } + end + + # 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_params(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_params(params, name).all? { |p| p.public_send(value_type).present? } + + "The `#{name}` parameter must be sent as `#{value_type}`." + end + end + + def parse_parameters_body + return FHIR::Parameters.new if request.body.string.blank? - return false if id_params&.any? && id_params&.none? { |p| p.valueString == subscription.id } + params = FHIR.from_contents(request.body.string) + return params if params.is_a?(FHIR::Parameters) - status_params = find_params(params, 'status') - status_params.blank? || status_params.any? { |p| p.valueString == status_code } + fhir_error_response(400, 'invalid', 'Invalid Parameters in request body') + nil + rescue StandardError + fhir_error_response(400, 'invalid', 'Invalid Parameters in request body') + nil end def tags @@ -61,7 +98,7 @@ def tags end def find_params(params, name) - params&.parameter&.filter { |p| p.name == name } + params.parameter.filter { |p| p.name == name } end def base_subscription_url diff --git a/lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb b/lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb new file mode 100644 index 0000000..29ab993 --- /dev/null +++ b/lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb @@ -0,0 +1,12 @@ +require_relative 'subscription_status_endpoint' + +module SubscriptionsTestKit + class SubscriptionTypeStatusEndpoint < SubscriptionStatusEndpoint + # Not inherited: SuiteEndpoint stores the format per class + error_response_format :operation_outcome + + def requested_ids(inputs) + inputs['id'] + end + end +end 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..d0ca3a1 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 @@ -9,6 +9,7 @@ require_relative '../endpoints/subscription_patch_endpoint' require_relative '../endpoints/subscription_delete_endpoint' require_relative '../endpoints/subscription_status_endpoint' +require_relative '../endpoints/subscription_type_status_endpoint' module SubscriptionsTestKit module SubscriptionsR5BackportR4Client @@ -84,7 +85,8 @@ class SubscriptionsR5BackportR4ClientSuite < Inferno::TestSuite suite_endpoint :delete, FHIR_SUBSCRIPTION_INSTANCE_PATH, SubscriptionDeleteEndpoint 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 :post, FHIR_SUBSCRIPTION_RESOURCE_STATUS_PATH, SubscriptionTypeStatusEndpoint + suite_endpoint :get, FHIR_SUBSCRIPTION_RESOURCE_STATUS_PATH, SubscriptionTypeStatusEndpoint 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..fd79aa9 --- /dev/null +++ b/spec/subscriptions_test_kit/endpoints/subscription_status_endpoint_spec.rb @@ -0,0 +1,171 @@ +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 returned_subscription_status + FHIR.from_contents(last_response.body).entry.first.resource + end + + def error_text + FHIR.from_contents(last_response.body).issue.first.details.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 'type-level GET' do + it 'returns the status of the current Subscription when no inputs are given' do + get(type_status_url) + + expect(last_response.status).to eq(200) + expect(returned_subscription_status).to be_a(FHIR::Parameters) + end + + it 'returns the status when repeated id and status query parameters match' do + get("#{type_status_url}?id=other&id=#{subscription_id}&status=requested") + + expect(last_response.status).to eq(200) + expect(returned_subscription_status).to be_a(FHIR::Parameters) + end + + it 'returns not-found when the id query parameter does not match' do + get("#{type_status_url}?id=other") + + expect(last_response.status).to eq(404) + end + + it 'returns not-found when the status query parameter does not match' do + get("#{type_status_url}?status=active") + + expect(last_response.status).to eq(404) + end + end + + describe 'type-level 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(last_response.status).to eq(200) + expect(returned_subscription_status).to be_a(FHIR::Parameters) + end + + it 'returns the status when the body is empty' do + post(type_status_url) + + expect(last_response.status).to eq(200) + end + + it 'returns not-found when the valueId input does not match' do + post_parameters(type_status_url, { name: 'id', valueId: 'other' }) + + expect(last_response.status).to eq(404) + end + + it 'returns not-found when the valueCode input does not match' do + post_parameters(type_status_url, { name: 'status', valueCode: 'active' }) + + expect(last_response.status).to eq(404) + end + + it 'rejects inputs sent as valueString' do + post_parameters(type_status_url, { name: 'id', valueString: subscription_id }, + { name: 'status', valueString: 'requested' }) + + expect(last_response.status).to eq(400) + expect(FHIR.from_contents(last_response.body).issue.first.code).to eq('invalid') + expect(error_text).to include('The `id` parameter must be sent as `valueId`.') + expect(error_text).to include('The `status` parameter must be sent as `valueCode`.') + 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(last_response.status).to eq(400) + expect(error_text).to eq('The `id` parameter must be sent as `valueId`.') + 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(last_response.status).to eq(400) + end + end + + describe 'instance-level' do + it 'responds to GET' do + get(instance_status_url) + + expect(last_response.status).to eq(200) + end + + it 'responds to POST with an empty body' do + post(instance_status_url) + + expect(last_response.status).to eq(200) + end + + it 'uses the id from the url rather than an id query parameter' do + get("/custom/#{suite_id}/fhir/Subscription/other/$status?id=#{subscription_id}") + + expect(last_response.status).to eq(404) + end + + it 'returns not-found when the status query parameter does not match' do + get("#{instance_status_url}?status=active") + + expect(last_response.status).to eq(404) + end + + it 'returns not-found when the valueCode input does not match' do + post_parameters(instance_status_url, { name: 'status', valueCode: 'active' }) + + expect(last_response.status).to eq(404) + end + + it 'rejects inputs sent as valueString' do + post_parameters(instance_status_url, { name: 'status', valueString: 'requested' }) + + expect(last_response.status).to eq(400) + expect(error_text).to eq('The `status` parameter must be sent as `valueCode`.') + end + end +end From 610cb2a84b383f71139b5ef4e66674ee5d4d2a66 Mon Sep 17 00:00:00 2001 From: Lucas Tucker Date: Mon, 19 Oct 2026 17:07:16 -0400 Subject: [PATCH 2/5] ID-278: ignore $status inputs at the instance level The IG says the id and status inputs are ignored when $status is invoked on a Subscription instance, so only the type-level endpoint reads and validates them. Specs now check the returned status Bundle and error codes. Co-Authored-By: Claude Opus 5.5 --- ...r5_backport_r4_client_suite_description.md | 6 +- .../endpoints/subscription_status_endpoint.rb | 70 +------ .../subscription_type_status_endpoint.rb | 61 ++++++- .../subscription_status_endpoint_spec.rb | 171 ++++++++++-------- 4 files changed, 163 insertions(+), 145 deletions(-) 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 728ff96..5a804e4 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 @@ -35,9 +35,9 @@ provided event notification is conformant and that it matches the requirements f 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`). Any `id` and -`status` inputs must match the Subscription created by the client system, and inputs sent in a -`Parameters` body must use `valueId` and `valueCode` respectively. +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, they must match the Subscription +created by the client system, and 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 diff --git a/lib/subscriptions_test_kit/endpoints/subscription_status_endpoint.rb b/lib/subscriptions_test_kit/endpoints/subscription_status_endpoint.rb index 608e56d..7f73994 100644 --- a/lib/subscriptions_test_kit/endpoints/subscription_status_endpoint.rb +++ b/lib/subscriptions_test_kit/endpoints/subscription_status_endpoint.rb @@ -6,8 +6,6 @@ module SubscriptionsTestKit 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 @@ -22,14 +20,8 @@ def make_response return end - inputs = request.get? ? query_inputs : body_inputs - return if inputs.nil? - status_code = current_subscription_status_code(subscription) - unless subscription_matches?(inputs, subscription, status_code) - fhir_error_response(404, 'not-found') - return - end + return unless requested_subscription?(subscription, status_code) notification_json = notification_bundle_input(result) subscription_url = "#{base_subscription_url}/#{subscription.id}" @@ -40,67 +32,19 @@ def make_response event_count, request.url).to_json end - def requested_ids(_inputs) - [request.params[:id]] - end + # Renders an error response and returns false if the request does not select the current Subscription. + # The operation's `id` and `status` inputs are ignored at the instance level. + def requested_subscription?(subscription, _status_code) + return true if request.params[:id] == subscription.id - def subscription_matches?(inputs, subscription, status_code) - ids = requested_ids(inputs) - (ids.empty? || ids.include?(subscription.id)) && - (inputs['status'].empty? || inputs['status'].include?(status_code)) - end - - # request.params keeps only the last value of a repeated query parameter - def query_inputs - query = ::Rack::Utils.parse_query(request.query_string) - INPUT_VALUE_TYPES.keys.to_h { |name| [name, Array(query[name])] } - end - - # 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_params(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_params(params, name).all? { |p| p.public_send(value_type).present? } - - "The `#{name}` parameter must be sent as `#{value_type}`." - end - end - - def parse_parameters_body - return FHIR::Parameters.new if request.body.string.blank? - - params = FHIR.from_contents(request.body.string) - return params if params.is_a?(FHIR::Parameters) - - fhir_error_response(400, 'invalid', 'Invalid Parameters in request body') - nil - rescue StandardError - fhir_error_response(400, 'invalid', 'Invalid Parameters in request body') - nil + fhir_error_response(404, 'not-found') + false end def tags [SUBSCRIPTION_STATUS_TAG] end - def find_params(params, name) - params.parameter.filter { |p| p.name == name } - end - def base_subscription_url request.url.sub(/(#{Regexp.escape(FHIR_SUBSCRIPTION_PATH)}).*/, '\1') end diff --git a/lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb b/lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb index 29ab993..575d055 100644 --- a/lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb +++ b/lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb @@ -2,11 +2,68 @@ module SubscriptionsTestKit class SubscriptionTypeStatusEndpoint < SubscriptionStatusEndpoint + INPUT_VALUE_TYPES = { 'id' => 'valueId', 'status' => 'valueCode' }.freeze + # Not inherited: SuiteEndpoint stores the format per class error_response_format :operation_outcome - def requested_ids(inputs) - inputs['id'] + def requested_subscription?(subscription, status_code) + inputs = request.get? ? query_inputs : body_inputs + return false if inputs.nil? + + return true if (inputs['id'].empty? || inputs['id'].include?(subscription.id)) && + (inputs['status'].empty? || inputs['status'].include?(status_code)) + + fhir_error_response(404, 'not-found') + false + end + + # request.params keeps only the last value of a repeated query parameter + def query_inputs + query = ::Rack::Utils.parse_query(request.query_string) + INPUT_VALUE_TYPES.keys.to_h { |name| [name, Array(query[name])] } + end + + # 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_params(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_params(params, name).all? { |p| p.public_send(value_type).present? } + + "The `#{name}` parameter must have a non-empty `#{value_type}`." + end + 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 find_params(params, name) + params.parameter.filter { |p| p.name == name } end end end diff --git a/spec/subscriptions_test_kit/endpoints/subscription_status_endpoint_spec.rb b/spec/subscriptions_test_kit/endpoints/subscription_status_endpoint_spec.rb index fd79aa9..0734fe0 100644 --- a/spec/subscriptions_test_kit/endpoints/subscription_status_endpoint_spec.rb +++ b/spec/subscriptions_test_kit/endpoints/subscription_status_endpoint_spec.rb @@ -1,4 +1,4 @@ -RSpec.describe SubscriptionsTestKit::SubscriptionStatusEndpoint, :request, :runnable do +RSpec.describe 'Subscription $status endpoints', :request, :runnable do # rubocop:disable RSpec/DescribeClass let(:suite_id) { 'subscriptions_r5_backport_r4_client' } let(:test) do find_test suite, SubscriptionsTestKit::SubscriptionsR5BackportR4Client::UpdateDeleteInteractionTest.id @@ -21,12 +21,25 @@ def post_parameters(url, *parameters) post(url, FHIR::Parameters.new(parameter: parameters.map { |p| FHIR::Parameters::Parameter.new(p) }).to_json) end - def returned_subscription_status - FHIR.from_contents(last_response.body).entry.first.resource + def response_resource + FHIR.from_contents(last_response.body) end - def error_text - FHIR.from_contents(last_response.body).issue.first.details.text + 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_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 @@ -48,124 +61,128 @@ def error_text header 'Authorization', "Bearer #{access_token}" end - describe 'type-level GET' do - it 'returns the status of the current Subscription when no inputs are given' do - get(type_status_url) + describe SubscriptionsTestKit::SubscriptionTypeStatusEndpoint do + describe 'GET' do + it 'returns the status of the current Subscription when no inputs are given' do + get(type_status_url) - expect(last_response.status).to eq(200) - expect(returned_subscription_status).to be_a(FHIR::Parameters) - end + expect_current_subscription_status + end - it 'returns the status when repeated id and status query parameters match' do - get("#{type_status_url}?id=other&id=#{subscription_id}&status=requested") + it 'returns the status when repeated id and status query parameters include a match' do + get("#{type_status_url}?id=other&id=#{subscription_id}&status=active&status=requested") - expect(last_response.status).to eq(200) - expect(returned_subscription_status).to be_a(FHIR::Parameters) - end + expect_current_subscription_status + end - it 'returns not-found when the id query parameter does not match' do - get("#{type_status_url}?id=other") + it 'returns not-found when the id query parameter does not match' do + get("#{type_status_url}?id=other") - expect(last_response.status).to eq(404) - end + expect_error(404, 'not-found') + end - it 'returns not-found when the status query parameter does not match' do - get("#{type_status_url}?status=active") + it 'returns not-found when the status query parameter does not match' do + get("#{type_status_url}?status=active") - expect(last_response.status).to eq(404) + expect_error(404, 'not-found') + end end - end - describe 'type-level 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' }) + 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(last_response.status).to eq(200) - expect(returned_subscription_status).to be_a(FHIR::Parameters) - end + expect_current_subscription_status + end - it 'returns the status when the body is empty' do - post(type_status_url) + it 'returns the status when the body is empty' do + post(type_status_url) - expect(last_response.status).to eq(200) - end + expect_current_subscription_status + end - it 'returns not-found when the valueId input does not match' do - post_parameters(type_status_url, { name: 'id', valueId: 'other' }) + it 'returns not-found when the valueId input does not match' do + post_parameters(type_status_url, { name: 'id', valueId: 'other' }) - expect(last_response.status).to eq(404) - end + expect_error(404, 'not-found') + end - it 'returns not-found when the valueCode input does not match' do - post_parameters(type_status_url, { name: 'status', valueCode: 'active' }) + it 'returns not-found when the valueCode input does not match' do + post_parameters(type_status_url, { name: 'status', valueCode: 'active' }) - expect(last_response.status).to eq(404) - end + expect_error(404, 'not-found') + end - it 'rejects inputs sent as valueString' do - post_parameters(type_status_url, { name: 'id', valueString: subscription_id }, - { name: 'status', valueString: 'requested' }) + it 'rejects inputs sent as valueString' do + post_parameters(type_status_url, { name: 'id', valueString: subscription_id }, + { name: 'status', valueString: 'requested' }) - expect(last_response.status).to eq(400) - expect(FHIR.from_contents(last_response.body).issue.first.code).to eq('invalid') - expect(error_text).to include('The `id` parameter must be sent as `valueId`.') - expect(error_text).to include('The `status` parameter must be sent as `valueCode`.') - end + expect_error(400, 'invalid', 'The `id` parameter must have a non-empty `valueId`. ' \ + 'The `status` parameter must have a non-empty `valueCode`.') + 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' }) + 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(last_response.status).to eq(400) - expect(error_text).to eq('The `id` parameter must be sent as `valueId`.') - end + expect_error(400, 'invalid', 'The `id` parameter must have a non-empty `valueId`.') + 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) + 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`.') + end - expect(last_response.status).to eq(400) + 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 end end - describe 'instance-level' do + describe SubscriptionsTestKit::SubscriptionStatusEndpoint do it 'responds to GET' do get(instance_status_url) - expect(last_response.status).to eq(200) + expect_current_subscription_status end it 'responds to POST with an empty body' do post(instance_status_url) - expect(last_response.status).to eq(200) + expect_current_subscription_status end - it 'uses the id from the url rather than an id query parameter' do + it 'returns not-found for a different Subscription id in the url' do get("/custom/#{suite_id}/fhir/Subscription/other/$status?id=#{subscription_id}") - expect(last_response.status).to eq(404) + expect_error(404, 'not-found') end - it 'returns not-found when the status query parameter does not match' do - get("#{instance_status_url}?status=active") + it 'ignores id and status query parameters' do + get("#{instance_status_url}?id=other&status=off") - expect(last_response.status).to eq(404) + expect_current_subscription_status end - it 'returns not-found when the valueCode input does not match' do - post_parameters(instance_status_url, { name: 'status', valueCode: 'active' }) + it 'ignores id and status inputs in the body, whatever their type' do + post_parameters(instance_status_url, { name: 'id', valueId: 'other' }, + { name: 'status', valueString: 'off' }) - expect(last_response.status).to eq(404) + expect_current_subscription_status end - it 'rejects inputs sent as valueString' do - post_parameters(instance_status_url, { name: 'status', valueString: 'requested' }) + it 'ignores a body that is not a Parameters resource' do + header 'Content-Type', 'application/fhir+json' + post(instance_status_url, FHIR::Bundle.new(type: 'collection').to_json) - expect(last_response.status).to eq(400) - expect(error_text).to eq('The `status` parameter must be sent as `valueCode`.') + expect_current_subscription_status end end end From a5cd03fda8dbd448bed9aeae471082f9ba294b71 Mon Sep 17 00:00:00 2001 From: Lucas Tucker Date: Mon, 19 Oct 2026 17:23:59 -0400 Subject: [PATCH 3/5] ID-278: tighten type-level $status input validation Reject GET query inputs without a value and body inputs whose only value is not a non-empty valueId/valueCode string, order the repeated-parameter spec so it guards against request.params, and clarify the OR semantics in the client suite description. Co-Authored-By: Claude Opus 5.5 --- ...r5_backport_r4_client_suite_description.md | 7 +++-- .../subscription_type_status_endpoint.rb | 23 +++++++++++--- .../subscription_status_endpoint_spec.rb | 31 ++++++++++++++++--- 3 files changed, 49 insertions(+), 12 deletions(-) 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 5a804e4..c21b521 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 @@ -35,9 +35,10 @@ provided event notification is conformant and that it matches the requirements f 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, they must match the Subscription -created by the client system, and inputs sent in a `Parameters` body must use `valueId` and `valueCode`. +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, at +least one value of each input given must match the Subscription created by the client +system, and 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 diff --git a/lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb b/lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb index 575d055..4c50d20 100644 --- a/lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb +++ b/lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb @@ -18,10 +18,19 @@ def requested_subscription?(subscription, status_code) false end - # request.params keeps only the last value of a repeated query parameter + # 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) - INPUT_VALUE_TYPES.keys.to_h { |name| [name, Array(query[name])] } + inputs = INPUT_VALUE_TYPES.keys.to_h { |name| [name, query.key?(name) ? [query[name]].flatten : []] } + + blank_inputs = inputs.select { |_name, values| values.any?(&:blank?) }.keys + return inputs if blank_inputs.empty? + + fhir_error_response(400, 'invalid', blank_inputs.map do |name| + "The `#{name}` query parameter must have a value." + end.join(' ')) + nil end # Renders an error response and returns nil if the body is not a Parameters resource with correctly typed inputs @@ -42,12 +51,18 @@ def body_inputs def input_type_errors(params) INPUT_VALUE_TYPES.filter_map do |name, value_type| - next if find_params(params, name).all? { |p| p.public_send(value_type).present? } + next if find_params(params, name).all? { |p| valid_input_value?(p, value_type) } - "The `#{name}` parameter must have a non-empty `#{value_type}`." + "The `#{name}` parameter must have a non-empty `#{value_type}` and no other value." end end + 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? diff --git a/spec/subscriptions_test_kit/endpoints/subscription_status_endpoint_spec.rb b/spec/subscriptions_test_kit/endpoints/subscription_status_endpoint_spec.rb index 0734fe0..6510392 100644 --- a/spec/subscriptions_test_kit/endpoints/subscription_status_endpoint_spec.rb +++ b/spec/subscriptions_test_kit/endpoints/subscription_status_endpoint_spec.rb @@ -69,8 +69,9 @@ def expect_error(status, code, text = nil) 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=other&id=#{subscription_id}&status=active&status=requested") + get("#{type_status_url}?id=#{subscription_id}&id=other&status=requested&status=active") expect_current_subscription_status end @@ -86,6 +87,13 @@ def expect_error(status, code, text = nil) expect_error(404, 'not-found') 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 @@ -118,8 +126,8 @@ def expect_error(status, code, text = nil) 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`. ' \ - 'The `status` parameter must have a non-empty `valueCode`.') + 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 @@ -127,14 +135,27 @@ def expect_error(status, code, text = nil) { name: 'id', valueString: 'other' }, { name: 'status', valueCode: 'requested' }) - expect_error(400, 'invalid', 'The `id` parameter must have a non-empty `valueId`.') + 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`.') + 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 From f9a37089ed2133a06cdf9c4fba60a14c1921b068 Mon Sep 17 00:00:00 2001 From: Lucas Tucker Date: Tue, 20 Oct 2026 11:20:39 -0400 Subject: [PATCH 4/5] Pin CI runners to ubuntu-24.04 Changes to the ubuntu-latest image break CI for Ruby 3.3.6, so run the workflows on ubuntu-24.04 until the Ruby version is upgraded. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ruby.yml | 8 ++++---- .github/workflows/run_inferno_execution_scripts.yml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) 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: From d6bdeafef6ee1e00d671311686b7c0376ee20db7 Mon Sep 17 00:00:00 2001 From: Lucas Tucker Date: Tue, 20 Oct 2026 11:34:40 -0400 Subject: [PATCH 5/5] ID-278: combine $status endpoints and return an empty Bundle when nothing matches A single SubscriptionStatusEndpoint now handles both the instance and type invocations, choosing by the request path. Type-level requests that match no Subscription return a 200 empty searchset Bundle instead of a 404, per the operation's search semantics, and input lookups use SubscriptionSimulationUtils#find_all_parameters. Co-Authored-By: Claude Opus 5.5 --- ...r5_backport_r4_client_suite_description.md | 7 +- .../endpoints/subscription_status_endpoint.rb | 108 ++++++++++++++++-- .../subscription_type_status_endpoint.rb | 84 -------------- .../common/subscription_simulation_utils.rb | 9 ++ ...bscriptions_r5_backport_r4_client_suite.rb | 5 +- .../subscription_status_endpoint_spec.rb | 53 +++++++-- 6 files changed, 153 insertions(+), 113 deletions(-) delete mode 100644 lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb 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 c21b521..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 @@ -36,9 +36,10 @@ 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, at -least one value of each input given must match the Subscription created by the client -system, and inputs sent in a `Parameters` body must use `valueId` and `valueCode`. +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 diff --git a/lib/subscriptions_test_kit/endpoints/subscription_status_endpoint.rb b/lib/subscriptions_test_kit/endpoints/subscription_status_endpoint.rb index 7f73994..f36a91d 100644 --- a/lib/subscriptions_test_kit/endpoints/subscription_status_endpoint.rb +++ b/lib/subscriptions_test_kit/endpoints/subscription_status_endpoint.rb @@ -2,10 +2,13 @@ require_relative '../suites/subscriptions_r5_backport_r4_client/common/subscription_simulation_utils' module SubscriptionsTestKit - # Handles $status invoked on a Subscription instance; SubscriptionTypeStatusEndpoint handles the type-level invocation + # 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 @@ -14,31 +17,112 @@ def test_run_identifier def make_response response.format = :json + type_level? ? make_type_response : make_instance_response + end + + def make_instance_response subscription = find_current_subscription(test_run.test_session_id) - if subscription.blank? + if subscription.blank? || request.params[:id] != subscription.id fhir_error_response(404, 'not-found') return end - status_code = current_subscription_status_code(subscription) - return unless requested_subscription?(subscription, status_code) + 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 - # Renders an error response and returns false if the request does not select the current Subscription. - # The operation's `id` and `status` inputs are ignored at the instance level. - def requested_subscription?(subscription, _status_code) - return true if request.params[:id] == subscription.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 : []] } + + blank_inputs = inputs.select { |_name, values| values.any?(&:blank?) }.keys + return inputs if blank_inputs.empty? + + fhir_error_response(400, 'invalid', blank_inputs.map do |name| + "The `#{name}` query parameter must have a value." + end.join(' ')) + nil + end + + # 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 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(404, 'not-found') - false + fhir_error_response(400, 'invalid', 'Invalid Parameters in request body') + nil end def tags diff --git a/lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb b/lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb deleted file mode 100644 index 4c50d20..0000000 --- a/lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb +++ /dev/null @@ -1,84 +0,0 @@ -require_relative 'subscription_status_endpoint' - -module SubscriptionsTestKit - class SubscriptionTypeStatusEndpoint < SubscriptionStatusEndpoint - INPUT_VALUE_TYPES = { 'id' => 'valueId', 'status' => 'valueCode' }.freeze - - # Not inherited: SuiteEndpoint stores the format per class - error_response_format :operation_outcome - - def requested_subscription?(subscription, status_code) - inputs = request.get? ? query_inputs : body_inputs - return false if inputs.nil? - - return true if (inputs['id'].empty? || inputs['id'].include?(subscription.id)) && - (inputs['status'].empty? || inputs['status'].include?(status_code)) - - fhir_error_response(404, 'not-found') - false - 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 : []] } - - blank_inputs = inputs.select { |_name, values| values.any?(&:blank?) }.keys - return inputs if blank_inputs.empty? - - fhir_error_response(400, 'invalid', blank_inputs.map do |name| - "The `#{name}` query parameter must have a value." - end.join(' ')) - nil - end - - # 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_params(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_params(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 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 find_params(params, name) - params.parameter.filter { |p| p.name == name } - end - end -end 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 d0ca3a1..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 @@ -9,7 +9,6 @@ require_relative '../endpoints/subscription_patch_endpoint' require_relative '../endpoints/subscription_delete_endpoint' require_relative '../endpoints/subscription_status_endpoint' -require_relative '../endpoints/subscription_type_status_endpoint' module SubscriptionsTestKit module SubscriptionsR5BackportR4Client @@ -85,8 +84,8 @@ class SubscriptionsR5BackportR4ClientSuite < Inferno::TestSuite suite_endpoint :delete, FHIR_SUBSCRIPTION_INSTANCE_PATH, SubscriptionDeleteEndpoint 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, SubscriptionTypeStatusEndpoint - suite_endpoint :get, FHIR_SUBSCRIPTION_RESOURCE_STATUS_PATH, SubscriptionTypeStatusEndpoint + 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/subscription_status_endpoint_spec.rb b/spec/subscriptions_test_kit/endpoints/subscription_status_endpoint_spec.rb index 6510392..e75d3e9 100644 --- a/spec/subscriptions_test_kit/endpoints/subscription_status_endpoint_spec.rb +++ b/spec/subscriptions_test_kit/endpoints/subscription_status_endpoint_spec.rb @@ -1,4 +1,4 @@ -RSpec.describe 'Subscription $status endpoints', :request, :runnable do # rubocop:disable RSpec/DescribeClass +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 @@ -35,6 +35,14 @@ def expect_current_subscription_status .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 @@ -61,7 +69,7 @@ def expect_error(status, code, text = nil) header 'Authorization', "Bearer #{access_token}" end - describe SubscriptionsTestKit::SubscriptionTypeStatusEndpoint do + 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) @@ -76,16 +84,16 @@ def expect_error(status, code, text = nil) expect_current_subscription_status end - it 'returns not-found when the id query parameter does not match' do + it 'returns an empty Bundle when the id query parameter does not match' do get("#{type_status_url}?id=other") - expect_error(404, 'not-found') + expect_empty_status_bundle end - it 'returns not-found when the status query parameter does not match' do + it 'returns an empty Bundle when the status query parameter does not match' do get("#{type_status_url}?status=active") - expect_error(404, 'not-found') + expect_empty_status_bundle end it 'rejects query parameters without a value' do @@ -110,16 +118,16 @@ def expect_error(status, code, text = nil) expect_current_subscription_status end - it 'returns not-found when the valueId input does not match' do + it 'returns an empty Bundle when the valueId input does not match' do post_parameters(type_status_url, { name: 'id', valueId: 'other' }) - expect_error(404, 'not-found') + expect_empty_status_bundle end - it 'returns not-found when the valueCode input does not match' do + it 'returns an empty Bundle when the valueCode input does not match' do post_parameters(type_status_url, { name: 'status', valueCode: 'active' }) - expect_error(404, 'not-found') + expect_empty_status_bundle end it 'rejects inputs sent as valueString' do @@ -164,10 +172,33 @@ def expect_error(status, code, text = nil) 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, '