davinci_crd_test_kit 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +201 -0
- data/lib/davinci_crd_test_kit/card_responses/companions_prerequisites.json +58 -0
- data/lib/davinci_crd_test_kit/card_responses/create_update_coverage_information.json +20 -0
- data/lib/davinci_crd_test_kit/card_responses/external_reference.json +21 -0
- data/lib/davinci_crd_test_kit/card_responses/instructions.json +14 -0
- data/lib/davinci_crd_test_kit/card_responses/launch_smart_app.json +21 -0
- data/lib/davinci_crd_test_kit/card_responses/propose_alternate_request.json +71 -0
- data/lib/davinci_crd_test_kit/card_responses/request_form_completion.json +227 -0
- data/lib/davinci_crd_test_kit/cards_validation.rb +234 -0
- data/lib/davinci_crd_test_kit/client_fhir_api_group.rb +762 -0
- data/lib/davinci_crd_test_kit/client_hook_request_validation.rb +15 -0
- data/lib/davinci_crd_test_kit/client_hooks_group.rb +706 -0
- data/lib/davinci_crd_test_kit/client_tests/appointment_book_receive_request_test.rb +71 -0
- data/lib/davinci_crd_test_kit/client_tests/client_display_cards_attest.rb +48 -0
- data/lib/davinci_crd_test_kit/client_tests/client_fhir_api_create_test.rb +40 -0
- data/lib/davinci_crd_test_kit/client_tests/client_fhir_api_read_test.rb +39 -0
- data/lib/davinci_crd_test_kit/client_tests/client_fhir_api_search_test.rb +232 -0
- data/lib/davinci_crd_test_kit/client_tests/client_fhir_api_update_test.rb +40 -0
- data/lib/davinci_crd_test_kit/client_tests/client_fhir_api_validation_test.rb +60 -0
- data/lib/davinci_crd_test_kit/client_tests/decode_auth_token_test.rb +40 -0
- data/lib/davinci_crd_test_kit/client_tests/encounter_discharge_receive_request_test.rb +68 -0
- data/lib/davinci_crd_test_kit/client_tests/encounter_start_receive_request_test.rb +68 -0
- data/lib/davinci_crd_test_kit/client_tests/hook_request_optional_fields_test.rb +41 -0
- data/lib/davinci_crd_test_kit/client_tests/hook_request_required_fields_test.rb +40 -0
- data/lib/davinci_crd_test_kit/client_tests/hook_request_valid_context_test.rb +63 -0
- data/lib/davinci_crd_test_kit/client_tests/hook_request_valid_prefetch_test.rb +151 -0
- data/lib/davinci_crd_test_kit/client_tests/order_dispatch_receive_request_test.rb +79 -0
- data/lib/davinci_crd_test_kit/client_tests/order_select_receive_request_test.rb +76 -0
- data/lib/davinci_crd_test_kit/client_tests/order_sign_receive_request_test.rb +79 -0
- data/lib/davinci_crd_test_kit/client_tests/retrieve_jwks_test.rb +65 -0
- data/lib/davinci_crd_test_kit/client_tests/token_header_test.rb +34 -0
- data/lib/davinci_crd_test_kit/client_tests/token_payload_test.rb +61 -0
- data/lib/davinci_crd_test_kit/crd_client_suite.rb +156 -0
- data/lib/davinci_crd_test_kit/crd_jwks.json +59 -0
- data/lib/davinci_crd_test_kit/crd_options.rb +9 -0
- data/lib/davinci_crd_test_kit/crd_server_suite.rb +115 -0
- data/lib/davinci_crd_test_kit/ext/inferno_core/runnable.rb +22 -0
- data/lib/davinci_crd_test_kit/hook_request_field_validation.rb +410 -0
- data/lib/davinci_crd_test_kit/jwks.rb +25 -0
- data/lib/davinci_crd_test_kit/jwt_helper.rb +74 -0
- data/lib/davinci_crd_test_kit/mock_service_response.rb +421 -0
- data/lib/davinci_crd_test_kit/routes/cds-services.json +74 -0
- data/lib/davinci_crd_test_kit/routes/cds_services_discovery_handler.rb +18 -0
- data/lib/davinci_crd_test_kit/routes/hook_request_endpoint.rb +93 -0
- data/lib/davinci_crd_test_kit/routes/jwk_set_endpoint_handler.rb +15 -0
- data/lib/davinci_crd_test_kit/server_appointment_book_group.rb +173 -0
- data/lib/davinci_crd_test_kit/server_discovery_group.rb +59 -0
- data/lib/davinci_crd_test_kit/server_encounter_discharge_group.rb +144 -0
- data/lib/davinci_crd_test_kit/server_encounter_start_group.rb +144 -0
- data/lib/davinci_crd_test_kit/server_hook_request_validation.rb +15 -0
- data/lib/davinci_crd_test_kit/server_hooks_group.rb +69 -0
- data/lib/davinci_crd_test_kit/server_order_dispatch_group.rb +173 -0
- data/lib/davinci_crd_test_kit/server_order_select_group.rb +169 -0
- data/lib/davinci_crd_test_kit/server_order_sign_group.rb +198 -0
- data/lib/davinci_crd_test_kit/server_required_card_response_validation_group.rb +23 -0
- data/lib/davinci_crd_test_kit/server_tests/additional_orders_validation_test.rb +70 -0
- data/lib/davinci_crd_test_kit/server_tests/card_optional_fields_validation_test.rb +47 -0
- data/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_across_hooks_validation_test.rb +32 -0
- data/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_received_test.rb +58 -0
- data/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_validation_test.rb +121 -0
- data/lib/davinci_crd_test_kit/server_tests/create_or_update_coverage_info_response_validation_test.rb +72 -0
- data/lib/davinci_crd_test_kit/server_tests/discovery_endpoint_test.rb +88 -0
- data/lib/davinci_crd_test_kit/server_tests/discovery_services_validation_test.rb +65 -0
- data/lib/davinci_crd_test_kit/server_tests/external_reference_card_across_hooks_validation_test.rb +28 -0
- data/lib/davinci_crd_test_kit/server_tests/external_reference_card_validation_test.rb +36 -0
- data/lib/davinci_crd_test_kit/server_tests/form_completion_response_validation_test.rb +79 -0
- data/lib/davinci_crd_test_kit/server_tests/instructions_card_received_across_hooks_test.rb +25 -0
- data/lib/davinci_crd_test_kit/server_tests/instructions_card_received_test.rb +28 -0
- data/lib/davinci_crd_test_kit/server_tests/launch_smart_app_card_validation_test.rb +38 -0
- data/lib/davinci_crd_test_kit/server_tests/propose_alternate_request_card_validation_test.rb +65 -0
- data/lib/davinci_crd_test_kit/server_tests/service_call_test.rb +86 -0
- data/lib/davinci_crd_test_kit/server_tests/service_request_context_validation_test.rb +30 -0
- data/lib/davinci_crd_test_kit/server_tests/service_request_optional_fields_validation_test.rb +41 -0
- data/lib/davinci_crd_test_kit/server_tests/service_request_required_fields_validation_test.rb +43 -0
- data/lib/davinci_crd_test_kit/server_tests/service_response_validation_test.rb +82 -0
- data/lib/davinci_crd_test_kit/suggestion_actions_validation.rb +123 -0
- data/lib/davinci_crd_test_kit/tags.rb +8 -0
- data/lib/davinci_crd_test_kit/test_helper.rb +23 -0
- data/lib/davinci_crd_test_kit/urls.rb +52 -0
- data/lib/davinci_crd_test_kit/version.rb +3 -0
- data/lib/davinci_crd_test_kit.rb +2 -0
- metadata +170 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
module DaVinciCRDTestKit
|
|
2
|
+
module HookRequestFieldValidation
|
|
3
|
+
def hook_required_fields
|
|
4
|
+
{
|
|
5
|
+
'hook' => String,
|
|
6
|
+
'hookInstance' => String,
|
|
7
|
+
'context' => Hash
|
|
8
|
+
}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def fhir_authorization_required_fields
|
|
12
|
+
{
|
|
13
|
+
'access_token' => String,
|
|
14
|
+
'token_type' => String,
|
|
15
|
+
'expires_in' => Integer,
|
|
16
|
+
'scope' => String,
|
|
17
|
+
'subject' => String
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def hook_optional_fields
|
|
22
|
+
{
|
|
23
|
+
'fhirServer' => String,
|
|
24
|
+
'fhirAuthorization' => Hash,
|
|
25
|
+
'prefetch' => Hash
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def common_context_fields
|
|
30
|
+
{ 'userId' => String, 'patientId' => String }.freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def context_required_fields_by_hook
|
|
34
|
+
{
|
|
35
|
+
'appointment-book' => common_context_fields.merge('appointments' => Hash),
|
|
36
|
+
'encounter-start' => common_context_fields.merge('encounterId' => String),
|
|
37
|
+
'encounter-discharge' => common_context_fields.merge('encounterId' => String),
|
|
38
|
+
'order-select' => common_context_fields.merge('selections' => Array, 'draftOrders' => Hash),
|
|
39
|
+
'order-dispatch' => { 'patientId' => String, 'order' => String, 'performer' => String },
|
|
40
|
+
'order-sign' => common_context_fields.merge('draftOrders' => Hash)
|
|
41
|
+
}.freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def context_optional_fields_by_hook
|
|
45
|
+
{
|
|
46
|
+
'appointment-book' => { 'encounterId' => String },
|
|
47
|
+
'order-select' => { 'encounterId' => String },
|
|
48
|
+
'order-dispatch' => { 'task' => Hash },
|
|
49
|
+
'order-sign' => { 'encounterId' => String }
|
|
50
|
+
}.freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def optional_field_resource_types
|
|
54
|
+
{
|
|
55
|
+
'task' => 'Task'
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def context_user_types_by_hook
|
|
60
|
+
shared_resources = ['Practitioner', 'PractitionerRole']
|
|
61
|
+
{
|
|
62
|
+
'appointment-book' => ['Patient', 'RelatedPerson'].concat(shared_resources),
|
|
63
|
+
'encounter-start' => shared_resources,
|
|
64
|
+
'encounter-discharge' => shared_resources,
|
|
65
|
+
'order-select' => shared_resources,
|
|
66
|
+
'order-sign' => shared_resources
|
|
67
|
+
}.freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def structure_definition_map
|
|
71
|
+
{
|
|
72
|
+
'Practitioner' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-practitioner',
|
|
73
|
+
'PractitionerRole' => 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-practitionerrole',
|
|
74
|
+
'Patient' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-patient',
|
|
75
|
+
'Encounter' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-encounter',
|
|
76
|
+
'Appointment' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-appointment',
|
|
77
|
+
'DeviceRequest' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-devicerequest',
|
|
78
|
+
'MedicationRequest' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-medicationrequest',
|
|
79
|
+
'NutritionOrder' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-nutritionorder',
|
|
80
|
+
'ServiceRequest' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-servicerequest',
|
|
81
|
+
'VisionPrescription' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-visionprescription',
|
|
82
|
+
'Medication' => 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-medication',
|
|
83
|
+
'Device' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-device',
|
|
84
|
+
'CommunicationRequest' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-communicationrequest',
|
|
85
|
+
'Task' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-taskquestionnaire'
|
|
86
|
+
}.freeze
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def hook_request_required_fields_check(request_body, hook_name)
|
|
90
|
+
hook_required_fields.each do |field, type|
|
|
91
|
+
assert(request_body[field], "Hook request did not contain required field: `#{field}`")
|
|
92
|
+
assert(request_body[field].is_a?(type), "Hook request field #{field} is not of type #{type}")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
assert(request_body['hook'] == hook_name,
|
|
96
|
+
"The `hook` field should be #{hook_name}, but was #{request_body['hook']}")
|
|
97
|
+
|
|
98
|
+
return unless request_body['fhirAuthorization']
|
|
99
|
+
|
|
100
|
+
assert(request_body['fhirServer'],
|
|
101
|
+
'Missing `fhirServer` field: If `fhirAuthorization` is provided, this field is REQUIRED.')
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def hook_request_fhir_auth_check(request_body)
|
|
105
|
+
if request_body['fhirAuthorization']
|
|
106
|
+
|
|
107
|
+
fhir_authorization = request_body['fhirAuthorization']
|
|
108
|
+
|
|
109
|
+
fhir_authorization_required_fields.each do |field, type|
|
|
110
|
+
assert(fhir_authorization[field], "`fhirAuthorization` did not contain required field: `#{field}`")
|
|
111
|
+
assert(fhir_authorization[field].is_a?(type), "`fhirAuthorization` field #{field} is not of type #{type}")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
assert(fhir_authorization['token_type'] == 'Bearer',
|
|
115
|
+
"`fhirAuthorization` `token_type` field is not set to 'Bearer'")
|
|
116
|
+
|
|
117
|
+
access_token = fhir_authorization['access_token']
|
|
118
|
+
|
|
119
|
+
scopes = fhir_authorization['scope'].split
|
|
120
|
+
|
|
121
|
+
if scopes.any? { |scope| scope.start_with?('patient/') }
|
|
122
|
+
info do
|
|
123
|
+
assert(fhir_authorization['patient'] && fhir_authorization['patient'].is_a?(String),
|
|
124
|
+
%(The `patient` field SHOULD be populated to identify the FHIR id of that patient when the granted
|
|
125
|
+
SMART scopes include patient scopes))
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
{ fhir_server_uri: request_body['fhirServer'], fhir_access_token: access_token }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def hook_request_optional_fields_check(request_body)
|
|
133
|
+
hook_optional_fields.each do |field, type|
|
|
134
|
+
info do
|
|
135
|
+
assert(request_body[field], "Hook request did not contain optional field: `#{field}`")
|
|
136
|
+
end
|
|
137
|
+
if request_body[field]
|
|
138
|
+
assert(request_body[field].is_a?(type), "Hook request field #{field} is not of type #{type}")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
hook_request_fhir_auth_check(request_body)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_presence_and_type(object, field_name, type, description = '')
|
|
145
|
+
value = object[field_name]
|
|
146
|
+
unless value
|
|
147
|
+
error_msg = "#{description} does not contain required field `#{field_name}`: #{description} `#{object}`."
|
|
148
|
+
add_message('error', error_msg)
|
|
149
|
+
return
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
is_valid_type = type == 'URL' ? valid_url?(value) : value.is_a?(type)
|
|
153
|
+
unless is_valid_type
|
|
154
|
+
error_msg = type == 'URL' ? 'is not a valid URL' : "is not of type `#{type}`"
|
|
155
|
+
add_message('error', "#{description} field `#{field_name}` #{error_msg}: #{description} `#{object}`.")
|
|
156
|
+
return
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
return unless value.blank?
|
|
160
|
+
|
|
161
|
+
error_msg = "#{description} field `#{field_name}` should not be an empty #{type}: #{description} `#{object}`."
|
|
162
|
+
add_message('error', error_msg)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def hook_request_context_check(context, hook_name)
|
|
166
|
+
required_fields = context_required_fields_by_hook[hook_name]
|
|
167
|
+
required_fields.each do |field, type|
|
|
168
|
+
validate_presence_and_type(context, field, type, "#{hook_name} request context")
|
|
169
|
+
end
|
|
170
|
+
context_validate_optional_fields(context, hook_name)
|
|
171
|
+
hook_specific_context_check(context, hook_name)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def hook_specific_context_check(context, hook_name)
|
|
175
|
+
case hook_name
|
|
176
|
+
when 'appointment-book'
|
|
177
|
+
appointment_book_context_check(context)
|
|
178
|
+
when 'encounter-start', 'encounter-discharge'
|
|
179
|
+
encounter_start_or_discharge_context_check(context, hook_name)
|
|
180
|
+
when 'order-select', 'order-sign'
|
|
181
|
+
order_select_or_sign_context_check(context, hook_name)
|
|
182
|
+
when 'order-dispatch'
|
|
183
|
+
order_dispatch_context_check(context)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def hook_user_type_check(context, hook_name)
|
|
188
|
+
supported_resource_types = context_user_types_by_hook[hook_name]
|
|
189
|
+
resource_reference_check(context['userId'], 'userId', supported_resource_types:)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def resource_reference_check(reference, field_name, supported_resource_types: nil)
|
|
193
|
+
return unless reference.is_a?(String) && valid_reference_format?(reference, field_name)
|
|
194
|
+
|
|
195
|
+
resource_type, resource_id = reference.split('/')
|
|
196
|
+
|
|
197
|
+
if supported_resource_types && !supported_resource_types.include?(resource_type)
|
|
198
|
+
error_msg = "Unsupported resource type: `#{field_name}` type should be one " \
|
|
199
|
+
"of the following: #{supported_resource_types.to_sentence}, but " \
|
|
200
|
+
"received #{resource_type}."
|
|
201
|
+
|
|
202
|
+
add_message('error', error_msg)
|
|
203
|
+
return
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
query_and_validate_id_field(resource_type, resource_id) if client_test? && !field_name.include?('selections')
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def valid_reference_format?(reference, field_name)
|
|
210
|
+
resource_type, resource_id = reference.split('/')
|
|
211
|
+
return true if resource_type.present? && resource_id.present?
|
|
212
|
+
|
|
213
|
+
add_message('error', "Invalid `#{field_name}` format. Expected `{resourceType}/{id}`, received `#{reference}`.")
|
|
214
|
+
false
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def id_only_fields_check(hook_name, context, id_fields)
|
|
218
|
+
id_fields.each do |field|
|
|
219
|
+
resource_id = context[field]
|
|
220
|
+
next unless resource_id.is_a?(String) && valid_id_format?(field, hook_name, resource_id)
|
|
221
|
+
|
|
222
|
+
if client_test?
|
|
223
|
+
resource_type = field.split(/(?=[A-Z])/).first.capitalize
|
|
224
|
+
query_and_validate_id_field(resource_type, resource_id)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def valid_id_format?(field, hook_name, resource_id)
|
|
230
|
+
if resource_id.include?('/')
|
|
231
|
+
error_msg = "`#{field}` in #{hook_name} context should be a plain ID, not a reference. Got: `#{resource_id}`."
|
|
232
|
+
add_message('error', error_msg)
|
|
233
|
+
false
|
|
234
|
+
end
|
|
235
|
+
true
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def bundle_entries_check(context, context_field_name, bundle, resource_types, status = nil)
|
|
239
|
+
target_resources = bundle.entry.map(&:resource).select { |r| resource_types.include?(r.resourceType) }
|
|
240
|
+
unless target_resources.present?
|
|
241
|
+
error_msg = "`#{context_field_name}` bundle must contain at least one of the expected resource types: " \
|
|
242
|
+
"#{resource_types.to_sentence}. In Context `#{context}`"
|
|
243
|
+
add_message('error', error_msg)
|
|
244
|
+
return
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
status_check(context, context_field_name, status, target_resources)
|
|
248
|
+
|
|
249
|
+
target_resources.each do |resource|
|
|
250
|
+
resource_is_valid?(resource:, profile_url: structure_definition_map[resource.resourceType])
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def status_check(context, context_field_name, status, resources)
|
|
255
|
+
return unless status && !resources.all? { |resource| resource.status == status }
|
|
256
|
+
|
|
257
|
+
error_msg = "All #{resources.map(&:resourceType).uniq.to_sentence} resources in `#{context_field_name}` " \
|
|
258
|
+
"bundle must have a `#{status}` status. In Context `#{context}`"
|
|
259
|
+
add_message('error', error_msg)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def parse_fhir_bundle_from_context(context_field_name, context)
|
|
263
|
+
fhir_bundle = FHIR.from_contents(context[context_field_name].to_json)
|
|
264
|
+
unless fhir_bundle
|
|
265
|
+
error_msg = "`#{context_field_name}` field is not a FHIR resource: `#{context[context_field_name]}`. " \
|
|
266
|
+
"In Context `#{context}`"
|
|
267
|
+
add_message('error', error_msg)
|
|
268
|
+
return
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
return fhir_bundle if fhir_bundle.is_a?(FHIR::Bundle)
|
|
272
|
+
|
|
273
|
+
error_msg = "Wrong context resource type: Expected `Bundle`, got `#{fhir_bundle.resourceType}`. " \
|
|
274
|
+
"In Context `#{context}`"
|
|
275
|
+
add_message('error', error_msg)
|
|
276
|
+
nil
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def context_selections_check(context, selections, order_refs, expected_resource_types)
|
|
280
|
+
return unless selections.is_a?(Array)
|
|
281
|
+
|
|
282
|
+
selections.each do |reference|
|
|
283
|
+
resource_reference_check(reference, 'selections item', supported_resource_types: expected_resource_types)
|
|
284
|
+
next if order_refs.include?(reference)
|
|
285
|
+
|
|
286
|
+
error_msg = '`selections` field must reference FHIR resources in `draftOrders`. ' \
|
|
287
|
+
"#{reference} is not in `draftOrders`. In Context `#{context}`"
|
|
288
|
+
add_message('error', error_msg)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def appointment_book_context_check(context)
|
|
293
|
+
hook_user_type_check(context, 'appointment-book')
|
|
294
|
+
id_only_fields_check('appointment-book', context, ['patientId'])
|
|
295
|
+
|
|
296
|
+
appointment_bundle = parse_fhir_bundle_from_context('appointments', context)
|
|
297
|
+
return unless appointment_bundle
|
|
298
|
+
|
|
299
|
+
expected_resource_types = ['Appointment']
|
|
300
|
+
bundle_entries_check(context, 'appointments', appointment_bundle, expected_resource_types, 'proposed')
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def encounter_start_or_discharge_context_check(context, hook_name)
|
|
304
|
+
hook_user_type_check(context, hook_name)
|
|
305
|
+
id_only_fields_check(hook_name, context, ['patientId', 'encounterId'])
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def order_select_or_sign_context_check(context, hook_name)
|
|
309
|
+
hook_user_type_check(context, hook_name)
|
|
310
|
+
id_only_fields_check(hook_name, context, ['patientId'])
|
|
311
|
+
|
|
312
|
+
draft_orders_bundle = parse_fhir_bundle_from_context('draftOrders', context)
|
|
313
|
+
return unless draft_orders_bundle
|
|
314
|
+
|
|
315
|
+
expected_resource_types = [
|
|
316
|
+
'DeviceRequest', 'MedicationRequest', 'NutritionOrder',
|
|
317
|
+
'ServiceRequest', 'VisionPrescription'
|
|
318
|
+
]
|
|
319
|
+
|
|
320
|
+
bundle_entries_check(context, 'draftOrders', draft_orders_bundle, expected_resource_types)
|
|
321
|
+
|
|
322
|
+
return unless hook_name == 'order-select'
|
|
323
|
+
|
|
324
|
+
order_refs = draft_orders_bundle.entry.map(&:resource).map do |resource|
|
|
325
|
+
"#{resource.resourceType}/#{resource.id}"
|
|
326
|
+
end
|
|
327
|
+
context_selections_check(context, context['selections'], order_refs, expected_resource_types)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def order_dispatch_context_check(context)
|
|
331
|
+
id_only_fields_check('order-dispatch', context, ['patientId'])
|
|
332
|
+
order_supported_resource_type = [
|
|
333
|
+
'DeviceRequest', 'MedicationRequest', 'NutritionOrder',
|
|
334
|
+
'ServiceRequest', 'VisionPrescription'
|
|
335
|
+
]
|
|
336
|
+
resource_reference_check(context['order'], 'order', supported_resource_types: order_supported_resource_type)
|
|
337
|
+
resource_reference_check(context['performer'], 'performer')
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def no_error_validation(message)
|
|
341
|
+
assert messages.none? { |msg| msg[:type] == 'error' }, message
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def valid_url?(url)
|
|
345
|
+
uri = URI.parse(url)
|
|
346
|
+
uri.host.present? && ['http', 'https'].include?(uri.scheme)
|
|
347
|
+
rescue URI::InvalidURIError
|
|
348
|
+
false
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def query_and_validate_id_field(resource_type, resource_id)
|
|
352
|
+
fhir_read(resource_type, resource_id)
|
|
353
|
+
status = request.response[:status]
|
|
354
|
+
unless status == 200
|
|
355
|
+
add_message('error', "Unexpected response status: expected 200, but received #{status}")
|
|
356
|
+
return
|
|
357
|
+
end
|
|
358
|
+
unless resource.resourceType == resource_type
|
|
359
|
+
add_message('error', "Unexpected resource type: Expected `#{resource_type}`. Got `#{resource.resourceType}`.")
|
|
360
|
+
return
|
|
361
|
+
end
|
|
362
|
+
unless resource.id == resource_id
|
|
363
|
+
add_message('error', "Requested resource with id #{resource_id}, received resource with id #{resource.id}")
|
|
364
|
+
return
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
profile_url = hook_name == 'order-dispatch' ? nil : structure_definition_map[resource_type]
|
|
368
|
+
resource_is_valid?(profile_url:)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def context_validate_optional_fields(hook_context, hook_name)
|
|
372
|
+
hook_optional_context_fields = context_optional_fields_by_hook[hook_name]
|
|
373
|
+
return unless hook_optional_context_fields.present?
|
|
374
|
+
|
|
375
|
+
hook_optional_context_fields.each do |field, type|
|
|
376
|
+
validate_presence_and_type(hook_context, field, type, "#{hook_name} request context") if hook_context[field]
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
optional_field_keys = hook_optional_context_fields.keys
|
|
380
|
+
if optional_field_keys.include?('encounterId') && hook_context['encounterId'].present?
|
|
381
|
+
id_only_fields_check(hook_name, hook_context, ['encounterId'])
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
validate_hash_fields(hook_context, optional_field_keys)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def validate_hash_fields(hook_context, hook_optional_context_fields)
|
|
388
|
+
hash_context_fields = hook_context.select do |field, value|
|
|
389
|
+
value.is_a?(Hash) && hook_optional_context_fields.include?(field)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
return if hash_context_fields.empty?
|
|
393
|
+
|
|
394
|
+
hash_context_fields.each do |field, entry|
|
|
395
|
+
resource_json = entry.to_json
|
|
396
|
+
fhir_resource = FHIR.from_contents(resource_json)
|
|
397
|
+
unless fhir_resource
|
|
398
|
+
add_message('error', "Field `#{field}` is not a FHIR resource.")
|
|
399
|
+
next
|
|
400
|
+
end
|
|
401
|
+
resource_type = optional_field_resource_types[field]
|
|
402
|
+
unless fhir_resource.resourceType == resource_type
|
|
403
|
+
add_message('error', "Field `#{field}` must be a `#{resource_type}`. Got `#{fhir_resource.resourceType}`.")
|
|
404
|
+
next
|
|
405
|
+
end
|
|
406
|
+
resource_is_valid?(resource: fhir_resource)
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module DaVinciCRDTestKit
|
|
2
|
+
class JWKS
|
|
3
|
+
class << self
|
|
4
|
+
def jwks_json
|
|
5
|
+
@jwks_json ||=
|
|
6
|
+
JSON.pretty_generate(
|
|
7
|
+
{ keys: jwks.export[:keys].select { |key| key[:key_ops]&.include?('verify') } }
|
|
8
|
+
)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def default_jwks_path
|
|
12
|
+
@default_jwks_path ||= File.join(__dir__, 'crd_jwks.json')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def jwks_path
|
|
16
|
+
@jwks_path ||=
|
|
17
|
+
ENV.fetch('CRD_JWKS_PATH', default_jwks_path)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def jwks
|
|
21
|
+
@jwks ||= JWT::JWK::Set.new(JSON.parse(File.read(jwks_path)))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require_relative 'jwks'
|
|
2
|
+
|
|
3
|
+
module DaVinciCRDTestKit
|
|
4
|
+
class JwtHelper
|
|
5
|
+
def self.build(...)
|
|
6
|
+
new(...).signed_jwt
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.decode_jwt(token, jwks_hash, kid = nil)
|
|
10
|
+
jwks = JWT::JWK::Set.new(jwks_hash)
|
|
11
|
+
jwks.filter! { |key| key[:use] == 'sig' }
|
|
12
|
+
algorithms = jwks.map { |key| key[:alg] }.compact.uniq
|
|
13
|
+
begin
|
|
14
|
+
JWT.decode(token, kid, true, algorithms:, jwks:)
|
|
15
|
+
rescue StandardError => e
|
|
16
|
+
raise Inferno::Exceptions::AssertionException, e.message
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_reader :aud, :encryption_method, :exp, :iat, :iss, :jku, :jti, :kid
|
|
21
|
+
|
|
22
|
+
def initialize(
|
|
23
|
+
aud:,
|
|
24
|
+
encryption_method:,
|
|
25
|
+
iss:,
|
|
26
|
+
jku:,
|
|
27
|
+
iat: Time.now.to_i,
|
|
28
|
+
exp: 5.minutes.from_now.to_i,
|
|
29
|
+
jti: SecureRandom.hex(32),
|
|
30
|
+
kid: nil
|
|
31
|
+
)
|
|
32
|
+
@aud = aud
|
|
33
|
+
@encryption_method = encryption_method
|
|
34
|
+
@iss = iss
|
|
35
|
+
@jku = jku
|
|
36
|
+
@iat = iat
|
|
37
|
+
@exp = exp
|
|
38
|
+
@jti = jti
|
|
39
|
+
@kid = kid
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def private_key
|
|
43
|
+
@private_key ||= JWKS.jwks
|
|
44
|
+
.select { |key| key[:key_ops]&.include?('sign') }
|
|
45
|
+
.select { |key| key[:alg] == encryption_method }
|
|
46
|
+
.find { |key| !kid || key[:kid] == kid }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def signing_key
|
|
50
|
+
if private_key.nil?
|
|
51
|
+
raise Inferno::Exceptions::AssertionException,
|
|
52
|
+
"No signing key found for inputs: encryption method = '#{encryption_method}' and kid = '#{kid}'"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@private_key.signing_key
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def jwt_header
|
|
59
|
+
{ alg: encryption_method, typ: 'JWT', kid: key_id, jku: }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def jwt_payload
|
|
63
|
+
{ iss:, aud:, exp:, iat:, jti: }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def key_id
|
|
67
|
+
@private_key['kid']
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def signed_jwt
|
|
71
|
+
@signed_jwt ||= JWT.encode jwt_payload, signing_key, encryption_method, jwt_header
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|