davinci_crd_test_kit 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +201 -0
  3. data/lib/davinci_crd_test_kit/card_responses/companions_prerequisites.json +58 -0
  4. data/lib/davinci_crd_test_kit/card_responses/create_update_coverage_information.json +20 -0
  5. data/lib/davinci_crd_test_kit/card_responses/external_reference.json +21 -0
  6. data/lib/davinci_crd_test_kit/card_responses/instructions.json +14 -0
  7. data/lib/davinci_crd_test_kit/card_responses/launch_smart_app.json +21 -0
  8. data/lib/davinci_crd_test_kit/card_responses/propose_alternate_request.json +71 -0
  9. data/lib/davinci_crd_test_kit/card_responses/request_form_completion.json +227 -0
  10. data/lib/davinci_crd_test_kit/cards_validation.rb +234 -0
  11. data/lib/davinci_crd_test_kit/client_fhir_api_group.rb +762 -0
  12. data/lib/davinci_crd_test_kit/client_hook_request_validation.rb +15 -0
  13. data/lib/davinci_crd_test_kit/client_hooks_group.rb +706 -0
  14. data/lib/davinci_crd_test_kit/client_tests/appointment_book_receive_request_test.rb +71 -0
  15. data/lib/davinci_crd_test_kit/client_tests/client_display_cards_attest.rb +48 -0
  16. data/lib/davinci_crd_test_kit/client_tests/client_fhir_api_create_test.rb +40 -0
  17. data/lib/davinci_crd_test_kit/client_tests/client_fhir_api_read_test.rb +39 -0
  18. data/lib/davinci_crd_test_kit/client_tests/client_fhir_api_search_test.rb +232 -0
  19. data/lib/davinci_crd_test_kit/client_tests/client_fhir_api_update_test.rb +40 -0
  20. data/lib/davinci_crd_test_kit/client_tests/client_fhir_api_validation_test.rb +60 -0
  21. data/lib/davinci_crd_test_kit/client_tests/decode_auth_token_test.rb +40 -0
  22. data/lib/davinci_crd_test_kit/client_tests/encounter_discharge_receive_request_test.rb +68 -0
  23. data/lib/davinci_crd_test_kit/client_tests/encounter_start_receive_request_test.rb +68 -0
  24. data/lib/davinci_crd_test_kit/client_tests/hook_request_optional_fields_test.rb +41 -0
  25. data/lib/davinci_crd_test_kit/client_tests/hook_request_required_fields_test.rb +40 -0
  26. data/lib/davinci_crd_test_kit/client_tests/hook_request_valid_context_test.rb +63 -0
  27. data/lib/davinci_crd_test_kit/client_tests/hook_request_valid_prefetch_test.rb +151 -0
  28. data/lib/davinci_crd_test_kit/client_tests/order_dispatch_receive_request_test.rb +79 -0
  29. data/lib/davinci_crd_test_kit/client_tests/order_select_receive_request_test.rb +76 -0
  30. data/lib/davinci_crd_test_kit/client_tests/order_sign_receive_request_test.rb +79 -0
  31. data/lib/davinci_crd_test_kit/client_tests/retrieve_jwks_test.rb +65 -0
  32. data/lib/davinci_crd_test_kit/client_tests/token_header_test.rb +34 -0
  33. data/lib/davinci_crd_test_kit/client_tests/token_payload_test.rb +61 -0
  34. data/lib/davinci_crd_test_kit/crd_client_suite.rb +156 -0
  35. data/lib/davinci_crd_test_kit/crd_jwks.json +59 -0
  36. data/lib/davinci_crd_test_kit/crd_options.rb +9 -0
  37. data/lib/davinci_crd_test_kit/crd_server_suite.rb +115 -0
  38. data/lib/davinci_crd_test_kit/ext/inferno_core/runnable.rb +22 -0
  39. data/lib/davinci_crd_test_kit/hook_request_field_validation.rb +410 -0
  40. data/lib/davinci_crd_test_kit/jwks.rb +25 -0
  41. data/lib/davinci_crd_test_kit/jwt_helper.rb +74 -0
  42. data/lib/davinci_crd_test_kit/mock_service_response.rb +421 -0
  43. data/lib/davinci_crd_test_kit/routes/cds-services.json +74 -0
  44. data/lib/davinci_crd_test_kit/routes/cds_services_discovery_handler.rb +18 -0
  45. data/lib/davinci_crd_test_kit/routes/hook_request_endpoint.rb +93 -0
  46. data/lib/davinci_crd_test_kit/routes/jwk_set_endpoint_handler.rb +15 -0
  47. data/lib/davinci_crd_test_kit/server_appointment_book_group.rb +173 -0
  48. data/lib/davinci_crd_test_kit/server_discovery_group.rb +59 -0
  49. data/lib/davinci_crd_test_kit/server_encounter_discharge_group.rb +144 -0
  50. data/lib/davinci_crd_test_kit/server_encounter_start_group.rb +144 -0
  51. data/lib/davinci_crd_test_kit/server_hook_request_validation.rb +15 -0
  52. data/lib/davinci_crd_test_kit/server_hooks_group.rb +69 -0
  53. data/lib/davinci_crd_test_kit/server_order_dispatch_group.rb +173 -0
  54. data/lib/davinci_crd_test_kit/server_order_select_group.rb +169 -0
  55. data/lib/davinci_crd_test_kit/server_order_sign_group.rb +198 -0
  56. data/lib/davinci_crd_test_kit/server_required_card_response_validation_group.rb +23 -0
  57. data/lib/davinci_crd_test_kit/server_tests/additional_orders_validation_test.rb +70 -0
  58. data/lib/davinci_crd_test_kit/server_tests/card_optional_fields_validation_test.rb +47 -0
  59. data/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_across_hooks_validation_test.rb +32 -0
  60. data/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_received_test.rb +58 -0
  61. data/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_validation_test.rb +121 -0
  62. data/lib/davinci_crd_test_kit/server_tests/create_or_update_coverage_info_response_validation_test.rb +72 -0
  63. data/lib/davinci_crd_test_kit/server_tests/discovery_endpoint_test.rb +88 -0
  64. data/lib/davinci_crd_test_kit/server_tests/discovery_services_validation_test.rb +65 -0
  65. data/lib/davinci_crd_test_kit/server_tests/external_reference_card_across_hooks_validation_test.rb +28 -0
  66. data/lib/davinci_crd_test_kit/server_tests/external_reference_card_validation_test.rb +36 -0
  67. data/lib/davinci_crd_test_kit/server_tests/form_completion_response_validation_test.rb +79 -0
  68. data/lib/davinci_crd_test_kit/server_tests/instructions_card_received_across_hooks_test.rb +25 -0
  69. data/lib/davinci_crd_test_kit/server_tests/instructions_card_received_test.rb +28 -0
  70. data/lib/davinci_crd_test_kit/server_tests/launch_smart_app_card_validation_test.rb +38 -0
  71. data/lib/davinci_crd_test_kit/server_tests/propose_alternate_request_card_validation_test.rb +65 -0
  72. data/lib/davinci_crd_test_kit/server_tests/service_call_test.rb +86 -0
  73. data/lib/davinci_crd_test_kit/server_tests/service_request_context_validation_test.rb +30 -0
  74. data/lib/davinci_crd_test_kit/server_tests/service_request_optional_fields_validation_test.rb +41 -0
  75. data/lib/davinci_crd_test_kit/server_tests/service_request_required_fields_validation_test.rb +43 -0
  76. data/lib/davinci_crd_test_kit/server_tests/service_response_validation_test.rb +82 -0
  77. data/lib/davinci_crd_test_kit/suggestion_actions_validation.rb +123 -0
  78. data/lib/davinci_crd_test_kit/tags.rb +8 -0
  79. data/lib/davinci_crd_test_kit/test_helper.rb +23 -0
  80. data/lib/davinci_crd_test_kit/urls.rb +52 -0
  81. data/lib/davinci_crd_test_kit/version.rb +3 -0
  82. data/lib/davinci_crd_test_kit.rb +2 -0
  83. 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