From c1ea9a4b4429503cbf94565d7fae59fbe1e5f830 Mon Sep 17 00:00:00 2001 From: "Axel Garcia K." Date: Thu, 19 Jun 2025 16:40:46 +0100 Subject: [PATCH 1/6] NRL-512 Initial implementation for rejecting HEAD requests --- .../modules/api_gateway/api_gateway.tf | 3 +- .../modules/api_gateway/method_responses.tf | 76 +++++++++++++++++++ .../consumer/headOnAnyEndpoint.feature | 9 +++ tests/features/steps/2_request.py | 21 +++++ tests/features/steps/3_assert.py | 14 ++++ tests/utilities/api_clients.py | 16 ++++ 6 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 terraform/infrastructure/modules/api_gateway/method_responses.tf create mode 100644 tests/features/consumer/headOnAnyEndpoint.feature diff --git a/terraform/infrastructure/modules/api_gateway/api_gateway.tf b/terraform/infrastructure/modules/api_gateway/api_gateway.tf index 40dca31cf..8ab9ccf75 100644 --- a/terraform/infrastructure/modules/api_gateway/api_gateway.tf +++ b/terraform/infrastructure/modules/api_gateway/api_gateway.tf @@ -60,7 +60,8 @@ resource "aws_api_gateway_deployment" "api_gateway_deployment" { } depends_on = [ - aws_api_gateway_rest_api.api_gateway_rest_api + aws_api_gateway_rest_api.api_gateway_rest_api, + aws_api_gateway_integration_response.head_integration_response ] } diff --git a/terraform/infrastructure/modules/api_gateway/method_responses.tf b/terraform/infrastructure/modules/api_gateway/method_responses.tf new file mode 100644 index 000000000..9dff3a4f6 --- /dev/null +++ b/terraform/infrastructure/modules/api_gateway/method_responses.tf @@ -0,0 +1,76 @@ +# Define a reusable map of allowed methods for each path +locals { + endpoint_allowed_methods = { + "/DocumentReference" = "GET,POST,PUT" + # "/DocumentReference/_search" = "POST" + # "/DocumentReference/{id}" = "GET" + } +} + +data "aws_api_gateway_resource" "resource" { + for_each = local.endpoint_allowed_methods + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + path = each.key + + depends_on = [aws_api_gateway_rest_api.api_gateway_rest_api] +} + +# Add HEAD method to each resource with 405 response +resource "aws_api_gateway_method" "head_method" { + for_each = local.endpoint_allowed_methods + + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + resource_id = data.aws_api_gateway_resource.resource[each.key].id + http_method = "HEAD" + authorization = "NONE" +} + +resource "aws_api_gateway_integration" "head_integration" { + for_each = local.endpoint_allowed_methods + + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + resource_id = data.aws_api_gateway_resource.resource[each.key].id + http_method = aws_api_gateway_method.head_method[each.key].http_method + type = "MOCK" + # passthrough_behavior = "WHEN_NO_TEMPLATES" + passthrough_behavior = "WHEN_NO_MATCH" + + request_templates = { + "application/json" = <<-EOF + { "statusCode": 405 } + EOF + "application/json+fhir" = <<-EOF + { "statusCode": 405 } + EOF + "application/fhir+json" = <<-EOF + { "statusCode": 405 } + EOF + } +} + +resource "aws_api_gateway_method_response" "head_method_response" { + for_each = local.endpoint_allowed_methods + + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + resource_id = data.aws_api_gateway_resource.resource[each.key].id + http_method = aws_api_gateway_method.head_method[each.key].http_method + status_code = "405" + + response_parameters = { + "method.response.header.Allow" = true + } +} + +resource "aws_api_gateway_integration_response" "head_integration_response" { + for_each = local.endpoint_allowed_methods + + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + resource_id = data.aws_api_gateway_resource.resource[each.key].id + http_method = aws_api_gateway_method.head_method[each.key].http_method + status_code = aws_api_gateway_method_response.head_method_response[each.key].status_code + selection_pattern = "" # default catch-all + + response_parameters = { + "method.response.header.Allow" = "'${each.value}'" + } +} diff --git a/tests/features/consumer/headOnAnyEndpoint.feature b/tests/features/consumer/headOnAnyEndpoint.feature new file mode 100644 index 000000000..abd229263 --- /dev/null +++ b/tests/features/consumer/headOnAnyEndpoint.feature @@ -0,0 +1,9 @@ +Feature: Consumer - HEAD Requests + + @custom_tag + Scenario: DocumentReference with HEAD fails + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When consumer 'RX898' sends HEAD request to DocumentReference endpoint + Then the response status code is 405 + And the response has an empty body + And the Allow header is 'GET' diff --git a/tests/features/steps/2_request.py b/tests/features/steps/2_request.py index a904982a7..4a3e5fcab 100644 --- a/tests/features/steps/2_request.py +++ b/tests/features/steps/2_request.py @@ -76,6 +76,27 @@ def consumer_read_document_reference_step( context.response = client.read(doc_ref_id) +@when("consumer '{ods_code}' sends HEAD request to {endpoint} endpoint") +def consumer_head_request_step(context: Context, ods_code: str, endpoint: str): + client = consumer_client_from_context(context, ods_code) + context.response = client.head(endpoint) + + +@when( + "consumer '{ods_code}' sends HEAD request to '{endpoint}' endpoint with parameters" +) +def consumer_head_request_step_with_parameters( + context: Context, ods_code: str, endpoint: str +): + if not context.table: + raise ValueError("No search query table provided") + + items = {row["parameter"]: row["value"] for row in context.table} + + client = consumer_client_from_context(context, ods_code) + context.response = client.head(endpoint, items) + + @when("producer '{ods_code}' creates a DocumentReference with values") def create_post_document_reference_step(context: Context, ods_code: str): client = producer_client_from_context(context, ods_code) diff --git a/tests/features/steps/3_assert.py b/tests/features/steps/3_assert.py index 445f2268d..2dfa08291 100644 --- a/tests/features/steps/3_assert.py +++ b/tests/features/steps/3_assert.py @@ -51,6 +51,20 @@ def assert_bundle_step(context: Context, bundle_type: str): context.bundle = Bundle.model_validate(body) +@then("the response has an empty body") +def assert_empty_body_step(context: Context): + """ + Asserts that the response body is empty. + """ + assert context.response.text == "", format_error( + "Response body is not empty", + "empty", + context.response.text, + context.response.text, + ) + context.bundle = None + + @then("the Bundle has a total of {total}") def assert_bundle_total_step(context: Context, total: str): assert ( diff --git a/tests/utilities/api_clients.py b/tests/utilities/api_clients.py index 21b91f9c5..eef6418a0 100644 --- a/tests/utilities/api_clients.py +++ b/tests/utilities/api_clients.py @@ -192,6 +192,22 @@ def search_post( cert=self.config.client_cert, ) + @retry_if([502]) + def head( + self, + endpoint: str, + extra_params: dict[str, str] | None = None, + ) -> Response: + params = {**(extra_params or {})} + url = f"{self.api_url}/{endpoint}" + headers = {**self.request_headers, "Content-Type": "application/json"} + return requests.head( + url, + params=params, + headers=headers, + cert=self.config.client_cert, + ) + @retry_if([502]) def read_capability_statement(self) -> Response: return requests.get( From 81a286e1121a1eb19d373abd277cab97e38c343b Mon Sep 17 00:00:00 2001 From: "Axel Garcia K." Date: Thu, 19 Jun 2025 17:14:38 +0100 Subject: [PATCH 2/6] NRL-512 All endpoints and integration tests working --- terraform/infrastructure/api_gateway.tf | 9 +++- .../modules/api_gateway/method_responses.tf | 18 ++----- .../modules/api_gateway/vars.tf | 4 ++ .../consumer/headOnAnyEndpoint.feature | 10 +++- .../producer/headOnAnyEndpoint.feature | 15 ++++++ tests/features/steps/2_request.py | 50 +++++++++++-------- tests/utilities/api_clients.py | 16 ++++++ 7 files changed, 85 insertions(+), 37 deletions(-) create mode 100644 tests/features/producer/headOnAnyEndpoint.feature diff --git a/terraform/infrastructure/api_gateway.tf b/terraform/infrastructure/api_gateway.tf index 2e6a42fbd..e7f045897 100644 --- a/terraform/infrastructure/api_gateway.tf +++ b/terraform/infrastructure/api_gateway.tf @@ -10,6 +10,10 @@ module "consumer__gateway" { method_readDocumentReference = "arn:aws:apigateway:eu-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-2:${local.aws_account_id}:function:${substr("${local.prefix}--api--consumer--readDocumentReference", 0, 64)}/invocations" method_status = "arn:aws:apigateway:eu-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-2:${local.aws_account_id}:function:${substr("${local.prefix}--api--consumer--status", 0, 64)}/invocations" } + endpoint_allowed_methods = { + "/DocumentReference" = "GET", + "/DocumentReference/{id}" = "GET" + } kms_key_id = module.kms__cloudwatch.kms_arn domain = local.apis.domain path = local.apis.consumer.path @@ -35,7 +39,10 @@ module "producer__gateway" { method_deleteDocumentReference = "arn:aws:apigateway:eu-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-2:${local.aws_account_id}:function:${substr("${local.prefix}--api--producer--deleteDocumentReference", 0, 64)}/invocations" method_status = "arn:aws:apigateway:eu-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-2:${local.aws_account_id}:function:${substr("${local.prefix}--api--producer--status", 0, 64)}/invocations" } - + endpoint_allowed_methods = { + "/DocumentReference" = "GET,POST", + "/DocumentReference/{id}" = "GET,DELETE" + } kms_key_id = module.kms__cloudwatch.kms_arn domain = local.apis.domain path = local.apis.producer.path diff --git a/terraform/infrastructure/modules/api_gateway/method_responses.tf b/terraform/infrastructure/modules/api_gateway/method_responses.tf index 9dff3a4f6..d2eadc062 100644 --- a/terraform/infrastructure/modules/api_gateway/method_responses.tf +++ b/terraform/infrastructure/modules/api_gateway/method_responses.tf @@ -1,14 +1,6 @@ # Define a reusable map of allowed methods for each path -locals { - endpoint_allowed_methods = { - "/DocumentReference" = "GET,POST,PUT" - # "/DocumentReference/_search" = "POST" - # "/DocumentReference/{id}" = "GET" - } -} - data "aws_api_gateway_resource" "resource" { - for_each = local.endpoint_allowed_methods + for_each = var.endpoint_allowed_methods rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id path = each.key @@ -17,7 +9,7 @@ data "aws_api_gateway_resource" "resource" { # Add HEAD method to each resource with 405 response resource "aws_api_gateway_method" "head_method" { - for_each = local.endpoint_allowed_methods + for_each = var.endpoint_allowed_methods rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id resource_id = data.aws_api_gateway_resource.resource[each.key].id @@ -26,7 +18,7 @@ resource "aws_api_gateway_method" "head_method" { } resource "aws_api_gateway_integration" "head_integration" { - for_each = local.endpoint_allowed_methods + for_each = var.endpoint_allowed_methods rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id resource_id = data.aws_api_gateway_resource.resource[each.key].id @@ -49,7 +41,7 @@ resource "aws_api_gateway_integration" "head_integration" { } resource "aws_api_gateway_method_response" "head_method_response" { - for_each = local.endpoint_allowed_methods + for_each = var.endpoint_allowed_methods rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id resource_id = data.aws_api_gateway_resource.resource[each.key].id @@ -62,7 +54,7 @@ resource "aws_api_gateway_method_response" "head_method_response" { } resource "aws_api_gateway_integration_response" "head_integration_response" { - for_each = local.endpoint_allowed_methods + for_each = var.endpoint_allowed_methods rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id resource_id = data.aws_api_gateway_resource.resource[each.key].id diff --git a/terraform/infrastructure/modules/api_gateway/vars.tf b/terraform/infrastructure/modules/api_gateway/vars.tf index 0a9d0c6f6..a71eda145 100644 --- a/terraform/infrastructure/modules/api_gateway/vars.tf +++ b/terraform/infrastructure/modules/api_gateway/vars.tf @@ -22,3 +22,7 @@ variable "retention" { default = 90 type = number } + +variable "endpoint_allowed_methods" { + type = map(string) +} diff --git a/tests/features/consumer/headOnAnyEndpoint.feature b/tests/features/consumer/headOnAnyEndpoint.feature index abd229263..dad8bb03a 100644 --- a/tests/features/consumer/headOnAnyEndpoint.feature +++ b/tests/features/consumer/headOnAnyEndpoint.feature @@ -1,9 +1,15 @@ Feature: Consumer - HEAD Requests - @custom_tag Scenario: DocumentReference with HEAD fails Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API - When consumer 'RX898' sends HEAD request to DocumentReference endpoint + When consumer 'RX898' sends HEAD request to 'DocumentReference' endpoint + Then the response status code is 405 + And the response has an empty body + And the Allow header is 'GET' + + Scenario: DocumentReference/{id} with HEAD fails + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When consumer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint Then the response status code is 405 And the response has an empty body And the Allow header is 'GET' diff --git a/tests/features/producer/headOnAnyEndpoint.feature b/tests/features/producer/headOnAnyEndpoint.feature new file mode 100644 index 000000000..fec946053 --- /dev/null +++ b/tests/features/producer/headOnAnyEndpoint.feature @@ -0,0 +1,15 @@ +Feature: Producer - HEAD Requests + + Scenario: DocumentReference with HEAD fails + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When producer 'RX898' sends HEAD request to 'DocumentReference' endpoint + Then the response status code is 405 + And the response has an empty body + And the Allow header is 'GET,POST' + + Scenario: DocumentReference/{id} with HEAD fails + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When producer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint + Then the response status code is 405 + And the response has an empty body + And the Allow header is 'GET,DELETE' diff --git a/tests/features/steps/2_request.py b/tests/features/steps/2_request.py index 4a3e5fcab..8ef8f46db 100644 --- a/tests/features/steps/2_request.py +++ b/tests/features/steps/2_request.py @@ -76,27 +76,6 @@ def consumer_read_document_reference_step( context.response = client.read(doc_ref_id) -@when("consumer '{ods_code}' sends HEAD request to {endpoint} endpoint") -def consumer_head_request_step(context: Context, ods_code: str, endpoint: str): - client = consumer_client_from_context(context, ods_code) - context.response = client.head(endpoint) - - -@when( - "consumer '{ods_code}' sends HEAD request to '{endpoint}' endpoint with parameters" -) -def consumer_head_request_step_with_parameters( - context: Context, ods_code: str, endpoint: str -): - if not context.table: - raise ValueError("No search query table provided") - - items = {row["parameter"]: row["value"] for row in context.table} - - client = consumer_client_from_context(context, ods_code) - context.response = client.head(endpoint, items) - - @when("producer '{ods_code}' creates a DocumentReference with values") def create_post_document_reference_step(context: Context, ods_code: str): client = producer_client_from_context(context, ods_code) @@ -268,3 +247,32 @@ def producer_search_document_reference_step(context: Context, ods_code: str): pointer_type=pointer_type, extra_params=items, ) + + +@when("{consumer_or_producer} '{ods_code}' sends HEAD request to '{endpoint}' endpoint") +def consumer_head_request_step( + context: Context, consumer_or_producer: str, ods_code: str, endpoint: str +): + if consumer_or_producer == "producer": + client = producer_client_from_context(context, ods_code) + elif consumer_or_producer == "consumer": + client = consumer_client_from_context(context, ods_code) + context.response = client.head(endpoint) + + +@when( + "{consumer_or_producer} '{ods_code}' sends HEAD request to '{endpoint}' endpoint with parameters" +) +def consumer_head_request_step_with_parameters( + context: Context, consumer_or_producer: str, ods_code: str, endpoint: str +): + if not context.table: + raise ValueError("No search query table provided") + + items = {row["parameter"]: row["value"] for row in context.table} + + if consumer_or_producer == "producer": + client = producer_client_from_context(context, ods_code) + elif consumer_or_producer == "consumer": + client = consumer_client_from_context(context, ods_code) + context.response = client.head(endpoint, items) diff --git a/tests/utilities/api_clients.py b/tests/utilities/api_clients.py index eef6418a0..95dbebe19 100644 --- a/tests/utilities/api_clients.py +++ b/tests/utilities/api_clients.py @@ -376,3 +376,19 @@ def read_capability_statement(self) -> Response: headers=self.request_headers, cert=self.config.client_cert, ) + + @retry_if([502]) + def head( + self, + endpoint: str, + extra_params: dict[str, str] | None = None, + ) -> Response: + params = {**(extra_params or {})} + url = f"{self.api_url}/{endpoint}" + headers = {**self.request_headers, "Content-Type": "application/json"} + return requests.head( + url, + params=params, + headers=headers, + cert=self.config.client_cert, + ) From 872fabaf9cdbace28bc604d479344f4cf5127409 Mon Sep 17 00:00:00 2001 From: "Axel Garcia K." Date: Thu, 19 Jun 2025 17:48:16 +0100 Subject: [PATCH 3/6] NRL-512 Fix methods for producer and auto deploy --- terraform/infrastructure/api_gateway.tf | 2 +- .../infrastructure/modules/api_gateway/api_gateway.tf | 8 +++++--- ...eadOnAnyEndpoint.feature => headOnGetEndpoint.feature} | 0 ...eadOnAnyEndpoint.feature => headOnGetEndpoint.feature} | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) rename tests/features/consumer/{headOnAnyEndpoint.feature => headOnGetEndpoint.feature} (100%) rename tests/features/producer/{headOnAnyEndpoint.feature => headOnGetEndpoint.feature} (93%) diff --git a/terraform/infrastructure/api_gateway.tf b/terraform/infrastructure/api_gateway.tf index e7f045897..224096e3e 100644 --- a/terraform/infrastructure/api_gateway.tf +++ b/terraform/infrastructure/api_gateway.tf @@ -41,7 +41,7 @@ module "producer__gateway" { } endpoint_allowed_methods = { "/DocumentReference" = "GET,POST", - "/DocumentReference/{id}" = "GET,DELETE" + "/DocumentReference/{id}" = "GET,PUT,DELETE" } kms_key_id = module.kms__cloudwatch.kms_arn domain = local.apis.domain diff --git a/terraform/infrastructure/modules/api_gateway/api_gateway.tf b/terraform/infrastructure/modules/api_gateway/api_gateway.tf index 8ab9ccf75..8191f692f 100644 --- a/terraform/infrastructure/modules/api_gateway/api_gateway.tf +++ b/terraform/infrastructure/modules/api_gateway/api_gateway.tf @@ -50,9 +50,11 @@ resource "aws_api_gateway_deployment" "api_gateway_deployment" { rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id triggers = { - redeployment = sha1(jsonencode(aws_api_gateway_rest_api.api_gateway_rest_api.body)) - resource_change = "${md5(file("${path.module}/api_gateway.tf"))}" - capabilities = sha1(var.capability_statement_content) + redeployment = sha1(jsonencode(aws_api_gateway_rest_api.api_gateway_rest_api.body)) + resource_change = "${md5(file("${path.module}/api_gateway.tf"))}" + capabilities = sha1(var.capability_statement_content) + method_responses_change = md5(file("${path.module}/method_responses.tf")) + parent_api_gateway_change = md5(file("${path.module}/../../api_gateway.tf")) } lifecycle { diff --git a/tests/features/consumer/headOnAnyEndpoint.feature b/tests/features/consumer/headOnGetEndpoint.feature similarity index 100% rename from tests/features/consumer/headOnAnyEndpoint.feature rename to tests/features/consumer/headOnGetEndpoint.feature diff --git a/tests/features/producer/headOnAnyEndpoint.feature b/tests/features/producer/headOnGetEndpoint.feature similarity index 93% rename from tests/features/producer/headOnAnyEndpoint.feature rename to tests/features/producer/headOnGetEndpoint.feature index fec946053..add4f6ddd 100644 --- a/tests/features/producer/headOnAnyEndpoint.feature +++ b/tests/features/producer/headOnGetEndpoint.feature @@ -12,4 +12,4 @@ Feature: Producer - HEAD Requests When producer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint Then the response status code is 405 And the response has an empty body - And the Allow header is 'GET,DELETE' + And the Allow header is 'GET,PUT,DELETE' From 8c024b32ac93f15d81e30b3d848482ef470c8195 Mon Sep 17 00:00:00 2001 From: "Axel Garcia K." Date: Thu, 19 Jun 2025 18:07:33 +0100 Subject: [PATCH 4/6] NRL-512 Remove unused step --- tests/features/steps/2_request.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/tests/features/steps/2_request.py b/tests/features/steps/2_request.py index 8ef8f46db..705e1e419 100644 --- a/tests/features/steps/2_request.py +++ b/tests/features/steps/2_request.py @@ -258,21 +258,3 @@ def consumer_head_request_step( elif consumer_or_producer == "consumer": client = consumer_client_from_context(context, ods_code) context.response = client.head(endpoint) - - -@when( - "{consumer_or_producer} '{ods_code}' sends HEAD request to '{endpoint}' endpoint with parameters" -) -def consumer_head_request_step_with_parameters( - context: Context, consumer_or_producer: str, ods_code: str, endpoint: str -): - if not context.table: - raise ValueError("No search query table provided") - - items = {row["parameter"]: row["value"] for row in context.table} - - if consumer_or_producer == "producer": - client = producer_client_from_context(context, ods_code) - elif consumer_or_producer == "consumer": - client = consumer_client_from_context(context, ods_code) - context.response = client.head(endpoint, items) From 8a3ea48eb47792c784123907038ca9cd02dd8b90 Mon Sep 17 00:00:00 2001 From: "Axel Garcia K." Date: Mon, 30 Jun 2025 18:13:30 +0100 Subject: [PATCH 5/6] NRL-512 An unsupported content-type will respond with 415, rename terraform file, add more tests --- .../modules/api_gateway/api_gateway.tf | 2 +- ...{method_responses.tf => head_responses.tf} | 11 ++-- .../consumer/headOnGetEndpoint.feature | 59 +++++++++++++++++-- .../producer/headOnGetEndpoint.feature | 56 ++++++++++++++++-- tests/features/steps/2_request.py | 11 +++- tests/features/steps/3_assert.py | 11 ++++ tests/utilities/api_clients.py | 6 +- 7 files changed, 137 insertions(+), 19 deletions(-) rename terraform/infrastructure/modules/api_gateway/{method_responses.tf => head_responses.tf} (86%) diff --git a/terraform/infrastructure/modules/api_gateway/api_gateway.tf b/terraform/infrastructure/modules/api_gateway/api_gateway.tf index 8191f692f..57ca9e75c 100644 --- a/terraform/infrastructure/modules/api_gateway/api_gateway.tf +++ b/terraform/infrastructure/modules/api_gateway/api_gateway.tf @@ -53,7 +53,7 @@ resource "aws_api_gateway_deployment" "api_gateway_deployment" { redeployment = sha1(jsonencode(aws_api_gateway_rest_api.api_gateway_rest_api.body)) resource_change = "${md5(file("${path.module}/api_gateway.tf"))}" capabilities = sha1(var.capability_statement_content) - method_responses_change = md5(file("${path.module}/method_responses.tf")) + head_responses_change = md5(file("${path.module}/head_responses.tf")) parent_api_gateway_change = md5(file("${path.module}/../../api_gateway.tf")) } diff --git a/terraform/infrastructure/modules/api_gateway/method_responses.tf b/terraform/infrastructure/modules/api_gateway/head_responses.tf similarity index 86% rename from terraform/infrastructure/modules/api_gateway/method_responses.tf rename to terraform/infrastructure/modules/api_gateway/head_responses.tf index d2eadc062..bc3c61ffe 100644 --- a/terraform/infrastructure/modules/api_gateway/method_responses.tf +++ b/terraform/infrastructure/modules/api_gateway/head_responses.tf @@ -20,12 +20,11 @@ resource "aws_api_gateway_method" "head_method" { resource "aws_api_gateway_integration" "head_integration" { for_each = var.endpoint_allowed_methods - rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id - resource_id = data.aws_api_gateway_resource.resource[each.key].id - http_method = aws_api_gateway_method.head_method[each.key].http_method - type = "MOCK" - # passthrough_behavior = "WHEN_NO_TEMPLATES" - passthrough_behavior = "WHEN_NO_MATCH" + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + resource_id = data.aws_api_gateway_resource.resource[each.key].id + http_method = aws_api_gateway_method.head_method[each.key].http_method + type = "MOCK" + passthrough_behavior = "WHEN_NO_TEMPLATES" request_templates = { "application/json" = <<-EOF diff --git a/tests/features/consumer/headOnGetEndpoint.feature b/tests/features/consumer/headOnGetEndpoint.feature index dad8bb03a..28ae7d72e 100644 --- a/tests/features/consumer/headOnGetEndpoint.feature +++ b/tests/features/consumer/headOnGetEndpoint.feature @@ -1,15 +1,66 @@ Feature: Consumer - HEAD Requests - Scenario: DocumentReference with HEAD fails + Scenario Outline: DocumentReference with HEAD fails (Content-Type: ) Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API - When consumer 'RX898' sends HEAD request to 'DocumentReference' endpoint + When consumer 'RX898' sends HEAD request to 'DocumentReference' endpoint with headers: + | header | value | + | Content-Type | | Then the response status code is 405 And the response has an empty body And the Allow header is 'GET' + And the Content-Length header is '0' - Scenario: DocumentReference/{id} with HEAD fails + Examples: + | content_type | + | application/json | + | application/json+fhir | + | application/fhir+json | + + Scenario Outline: DocumentReference/{id} with HEAD fails (Content-Type: ) Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API - When consumer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint + When consumer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint with headers: + | header | value | + | Content-Type | | Then the response status code is 405 And the response has an empty body And the Allow header is 'GET' + And the Content-Length header is '0' + + Examples: + | content_type | + | application/json | + | application/json+fhir | + | application/fhir+json | + + Scenario Outline: DocumentReference with HEAD fails with 415 with unsupported (Content-Type: ) + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When consumer 'RX898' sends HEAD request to 'DocumentReference' endpoint with headers: + | header | value | + | Content-Type | | + Then the response status code is 415 + And the Content-Type header is 'application/json' + And the Content-Length header is '0' + And the Allow header is not present + + Examples: + | content_type | + | application/notsupported | + | application/helloworld | + | application/harold | + + Scenario Outline: DocumentReference/{id} with HEAD fails (Content-Type: ) + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When consumer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint with headers: + | header | value | + | Content-Type | | + Then the response status code is 415 + And the response has an empty body + And the Content-Type header is 'application/json' + And the Content-Length header is '0' + And the Allow header is not present + + Examples: + | content_type | + | application/notsupported | + | application/helloworld | + | application/harold | diff --git a/tests/features/producer/headOnGetEndpoint.feature b/tests/features/producer/headOnGetEndpoint.feature index add4f6ddd..00fdb0d53 100644 --- a/tests/features/producer/headOnGetEndpoint.feature +++ b/tests/features/producer/headOnGetEndpoint.feature @@ -1,15 +1,63 @@ Feature: Producer - HEAD Requests - Scenario: DocumentReference with HEAD fails + Scenario Outline: DocumentReference with HEAD fails (Content-Type: ) Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API - When producer 'RX898' sends HEAD request to 'DocumentReference' endpoint + When producer 'RX898' sends HEAD request to 'DocumentReference' endpoint with headers: + | header | value | + | Content-Type | | Then the response status code is 405 And the response has an empty body And the Allow header is 'GET,POST' - Scenario: DocumentReference/{id} with HEAD fails + Examples: + | content_type | + | application/json | + | application/json+fhir | + | application/fhir+json | + + Scenario Outline: DocumentReference/{id} with HEAD fails (Content-Type: ) Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API - When producer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint + When producer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint with headers: + | header | value | + | Content-Type | | Then the response status code is 405 And the response has an empty body And the Allow header is 'GET,PUT,DELETE' + + Examples: + | content_type | + | application/json | + | application/json+fhir | + | application/fhir+json | + + Scenario Outline: DocumentReference with HEAD fails with 415 with unsupported (Content-Type: ) + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When producer 'RX898' sends HEAD request to 'DocumentReference' endpoint with headers: + | header | value | + | Content-Type | | + Then the response status code is 415 + And the Content-Type header is 'application/json' + And the response has an empty body + And the Allow header is not present + + Examples: + | content_type | + | application/notsupported | + | application/helloworld | + | application/harold | + + Scenario Outline: DocumentReference/{id} with HEAD fails with 415 with unsupported (Content-Type: ) + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When producer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint with headers: + | header | value | + | Content-Type | | + Then the response status code is 415 + And the Content-Type header is 'application/json' + And the response has an empty body + And the Allow header is not present + + Examples: + | content_type | + | application/notsupported | + | application/helloworld | + | application/harold | diff --git a/tests/features/steps/2_request.py b/tests/features/steps/2_request.py index 705e1e419..304228fec 100644 --- a/tests/features/steps/2_request.py +++ b/tests/features/steps/2_request.py @@ -249,12 +249,19 @@ def producer_search_document_reference_step(context: Context, ods_code: str): ) -@when("{consumer_or_producer} '{ods_code}' sends HEAD request to '{endpoint}' endpoint") +@when( + "{consumer_or_producer} '{ods_code}' sends HEAD request to '{endpoint}' endpoint with headers" +) def consumer_head_request_step( context: Context, consumer_or_producer: str, ods_code: str, endpoint: str ): + if not context.table: + raise ValueError("No headers table provided") + + headers = {row["header"]: row["value"] for row in context.table} + if consumer_or_producer == "producer": client = producer_client_from_context(context, ods_code) elif consumer_or_producer == "consumer": client = consumer_client_from_context(context, ods_code) - context.response = client.head(endpoint) + context.response = client.head(endpoint, headers=headers) diff --git a/tests/features/steps/3_assert.py b/tests/features/steps/3_assert.py index 2dfa08291..2ffbe625d 100644 --- a/tests/features/steps/3_assert.py +++ b/tests/features/steps/3_assert.py @@ -347,6 +347,17 @@ def assert_location_header(context: Context, header_name: str): context.pointer_id = generated_id +@then("the {header_name} header is not present") +def assert_header_not_present(context: Context, header_name: str): + header_value = context.response.headers.get(header_name) + assert header_value is None, format_error( + f"Header {header_name} should not be present", + "not present", + header_value, + context.response.text, + ) + + @then("the {header_name} header starts with '{starts_with}'") def assert_header_starts_with(context: Context, header_name: str, starts_with: str): header_value = context.response.headers.get(header_name) diff --git a/tests/utilities/api_clients.py b/tests/utilities/api_clients.py index 95dbebe19..0c21b7f47 100644 --- a/tests/utilities/api_clients.py +++ b/tests/utilities/api_clients.py @@ -196,11 +196,12 @@ def search_post( def head( self, endpoint: str, + headers: dict[str, str] | None = None, extra_params: dict[str, str] | None = None, ) -> Response: + headers = {**(headers or {}), **self.request_headers} params = {**(extra_params or {})} url = f"{self.api_url}/{endpoint}" - headers = {**self.request_headers, "Content-Type": "application/json"} return requests.head( url, params=params, @@ -381,11 +382,12 @@ def read_capability_statement(self) -> Response: def head( self, endpoint: str, + headers: dict[str, str] | None = None, extra_params: dict[str, str] | None = None, ) -> Response: + headers = {**(headers or {}), **self.request_headers} params = {**(extra_params or {})} url = f"{self.api_url}/{endpoint}" - headers = {**self.request_headers, "Content-Type": "application/json"} return requests.head( url, params=params, From e9591223f33e90094a9d42e5978ad7166e097010 Mon Sep 17 00:00:00 2001 From: "Axel Garcia K." Date: Wed, 2 Jul 2025 15:02:02 +0100 Subject: [PATCH 6/6] NRL-512 Add HEAD requests note to capability statement --- terraform/infrastructure/consumer.tftpl | 2 +- terraform/infrastructure/producer.tftpl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/terraform/infrastructure/consumer.tftpl b/terraform/infrastructure/consumer.tftpl index 363ead22f..60b8a2d88 100644 --- a/terraform/infrastructure/consumer.tftpl +++ b/terraform/infrastructure/consumer.tftpl @@ -63,7 +63,7 @@ { "type": "DocumentReference", "profile": "http://hl7.org/fhir/R4/documentreference.html", - "documentation": "Additional business rules apply to constrain patient, organisation and type/category content.", + "documentation": "Additional business rules apply to constrain patient, organisation and type/category content. Note that HEAD requests are not supported.", "interaction": [ { "code": "read", diff --git a/terraform/infrastructure/producer.tftpl b/terraform/infrastructure/producer.tftpl index fafe92700..e57375e1d 100644 --- a/terraform/infrastructure/producer.tftpl +++ b/terraform/infrastructure/producer.tftpl @@ -62,7 +62,7 @@ { "type": "DocumentReference", "profile": "http://hl7.org/fhir/R4/documentreference.html", - "documentation": "Additional business rules apply to validate patient, organisation and type/category content.", + "documentation": "Additional business rules apply to validate patient, organisation and type/category content. Note that HEAD requests are not supported.", "interaction": [ { "code": "read",