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.
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