subscriptions_test_kit 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +201 -0
  3. data/lib/inferno_requirements_tools/ext/inferno_core/runnable.rb +22 -0
  4. data/lib/inferno_requirements_tools/tasks/collect_requirements.rb +222 -0
  5. data/lib/inferno_requirements_tools/tasks/requirements_coverage.rb +264 -0
  6. data/lib/subscriptions_test_kit/common/notification_conformance_verification.rb +310 -0
  7. data/lib/subscriptions_test_kit/common/subscription_conformance_verification.rb +87 -0
  8. data/lib/subscriptions_test_kit/docs/samples/Subscription_empty.json +37 -0
  9. data/lib/subscriptions_test_kit/docs/samples/Subscription_full-resource.json +37 -0
  10. data/lib/subscriptions_test_kit/docs/samples/Subscription_id-only.json +38 -0
  11. data/lib/subscriptions_test_kit/docs/subscriptions_r5_backport_r4_client_suite_description.md +120 -0
  12. data/lib/subscriptions_test_kit/docs/subscriptions_r5_backport_r4_server_suite_description.md +170 -0
  13. data/lib/subscriptions_test_kit/endpoints/subscription_create_endpoint.rb +90 -0
  14. data/lib/subscriptions_test_kit/endpoints/subscription_read_endpoint.rb +32 -0
  15. data/lib/subscriptions_test_kit/endpoints/subscription_rest_hook_endpoint.rb +66 -0
  16. data/lib/subscriptions_test_kit/endpoints/subscription_status_endpoint.rb +70 -0
  17. data/lib/subscriptions_test_kit/igs/README.md +21 -0
  18. data/lib/subscriptions_test_kit/jobs/send_subscription_notifications.rb +130 -0
  19. data/lib/subscriptions_test_kit/requirements/generated/subscriptions-test-kit_requirements_coverage.csv +136 -0
  20. data/lib/subscriptions_test_kit/requirements/subscriptions-test-kit_out_of_scope_requirements.csv +23 -0
  21. data/lib/subscriptions_test_kit/requirements/subscriptions-test-kit_requirements.csv +145 -0
  22. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client/common/subscription_simulation_utils.rb +140 -0
  23. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client/fixtures/capability_statement.json +57 -0
  24. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client/workflow/conformance_verification/notification_input_payload_verification_test.rb +50 -0
  25. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client/workflow/conformance_verification/notification_input_verification_test.rb +25 -0
  26. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client/workflow/conformance_verification/processing_attestation_test.rb +38 -0
  27. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client/workflow/conformance_verification/subscription_verification_test.rb +28 -0
  28. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client/workflow/conformance_verification_group.rb +20 -0
  29. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client/workflow/interaction_test.rb +128 -0
  30. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client/workflow/interaction_verification/event_notification_verification_test.rb +40 -0
  31. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client/workflow/interaction_verification/handshake_notification_verification_test.rb +40 -0
  32. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client/workflow/interaction_verification_group.rb +16 -0
  33. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client/workflow_group.rb +33 -0
  34. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_client_suite.rb +66 -0
  35. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/common/interaction/creation_response_conformance_test.rb +51 -0
  36. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/common/interaction/notification_delivery_test.rb +56 -0
  37. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/common/interaction/subscription_conformance_test.rb +72 -0
  38. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/common/interaction_group.rb +24 -0
  39. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/common/interaction_verification/notification_conformance_test.rb +73 -0
  40. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/common/interaction_verification_group.rb +19 -0
  41. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/common/subscription_creation.rb +63 -0
  42. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/common/subscription_status_operation.rb +58 -0
  43. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/capability_statement/cs_conformance_test.rb +49 -0
  44. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/capability_statement/topic_discovery_test.rb +106 -0
  45. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/capability_statement_group.rb +21 -0
  46. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/event_notification/empty_content/empty_conformance_test.rb +63 -0
  47. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/event_notification/empty_content_group.rb +58 -0
  48. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/event_notification/full_resource_content/full_resource_conformance_test.rb +68 -0
  49. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/event_notification/full_resource_content_group.rb +58 -0
  50. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/event_notification/id_only_content/id_only_conformance_test.rb +66 -0
  51. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/event_notification/id_only_content_group.rb +58 -0
  52. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/event_notification_group.rb +25 -0
  53. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/handshake_heartbeat/handshake_conformance_test.rb +67 -0
  54. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/handshake_heartbeat/heartbeat_conformance_test.rb +74 -0
  55. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/handshake_heartbeat_group.rb +19 -0
  56. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/status_operation/status_invocation_test.rb +43 -0
  57. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/status_operation_group.rb +15 -0
  58. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/subscription_rejection/reject_subscriptions_test.rb +181 -0
  59. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage/subscription_rejection_group.rb +21 -0
  60. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/coverage_group.rb +26 -0
  61. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server/workflow_group.rb +27 -0
  62. data/lib/subscriptions_test_kit/suites/subscriptions_r5_backport_r4_server_suite.rb +79 -0
  63. data/lib/subscriptions_test_kit/tags.rb +10 -0
  64. data/lib/subscriptions_test_kit/urls.rb +37 -0
  65. data/lib/subscriptions_test_kit/version.rb +5 -0
  66. data/lib/subscriptions_test_kit.rb +3 -0
  67. metadata +194 -0
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'CSV'
4
+ require 'roo'
5
+ require_relative '../ext/inferno_core/runnable'
6
+
7
+ module InfernoRequirementsTools
8
+ module Tasks
9
+ # This class manages the mapping of test kit tests to requirements that they verify
10
+ # and creates a CSV file with the tests that cover each requirement.
11
+ # It expects a CSV file in the repo at `lib/[test kit id]/requirements/[test kit id]_requirements.csv`
12
+ # that serves as the source of the requirement set for the test kit. The requirements in
13
+ # this files are identified by a requirement set and an id and tests, groups, and suites
14
+ # within in the test kit can claim that they verify a requirement by including a reference
15
+ # to that requirementin the form <requirement set>@<id> in their `verifies_requirements` field.
16
+ # Requirements that are out of scope can be listed in a companion file
17
+ # `lib/[test kit id]/requirements/[test kit id]_out_of_scope_requirements.csv`.
18
+ #
19
+ # The `run` method generates a CSV file at
20
+ # `lib/[test kit id]/requirements/generated/[test kit id]_requirements_coverage.csv``.
21
+ # This file will be identical to the input spreadsheet, plus an additional column which holds a comma separated
22
+ # list of inferno test IDs that test each requirement. These test IDs are Inferno short form IDs that represent the
23
+ # position of the test within its group and suite. For example, the fifth test in the second group will have an ID
24
+ # of 2.05. This ID is also shown in the Inferno web UI.
25
+ #
26
+ # The `run_check` method will check whether the previously generated file is up-to-date.
27
+ class RequirementsCoverage
28
+ # Update these constants based on the test kit.
29
+ TEST_KIT_ID = 'subscriptions-test-kit'
30
+ TEST_SUITES = [SubscriptionsTestKit::SubscriptionsR5BackportR4Client::SubscriptionsR5BackportR4ClientSuite,
31
+ SubscriptionsTestKit::SubscriptionsR5BackportR4Server::SubscriptionsR5BackportR4ServerSuite].freeze
32
+ SUITE_ID_TO_ACTOR_MAP = {
33
+ 'subscriptions_r5_backport_r4_client' => 'Client',
34
+ 'subscriptions_r5_backport_r4_server' => 'Server'
35
+ }.freeze
36
+
37
+ # Derivative constants
38
+ TEST_KIT_CODE_FOLDER = TEST_KIT_ID.gsub('-', '_')
39
+ INPUT_HEADERS = [
40
+ 'Req Set',
41
+ 'ID',
42
+ 'URL',
43
+ 'Requirement',
44
+ 'Conformance',
45
+ 'Actor',
46
+ 'Sub-Requirement(s)',
47
+ 'Conditionality'
48
+ ].freeze
49
+ SHORT_ID_HEADER = 'Short ID(s)'
50
+ FULL_ID_HEADER = 'Full ID(s)'
51
+ INPUT_FILE_NAME = "#{TEST_KIT_ID}_requirements.csv".freeze
52
+ INPUT_FILE = File.join('lib', TEST_KIT_CODE_FOLDER, 'requirements', INPUT_FILE_NAME).freeze
53
+ NOT_TESTED_FILE_NAME = "#{TEST_KIT_ID}_out_of_scope_requirements.csv".freeze
54
+ NOT_TESTED_FILE = File.join('lib', TEST_KIT_CODE_FOLDER, 'requirements', NOT_TESTED_FILE_NAME).freeze
55
+ OUTPUT_FILE_NAME = "#{TEST_KIT_ID}_requirements_coverage.csv".freeze
56
+ OUTPUT_FILE = File.join('lib', TEST_KIT_CODE_FOLDER, 'requirements', 'generated', OUTPUT_FILE_NAME).freeze
57
+
58
+ def input_rows
59
+ @input_rows ||=
60
+ CSV.parse(File.open(INPUT_FILE, "r:bom|utf-8"), headers: true).map do |row|
61
+ row.to_h.slice(*INPUT_HEADERS)
62
+ end
63
+ end
64
+
65
+ def not_tested_requirements_map
66
+ @not_tested_requirements_map ||= load_not_tested_requirements
67
+ end
68
+
69
+ def load_not_tested_requirements
70
+ return {} unless File.exists?(NOT_TESTED_FILE)
71
+
72
+ not_tested_requirements = {}
73
+ CSV.parse(File.open(NOT_TESTED_FILE, "r:bom|utf-8"), headers: true).each do |row|
74
+ row_hash = row.to_h
75
+ not_tested_requirements[ "#{row_hash['Req Set']}@#{row_hash['ID']}"] = row_hash
76
+ end
77
+
78
+ not_tested_requirements
79
+ end
80
+
81
+ # Of the form:
82
+ # {
83
+ # 'req-id-1': [
84
+ # { short_id: 'short-id-1', full_id: 'long-id-1', suite_id: 'suite-id-1' },
85
+ # { short_id: 'short-id-2', full_id: 'long-id-2', suite_id: 'suite-id-2' }
86
+ # ],
87
+ # 'req-id-2': [{ short_id: 'short-id-3', full_id: 'long-id-3', suite_id: 'suite-id-3' }],
88
+ # ...
89
+ # }
90
+ def inferno_requirements_map
91
+ @inferno_requirements_map ||= TEST_SUITES.each_with_object({}) do |suite, requirements_map|
92
+ serialize_requirements(suite, 'suite', suite.id, requirements_map)
93
+ suite.groups.each do |group|
94
+ map_group_requirements(group, suite.id, requirements_map)
95
+ end
96
+ end
97
+ end
98
+
99
+ def new_csv
100
+ @new_csv ||=
101
+ CSV.generate(+"\xEF\xBB\xBF") do |csv|
102
+ output_headers = TEST_SUITES.each_with_object(INPUT_HEADERS.dup) do |suite, headers|
103
+ headers << "#{suite.title} #{SHORT_ID_HEADER}"
104
+ headers << "#{suite.title} #{FULL_ID_HEADER}"
105
+ end
106
+
107
+ csv << output_headers
108
+ input_rows.each do |row| # note: use row order from source file
109
+ next if row['Conformance'] == 'DEPRECATED' # filter out deprecated rows
110
+ row_actor = row['Actor']
111
+ TEST_SUITES.each do |suite|
112
+ suite_actor = SUITE_ID_TO_ACTOR_MAP[suite.id]
113
+ if row_actor&.include?(suite_actor)
114
+ set_and_req_id = "#{row['Req Set']}@#{row['ID']}"
115
+ suite_requirement_items = inferno_requirements_map[set_and_req_id]&.filter { |item| item[:suite_id] == suite.id}
116
+ short_ids = suite_requirement_items&.map { |item| item[:short_id] }
117
+ full_ids = suite_requirement_items&.map { |item| item[:full_id] }
118
+ if short_ids.blank? && not_tested_requirements_map.has_key?(set_and_req_id)
119
+ row["#{suite.title} #{SHORT_ID_HEADER}"] = 'Not Tested'
120
+ row["#{suite.title} #{FULL_ID_HEADER}"] = 'Not Tested'
121
+ else
122
+ row["#{suite.title} #{SHORT_ID_HEADER}"] = short_ids&.join(', ')
123
+ row["#{suite.title} #{FULL_ID_HEADER}"] = full_ids&.join(', ')
124
+ end
125
+ else
126
+ row["#{suite.title} #{SHORT_ID_HEADER}"] = 'NA'
127
+ row["#{suite.title} #{FULL_ID_HEADER}"] = 'NA'
128
+ end
129
+ end
130
+
131
+ csv << row.values
132
+ end
133
+ end
134
+ end
135
+
136
+ def input_requirement_ids
137
+ @input_requirement_ids ||= input_rows.map { |row| "#{row['Req Set']}@#{row['ID']}" }
138
+ end
139
+
140
+ # The requirements present in Inferno that aren't in the input spreadsheet
141
+ def unmatched_requirements_map
142
+ @unmatched_requirements_map ||= inferno_requirements_map.filter do |requirement_id, _|
143
+ !input_requirement_ids.include?(requirement_id)
144
+ end
145
+ end
146
+
147
+ def old_csv
148
+ @old_csv ||= File.read(OUTPUT_FILE)
149
+ end
150
+
151
+ def run
152
+ unless File.exist?(INPUT_FILE)
153
+ puts "Could not find input file: #{INPUT_FILE}. Aborting requirements coverage generation..."
154
+ exit(1)
155
+ end
156
+
157
+ if unmatched_requirements_map.any?
158
+ puts "WARNING: The following requirements indicated in the test kit are not present in #{INPUT_FILE_NAME}"
159
+ output_requirements_map_table(unmatched_requirements_map)
160
+ end
161
+
162
+ if File.exist?(OUTPUT_FILE)
163
+ if old_csv == new_csv
164
+ puts "'#{OUTPUT_FILE_NAME}' file is up to date."
165
+ return
166
+ else
167
+ puts 'Requirements coverage has changed.'
168
+ end
169
+ else
170
+ puts "No existing #{OUTPUT_FILE_NAME}."
171
+ end
172
+
173
+ puts "Writing to file #{OUTPUT_FILE}..."
174
+ File.write(OUTPUT_FILE, new_csv)
175
+ puts 'Done.'
176
+ end
177
+
178
+ def run_check
179
+ unless File.exist?(INPUT_FILE)
180
+ puts "Could not find input file: #{INPUT_FILE}. Aborting requirements coverage check..."
181
+ exit(1)
182
+ end
183
+
184
+ if unmatched_requirements_map.any?
185
+ puts "The following requirements indicated in the test kit are not present in #{INPUT_FILE_NAME}"
186
+ output_requirements_map_table(unmatched_requirements_map)
187
+ end
188
+
189
+ if File.exist?(OUTPUT_FILE)
190
+ if old_csv == new_csv
191
+ puts "'#{OUTPUT_FILE_NAME}' file is up to date."
192
+ return unless unmatched_requirements_map.any?
193
+ else
194
+ puts <<~MESSAGE
195
+ #{OUTPUT_FILE_NAME} file is out of date.
196
+ To regenerate the file, run:
197
+
198
+ bundle exec rake requirements:generate_coverage
199
+
200
+ MESSAGE
201
+ end
202
+ else
203
+ puts <<~MESSAGE
204
+ No existing #{OUTPUT_FILE_NAME} file.
205
+ To generate the file, run:
206
+
207
+ bundle exec rake requirements:generate_coverage
208
+
209
+ MESSAGE
210
+ end
211
+
212
+ puts 'Check failed.'
213
+ exit(1)
214
+ end
215
+
216
+ def map_group_requirements(group, suite_id, requirements_map)
217
+ serialize_requirements(group, group.short_id, suite_id, requirements_map)
218
+ group.tests&.each { |test| serialize_requirements(test, test.short_id, suite_id, requirements_map) }
219
+ group.groups&.each { |subgroup| map_group_requirements(subgroup, suite_id, requirements_map) }
220
+ end
221
+
222
+ def serialize_requirements(runnable, short_id, suite_id, requirements_map)
223
+ runnable.verifies_requirements&.each do |requirement_id|
224
+ requirement_id_string = requirement_id.to_s
225
+
226
+ requirements_map[requirement_id_string] ||= []
227
+ requirements_map[requirement_id_string] << { short_id:, full_id: runnable.id, suite_id: }
228
+ end
229
+ end
230
+
231
+ # Output the requirements in the map like so:
232
+ #
233
+ # requirement_id | short_id | full_id
234
+ # ---------------+------------+----------
235
+ # req-id-1 | short-id-1 | full-id-1
236
+ # req-id-2 | short-id-2 | full-id-2
237
+ def output_requirements_map_table(requirements_map)
238
+ headers = %w[requirement_id short_id full_id]
239
+ col_widths = headers.map(&:length)
240
+ col_widths[0] = [col_widths[0], requirements_map.keys.map(&:length).max].max
241
+ col_widths[1] = ([col_widths[1]] + requirements_map.values.flatten.map { |item| item[:short_id].length }).max
242
+ col_widths[2] = ([col_widths[2]] + requirements_map.values.flatten.map { |item| item[:full_id].length }).max
243
+ col_widths.map { |width| width + 3 }
244
+
245
+ puts [
246
+ headers[0].ljust(col_widths[0]),
247
+ headers[1].ljust(col_widths[1]),
248
+ headers[2].ljust(col_widths[2])
249
+ ].join(' | ')
250
+ puts col_widths.map { |width| '-' * width }.join('-+-')
251
+ requirements_map.each do |requirement_id, runnables|
252
+ runnables.each do |runnable|
253
+ puts [
254
+ requirement_id.ljust(col_widths[0]),
255
+ runnable[:short_id].ljust(col_widths[1]),
256
+ runnable[:full_id].ljust(col_widths[2])
257
+ ].join(' | ')
258
+ end
259
+ end
260
+ puts
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,310 @@
1
+ module SubscriptionsTestKit
2
+ module NotificationConformanceVerification
3
+ def no_error_verification(message)
4
+ assert messages.none? { |msg| msg[:type] == 'error' }, message
5
+ end
6
+
7
+ def find_all_elems(resource_array, param_name)
8
+ resource_array.select do |param|
9
+ param.name == param_name
10
+ end
11
+ end
12
+
13
+ def find_elem(resource_array, param_name)
14
+ resource_array.find do |param|
15
+ param.name == param_name
16
+ end
17
+ end
18
+
19
+ def check_entry_request_and_response(entry, entry_num)
20
+ if entry.request.blank?
21
+ add_message('error', %(
22
+ The `entry.request` field is mandatory for history Bundles, but was not included in entry #{entry_num}))
23
+ end
24
+ return unless entry.response.blank?
25
+
26
+ add_message('error', %(
27
+ The `entry.response` field is mandatory for history Bundles, but was not included in entry #{entry_num}))
28
+ end
29
+
30
+ def check_history_bundle_request_response(bundle, subscription_status_entry, subscription_id)
31
+ check_entry_request_and_response(subscription_status_entry, 1)
32
+
33
+ unless subscription_status_entry.request.present? &&
34
+ (
35
+ subscription_id ?
36
+ subscription_status_entry.request.url.end_with?("Subscription/#{subscription_id}/$status") :
37
+ subscription_status_entry.request.url.match?(%r{Subscription/[^/]+/\$status\z})
38
+ )
39
+
40
+ add_message('error',
41
+ 'The SubscriptionStatus `request` SHALL be filled out to match a request to the $status operation')
42
+ end
43
+
44
+ bundle.entry.drop(1).each_with_index do |entry, index|
45
+ check_entry_request_and_response(entry, index + 2)
46
+ end
47
+ end
48
+
49
+ def parameters_verification(subscription_status)
50
+ resource_type = subscription_status.resourceType
51
+ if resource_type == 'Parameters'
52
+ resource_is_valid?(resource: subscription_status, profile_url: 'http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-subscription-status-r4')
53
+ else
54
+ add_message('error',
55
+ "Unexpected resource type: Expected `Parameters`. Got `#{resource_type}`")
56
+ end
57
+ end
58
+
59
+ def notification_verification(notification_bundle, notification_type, subscription_id: nil, status: nil)
60
+ assert_valid_json(notification_bundle)
61
+ bundle = FHIR.from_contents(notification_bundle)
62
+ assert bundle.present?, 'Not a FHIR resource'
63
+ assert_resource_type(:bundle, resource: bundle)
64
+ unless bundle.type == 'history'
65
+ add_message('error', "Notification should be a history Bundle, instead was #{bundle.type}")
66
+ end
67
+
68
+ if bundle.entry.empty?
69
+ add_message('error', 'Notification Bundle is empty.')
70
+ return
71
+ end
72
+ subscription_status_entry = bundle.entry[0]
73
+
74
+ check_history_bundle_request_response(bundle, subscription_status_entry, subscription_id)
75
+
76
+ subscription_status = subscription_status_entry.resource
77
+ parameters_verification(subscription_status)
78
+
79
+ return unless subscription_status.respond_to?(:parameter)
80
+
81
+ subscription_type = find_elem(subscription_status.parameter, 'type')
82
+
83
+ unless subscription_type.valueCode == notification_type
84
+ add_message('error', %(
85
+ The Subscription resource should have it's `type` set to '#{notification_type}', was
86
+ '#{subscription_type.valueCode}'))
87
+ end
88
+
89
+ return unless status.present?
90
+
91
+ subscription_param_status = find_elem(subscription_status.parameter, 'status')
92
+ return if subscription_param_status.valueCode == status
93
+
94
+ add_message('error', %(
95
+ The Subscription resource should have it's `status` set to '#{status}', was
96
+ '#{subscription_param_status.valueCode}'))
97
+ end
98
+
99
+ def empty_notification_event_references(notification_events)
100
+ empty_req_check = true
101
+ notification_events.each do |notification_event|
102
+ empty_req_check = notification_event.part.none? do |part|
103
+ part.name == 'focus' || part.name == 'additional-context'
104
+ end
105
+ break unless empty_req_check
106
+ end
107
+ empty_req_check
108
+ end
109
+
110
+ def empty_notification_verification(bundle_entries, notification_events)
111
+ unless bundle_entries.empty?
112
+ add_message('error', %(
113
+ When the content type is empty, notification bundles SHALL not contain Bundle.entry elements other than
114
+ the SubscriptionStatus for the notification.))
115
+ end
116
+
117
+ if notification_events.empty?
118
+ add_message('error', %(
119
+ Events are required for empty notifications, but the SubscriptionStatus does not contain event-notifications
120
+ ))
121
+ return
122
+ end
123
+
124
+ empty_req_check = empty_notification_event_references(notification_events)
125
+ return if empty_req_check
126
+
127
+ add_message('error', %(
128
+ When populating the SubscriptionStatus.notificationEvent structure for a notification with an empty
129
+ payload, a server SHALL NOT include references to resources))
130
+ end
131
+
132
+ def verify_id_only_notification_bundle_entries(bundle_entries)
133
+ bundle_entries.each do |entry|
134
+ if entry.resource.present?
135
+ add_message('error', 'Each Bundle.entry for id-only notification SHALL not contain the `resource` field')
136
+ end
137
+ if entry.fullUrl.blank?
138
+ add_message('error', %(
139
+ Each Bundle.entry for id-only notification SHALL contain a relevant resource URL in the fullUrl))
140
+ end
141
+ end
142
+ end
143
+
144
+ def verify_full_resource_notification_bundle_entries(bundle_entries)
145
+ bundle_entries.each do |entry|
146
+ resource_is_valid?(resource: entry.resource) if entry.resource.present?
147
+ end
148
+ end
149
+
150
+ def check_bundle_entry_reference(bundle_entries, reference)
151
+ referenced_entry = bundle_entries.find do |entry|
152
+ reference.include?("#{entry.resource.resourceType}/#{entry.resource.id}")
153
+ end
154
+ referenced_entry.present?
155
+ end
156
+
157
+ def check_notification_event_focus(focus_elem, bundle_entries)
158
+ if focus_elem.blank?
159
+ add_message('error', %(
160
+ When the content type is `full-resource`, notification bundles SHALL include references to
161
+ the appropriate focus resources in the SubscriptionStatus.notificationEvent.focus element))
162
+ else
163
+ unless check_bundle_entry_reference(bundle_entries, focus_elem.valueReference.reference)
164
+ add_message('error', %(
165
+ The Notification Bundle does not include a resource entry for the reference found in
166
+ SubscriptionStatus.notificationEvent.focus with id #{focus_elem.valueReference.reference}))
167
+ end
168
+ end
169
+ end
170
+
171
+ def check_notification_event_additional_context(additional_context_list, bundle_entries)
172
+ return if additional_context_list.empty?
173
+
174
+ additional_context_list.each do |additional_context|
175
+ next if check_bundle_entry_reference(bundle_entries, additional_context.valueReference.reference)
176
+
177
+ add_message('error', %(
178
+ The Notification Bundle does not include a resource entry for the reference found in
179
+ SubscriptionStatus.notificationEvent.additional-context with id
180
+ #{additional_context.valueReference.reference}))
181
+ end
182
+ end
183
+
184
+ def full_resource_notification_event_parameter_verification(notification_events, bundle_entries)
185
+ notification_events.each do |notification_event|
186
+ focus_elem = find_elem(notification_event.part, 'focus')
187
+ additional_context_list = find_all_elems(notification_event.part, 'additional-context')
188
+
189
+ check_notification_event_focus(focus_elem, bundle_entries)
190
+ check_notification_event_additional_context(additional_context_list, bundle_entries)
191
+ end
192
+ end
193
+
194
+ def id_only_notification_event_parameter_verification(notification_events, criteria_resource_type)
195
+ notification_events.each do |notification_event|
196
+ focus_elem = find_elem(notification_event.part, 'focus')
197
+
198
+ if focus_elem.blank?
199
+ add_message('error', %(
200
+ When the content type is `id-only`, notification bundles SHALL include references to
201
+ the appropriate focus resources in the SubscriptionStatus.notificationEvent.focus element))
202
+ break
203
+ else
204
+ break unless criteria_resource_type.present?
205
+
206
+ unless focus_elem.valueReference.reference.include?(criteria_resource_type)
207
+ add_message('error', %(
208
+ The SubscriptionStatus.notificationEvent.focus should include a reference to a #{criteria_resource_type}
209
+ resource, the resource type the Subscription is focused on))
210
+ break
211
+ end
212
+ end
213
+ end
214
+ end
215
+
216
+ def full_resource_notification_criteria_resource_check(bundle_entries, criteria_resource_type)
217
+ relevant_resource = bundle_entries.any? do |entry|
218
+ entry.resource.resourceType == criteria_resource_type || entry.request.url.include?(criteria_resource_type)
219
+ end
220
+ return if relevant_resource
221
+
222
+ add_message('error', %(
223
+ The notification bundle of type `full-resource` must include at least one #{criteria_resource_type}
224
+ resource in the entry.resource element.))
225
+ end
226
+
227
+ def subscription_criteria(subscription)
228
+ return unless subscription['_criteria']
229
+
230
+ criteria_extension = subscription['_criteria']['extension'].find do |ext|
231
+ ext['url'].ends_with?('/backport-filter-criteria')
232
+ end
233
+ criteria_extension['valueString'].split('?').first
234
+ end
235
+
236
+ def empty_event_notification_verification(notification_bundle)
237
+ assert_valid_json(notification_bundle)
238
+ bundle = FHIR.from_contents(notification_bundle)
239
+ assert bundle.present?, 'Not a FHIR resource'
240
+
241
+ subscription_status = bundle.entry[0].resource
242
+
243
+ parameter_topic = find_elem(subscription_status.parameter, 'topic')
244
+ if parameter_topic.present?
245
+ add_message('warning',
246
+ 'Parameters.parameter:topic.value[x]: This value SHOULD NOT be present when using empty payloads')
247
+ end
248
+
249
+ notification_events = find_all_elems(subscription_status.parameter, 'notification-event')
250
+ bundle_entries = bundle.entry.drop(1)
251
+
252
+ empty_notification_verification(bundle_entries, notification_events)
253
+ end
254
+
255
+ def full_resource_event_notification_verification(notification_bundle, criteria_resource_type)
256
+ assert_valid_json(notification_bundle)
257
+ bundle = FHIR.from_contents(notification_bundle)
258
+ assert bundle.present?, 'Not a FHIR resource'
259
+
260
+ subscription_status = bundle.entry[0].resource
261
+
262
+ parameter_topic = find_elem(subscription_status.parameter, 'topic')
263
+ if parameter_topic.blank?
264
+ add_message('warning', %(
265
+ Parameters.parameter:topic.value[x]: This value SHOULD be present when using full-resource payloads))
266
+ end
267
+
268
+ notification_events = find_all_elems(subscription_status.parameter, 'notification-event')
269
+ bundle_entries = bundle.entry.drop(1)
270
+
271
+ if criteria_resource_type.present?
272
+ full_resource_notification_criteria_resource_check(bundle_entries, criteria_resource_type)
273
+ end
274
+
275
+ if notification_events.empty?
276
+ add_message('error', %(
277
+ The notification event parameter must be present in `full-resource` notification bundles.))
278
+ else
279
+ full_resource_notification_event_parameter_verification(notification_events, bundle_entries)
280
+ end
281
+
282
+ verify_full_resource_notification_bundle_entries(bundle_entries)
283
+ end
284
+
285
+ def id_only_event_notification_verification(notification_bundle, criteria_resource_type)
286
+ assert_valid_json(notification_bundle)
287
+ bundle = FHIR.from_contents(notification_bundle)
288
+ assert bundle.present?, 'Not a FHIR resource'
289
+
290
+ subscription_status = bundle.entry[0].resource
291
+
292
+ parameter_topic = find_elem(subscription_status.parameter, 'topic')
293
+ add_message('info', %(
294
+ Parameters.parameter:topic.value[x] is #{'not ' if parameter_topic.blank?}populated in `id-only` Notification.
295
+ This value MAY be present when using id-only payloads))
296
+
297
+ notification_events = find_all_elems(subscription_status.parameter, 'notification-event')
298
+ bundle_entries = bundle.entry.drop(1)
299
+
300
+ if notification_events.empty?
301
+ add_message('error', %(
302
+ The notification event parameter must be present in `id-only` notification bundles.))
303
+ else
304
+ id_only_notification_event_parameter_verification(notification_events, criteria_resource_type)
305
+ end
306
+
307
+ verify_id_only_notification_bundle_entries(bundle_entries)
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,87 @@
1
+ require_relative '../urls'
2
+
3
+ module SubscriptionsTestKit
4
+ module SubscriptionConformanceVerification
5
+ include URLs
6
+
7
+ def no_error_verification(message)
8
+ assert messages.none? { |msg| msg[:type] == 'error' }, message
9
+ end
10
+
11
+ def channel_field_matches?(subscription_channel, field_name, expected_entry)
12
+ subscription_channel[field_name].present? && subscription_channel[field_name] == expected_entry
13
+ end
14
+
15
+ def cross_version_extension?(url)
16
+ url.match?(%r{http://hl7\.org/fhir/[0-9]+\.[0-9]+/StructureDefinition/extension-Subscription\..+})
17
+ end
18
+
19
+ def check_extension(extensions)
20
+ extensions.each do |extension|
21
+ next unless cross_version_extension?(extension['url'])
22
+
23
+ add_message('warning', %(
24
+ Cross-version extensions SHOULD NOT be used on R4 subscriptions to describe any elements also described by
25
+ this guide, but found the #{extension['url']} extension on the Subscription resource
26
+ ))
27
+ end
28
+ end
29
+
30
+ def cross_version_extension_check(subscription)
31
+ subscription.each do |key, value|
32
+ if value.is_a?(Array) && key == 'extension'
33
+ check_extension(value)
34
+ elsif value.is_a?(Hash) || value.is_a?(Array)
35
+ cross_version_extension_check(value)
36
+ end
37
+ end
38
+ end
39
+
40
+ def subscription_verification(subscription_resource)
41
+ assert_valid_json(subscription_resource)
42
+ subscription = JSON.parse(subscription_resource)
43
+
44
+ subscription_channel = subscription['channel']
45
+ assert(channel_field_matches?(subscription_channel, 'type', 'rest-hook'), %(
46
+ The `type` field on the Subscription resource must be set to `rest-hook`, the `#{subscription_channel['type']}`
47
+ channel type is unsupported.))
48
+
49
+ subscription_resource = FHIR.from_contents(subscription.to_json)
50
+ assert_resource_type('Subscription', resource: subscription_resource)
51
+ assert_valid_resource(resource: subscription_resource,
52
+ profile_url: 'http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-subscription')
53
+
54
+ cross_version_extension_check(subscription)
55
+ subscription
56
+ end
57
+
58
+ def server_check_channel(subscription, access_token)
59
+ subscription_channel = subscription['channel']
60
+ unless channel_field_matches?(subscription_channel, 'endpoint', subscription_channel_url)
61
+ add_message('warning', %(
62
+ The subscription url was changed from #{subscription_channel['endpoint']} to
63
+ #{subscription_channel_url}))
64
+ subscription_channel['endpoint'] = subscription_channel_url
65
+ end
66
+
67
+ unless channel_field_matches?(subscription_channel, 'payload', 'application/json')
68
+ add_message('warning', %(
69
+ The `type` field on the Subscription resource should be set to `application/json`. Inferno will only accept
70
+ resources in requests with this content type.
71
+ ))
72
+ end
73
+
74
+ unless subscription_channel['header'].present? &&
75
+ subscription_channel['header'].include?("Authorization: Bearer #{access_token}")
76
+ add_message('warning', %(
77
+ Added the Authorization header field with a Bearer token set to #{access_token} to the `header` field on the
78
+ Subscription resource in order to connect successfully with the Inferno subscription channel.
79
+ ))
80
+ subscription['header'] = [] unless subscription['header'].present?
81
+ subscription['header'].append("Authorization: Bearer #{access_token}")
82
+ end
83
+ subscription['channel'] = subscription_channel
84
+ subscription
85
+ end
86
+ end
87
+ end