"""Unit tests for cdk_post_deploy.py (boto3 helpers, no live AWS).""" import json import sys from pathlib import Path from unittest.mock import MagicMock, patch CDK_DIR = Path(__file__).resolve().parents[1] sys.path.insert(0, str(CDK_DIR)) import cdk_post_deploy as post def test_container_definitions_with_named_port_adds_mapping_to_first_container(): containers = [{"name": "app", "image": "nginx"}] updated = post._container_definitions_with_named_port( containers, port_name="port-7860", container_port=7860, ) assert updated[0]["portMappings"] == [ {"name": "port-7860", "containerPort": 7860, "protocol": "tcp"} ] def test_container_definitions_with_named_port_names_existing_mapping(): containers = [ { "name": "app", "image": "nginx", "portMappings": [{"containerPort": 7860, "protocol": "tcp"}], } ] updated = post._container_definitions_with_named_port( containers, port_name="port-7860", container_port=7860, ) assert updated[0]["portMappings"][0]["name"] == "port-7860" def test_resolve_service_task_definition_arn_from_describe_services(): mock_ecs = MagicMock() mock_ecs.describe_services.return_value = { "services": [ { "serviceArn": "arn:aws:ecs:eu-west-2:123:service/cluster/app", "taskDefinition": "arn:aws:ecs:eu-west-2:123:task-definition/app:1", } ] } with patch("cdk_post_deploy.boto3.client", return_value=mock_ecs): arn = post.resolve_service_task_definition_arn("cluster", "app") assert arn == "arn:aws:ecs:eu-west-2:123:task-definition/app:1" mock_ecs.describe_express_gateway_service.assert_not_called() def test_resolve_service_task_definition_arn_from_express_service_revision(): mock_ecs = MagicMock() mock_ecs.describe_services.return_value = { "services": [ { "serviceArn": "arn:aws:ecs:eu-west-2:123:service/cluster/express-app", } ] } mock_ecs.describe_express_gateway_service.return_value = { "service": { "activeConfigurations": [ { "serviceRevisionArn": "arn:aws:ecs:eu-west-2:123:service-revision/rev/1" } ] } } mock_ecs.describe_service_revisions.return_value = { "serviceRevisions": [ { "taskDefinition": "arn:aws:ecs:eu-west-2:123:task-definition/express:3", } ] } with patch("cdk_post_deploy.boto3.client", return_value=mock_ecs): arn = post.resolve_service_task_definition_arn("cluster", "express-app") assert arn == "arn:aws:ecs:eu-west-2:123:task-definition/express:3" mock_ecs.describe_express_gateway_service.assert_called_once() mock_ecs.describe_service_revisions.assert_called_once_with( serviceRevisionArns=["arn:aws:ecs:eu-west-2:123:service-revision/rev/1"] ) def test_start_express_gateway_service_updates_scaling_target(): mock_ecs = MagicMock() mock_ecs.get_paginator.return_value.paginate.return_value = [ { "serviceArns": [ "arn:aws:ecs:eu-west-2:123456789012:service/my-cluster/my-express" ] } ] with patch("cdk_post_deploy.boto3.client", return_value=mock_ecs): result = post.start_express_gateway_service("my-cluster", "my-express") assert result["statusCode"] == 200 mock_ecs.update_express_gateway_service.assert_called_once_with( serviceArn="arn:aws:ecs:eu-west-2:123456789012:service/my-cluster/my-express", scalingTarget=post.EXPRESS_GATEWAY_ACTIVE_SCALING_TARGET, ) def test_cognito_https_callback_urls(): assert post.cognito_https_callback_urls( "https://abc123.eu-west-2.elb.amazonaws.com" ) == [ "https://abc123.eu-west-2.elb.amazonaws.com", "https://abc123.eu-west-2.elb.amazonaws.com/oauth2/idpresponse", ] assert post.cognito_https_callback_urls("app.example.com")[0].startswith("https://") def test_update_user_pool_client_callback_urls_preserves_oauth_settings(): mock_cognito = MagicMock() mock_cognito.describe_user_pool_client.return_value = { "UserPoolClient": { "ClientName": "app-client", "CallbackURLs": ["https://old.example.com"], "AllowedOAuthFlows": ["code"], "AllowedOAuthScopes": ["openid", "email", "profile"], "AllowedOAuthFlowsUserPoolClient": True, "SupportedIdentityProviders": ["COGNITO"], "ExplicitAuthFlows": ["ALLOW_REFRESH_TOKEN_AUTH"], } } with patch("cdk_post_deploy.boto3.client", return_value=mock_cognito): post.update_user_pool_client_callback_urls( "pool-1", "client-1", [ "https://new.example.com", "https://new.example.com/oauth2/idpresponse", ], aws_region="eu-west-2", ) mock_cognito.update_user_pool_client.assert_called_once() kwargs = mock_cognito.update_user_pool_client.call_args.kwargs assert kwargs["UserPoolId"] == "pool-1" assert kwargs["ClientId"] == "client-1" assert kwargs["CallbackURLs"] == [ "https://new.example.com", "https://new.example.com/oauth2/idpresponse", ] assert kwargs["AllowedOAuthFlows"] == ["code"] assert kwargs["AllowedOAuthScopes"] == ["openid", "email", "profile"] def test_apply_cognito_alb_callback_fixup_skips_when_already_correct(): mock_cognito = MagicMock() mock_cognito.describe_user_pool_client.return_value = { "UserPoolClient": { "CallbackURLs": post.cognito_https_callback_urls("https://app.example.com"), } } with patch("cdk_post_deploy.boto3.client", return_value=mock_cognito): changed = post.apply_cognito_alb_callback_fixup( user_pool_id="pool-1", client_id="client-1", redirect_base="https://app.example.com", ) assert changed is False mock_cognito.update_user_pool_client.assert_not_called() def test_target_group_arn_from_ecs_register_event(): message = ( "(service my-svc) registered 1 targets in " "(target-group arn:aws:elasticloadbalancing:eu-west-2:123:" "targetgroup/ecs-gateway-tg-abc/def)" ) assert ( post.target_group_arn_from_ecs_register_event(message) == "arn:aws:elasticloadbalancing:eu-west-2:123:targetgroup/ecs-gateway-tg-abc/def" ) def test_resolve_express_service_target_group_arn_waits_for_registration_event(): mock_ecs = MagicMock() mock_ecs.describe_services.side_effect = [ { "services": [ { "events": [], "runningCount": 0, "desiredCount": 1, } ] }, { "services": [ { "events": [ { "message": ( "(service Demo-Summarisation-ECSService) registered 1 targets in " "(target-group arn:aws:elasticloadbalancing:eu-west-2:123:" "targetgroup/ecs-gateway-tg-abc/def)" ) } ], "runningCount": 1, "desiredCount": 1, } ] }, ] with ( patch("cdk_post_deploy.boto3.client", return_value=mock_ecs), patch("cdk_post_deploy.time.sleep"), patch("cdk_post_deploy.time.monotonic", side_effect=[0, 0, 600]), ): arn = post.resolve_express_service_target_group_arn( "cluster", "Demo-Summarisation-ECSService", max_wait_seconds=600, poll_interval_seconds=15, ) assert arn == ( "arn:aws:elasticloadbalancing:eu-west-2:123:targetgroup/ecs-gateway-tg-abc/def" ) assert mock_ecs.describe_services.call_count == 2 def test_resolve_express_service_target_group_arn_from_task_ips(): mock_ecs = MagicMock() mock_ecs.describe_services.return_value = { "services": [ { "events": [], "runningCount": 1, "desiredCount": 1, } ] } mock_ecs.list_tasks.return_value = {"taskArns": ["arn:task/1"]} mock_ecs.describe_tasks.return_value = { "tasks": [ { "attachments": [ {"details": [{"name": "privateIPv4Address", "value": "10.0.1.42"}]} ] } ] } mock_elbv2 = MagicMock() mock_elbv2.describe_target_groups.return_value = { "TargetGroups": [{"TargetGroupArn": "arn:tg/main"}] } mock_elbv2.describe_target_health.return_value = { "TargetHealthDescriptions": [{"Target": {"Id": "10.0.1.42"}}] } def client_factory(service_name, **_kwargs): return mock_elbv2 if service_name == "elbv2" else mock_ecs with ( patch("cdk_post_deploy.boto3.client", side_effect=client_factory), patch("cdk_post_deploy.time.monotonic", return_value=0), ): arn = post.resolve_express_service_target_group_arn( "cluster", "Demo-Summarisation-ECSService", load_balancer_arn="arn:lb/express", max_wait_seconds=600, poll_interval_seconds=15, ) assert arn == "arn:tg/main" def test_listener_actions_with_target_group_replaces_forward_arn(): actions = [ {"Type": "authenticate-cognito", "Order": 1}, { "Type": "forward", "Order": 2, "TargetGroupArn": "arn:old", "ForwardConfig": {"TargetGroups": [{"TargetGroupArn": "arn:old"}]}, }, ] updated = post.listener_actions_with_target_group(actions, "arn:new") forward = next(action for action in updated if action["Type"] == "forward") assert forward["TargetGroupArn"] == "arn:new" assert forward["ForwardConfig"]["TargetGroups"][0]["TargetGroupArn"] == "arn:new" def test_cognito_secret_payload_matches(): desired = { "SUMMARISATION_USER_POOL_ID": "eu-west-2_AAAA", "SUMMARISATION_CLIENT_ID": "client", "SUMMARISATION_CLIENT_SECRET": "secret", } current = json.dumps( { "SUMMARISATION_USER_POOL_ID": "eu-west-2_OLD", "SUMMARISATION_CLIENT_ID": "client", "SUMMARISATION_CLIENT_SECRET": "secret", } ) assert post.cognito_secret_payload_matches(current, desired) is False assert post.cognito_secret_payload_matches(json.dumps(desired), desired) is True def test_listener_rule_has_cognito_auth(): assert post.listener_rule_has_cognito_auth( [{"Type": "authenticate-cognito"}, {"Type": "forward"}] ) assert not post.listener_rule_has_cognito_auth([{"Type": "forward"}]) def test_print_express_mode_next_steps(capsys, monkeypatch): def fake_get_stack_output(stack_name, output_key, region): outputs = { "ExpressServiceEndpoint": "main.example.ecs.eu-west-2.on.aws", "PiExpressEndpoint": "pi.example.ecs.eu-west-2.on.aws", } return outputs.get(output_key) monkeypatch.setattr(post, "get_stack_output", fake_get_stack_output) post.print_express_mode_next_steps( { "AWS_REGION": "eu-west-2", "ENABLE_PI_AGENT_EXPRESS_SERVICE": "True", } ) out = capsys.readouterr().out assert "Wait 10 minutes for app deployment to finish." in out assert "Cognito authorisation" not in out assert "sign in at the Pi agent URL" in out assert "https://main.example.ecs.eu-west-2.on.aws" in out assert "https://pi.example.ecs.eu-west-2.on.aws/" in out assert "pi_agent.env" not in out def test_print_headless_deployment_next_steps(capsys): post.print_headless_deployment_next_steps( { "AWS_REGION": "eu-west-2", "S3_OUTPUT_BUCKET_NAME": "my-output-bucket", "S3_LOG_CONFIG_BUCKET_NAME": "my-log-bucket", "S3_BATCH_INPUT_PREFIX": "input/", "S3_BATCH_ENV_PREFIX": "input/config/", "S3_BATCH_DEFAULT_PARAMS_KEY": "general-config/app_defaults.env", "S3_BATCH_LAMBDA_FUNCTION_NAME": "Headless-Summarisation-S3BatchEcsTrigger", "ECS_LOG_GROUP_NAME": "/ecs/headless-summarisation-ecsservice-logs", } ) out = capsys.readouterr().out assert ( "Upload a consultation spreadsheet (.xlsx) to s3://my-output-bucket/input/" in out ) assert "example_headless_env_file.env" in out assert "s3://my-output-bucket/input/config/" in out assert "Headless-Summarisation-S3BatchEcsTrigger" in out assert "s3://my-output-bucket/output//" in out assert "s3://my-log-bucket/general-config/app_defaults.env" in out assert "tools/config.py" in out def test_seed_headless_batch_s3_layout_creates_prefixes(tmp_path): from botocore.exceptions import ClientError example = tmp_path / "example_headless_env_file.env" example.write_text("DIRECT_MODE_TASK=redact\n", encoding="utf-8") defaults = tmp_path / "app_defaults.env" defaults.write_text("RUN_DIRECT_MODE=1\nS3_OUTPUTS_BUCKET=\n", encoding="utf-8") missing = ClientError( {"Error": {"Code": "404", "Message": "Not Found"}}, "HeadObject", ) s3 = MagicMock() s3.head_object.side_effect = missing with patch("cdk_post_deploy.boto3.client", return_value=s3): post.seed_headless_batch_s3_layout( "my-bucket", example_env_local_path=str(example), log_bucket="my-log-bucket", app_defaults_local_path=str(defaults), s3_outputs_bucket_name="my-bucket", aws_region="eu-west-2", ) put_calls = s3.put_object.call_args_list assert len(put_calls) == 4 keys_by_bucket = [(call.kwargs["Bucket"], call.kwargs["Key"]) for call in put_calls] assert ("my-bucket", "input/") in keys_by_bucket assert ("my-bucket", "input/config/") in keys_by_bucket assert ("my-bucket", "input/config/example_headless_env_file.env") in keys_by_bucket assert ("my-log-bucket", "general-config/app_defaults.env") in keys_by_bucket