document_redaction / cdk /test /test_cdk_post_deploy.py
seanpedrickcase's picture
Sync: Minor change to pass lint check
ecdf1e3
Raw
History Blame Contribute Delete
28.9 kB
"""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-Redaction-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-Redaction-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-Redaction-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 = {
"REDACTION_USER_POOL_ID": "eu-west-2_AAAA",
"REDACTION_CLIENT_ID": "client",
"REDACTION_CLIENT_SECRET": "secret",
}
current = json.dumps(
{
"REDACTION_USER_POOL_ID": "eu-west-2_OLD",
"REDACTION_CLIENT_ID": "client",
"REDACTION_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",
"AgenticExpressEndpoint": "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 "sign in at the agentic redaction URL" in out
assert "agentic redaction app can be accessed at" 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 "agent.env" not in out
def test_print_express_mode_next_steps_magic_link_uses_cloudfront(capsys, monkeypatch):
outputs = {
"CloudFrontDistributionURL": "d123.cloudfront.net",
"RedactionLoginUrl": "https://d123.cloudfront.net/?key=secret",
"ExpressServiceEndpoint": "main.example.ecs.eu-west-2.on.aws",
"AgenticExpressEndpoint": "pi.example.ecs.eu-west-2.on.aws",
}
monkeypatch.setattr(post, "get_stack_output", lambda _s, key, _r: outputs.get(key))
post.print_express_mode_next_steps(
{
"AWS_REGION": "eu-west-2",
"ENABLE_PI_AGENT_EXPRESS_SERVICE": "True",
"USE_CLOUDFRONT": "True",
"CLOUDFRONT_AUTH_MODE": "magic-link",
"AGENT_ALB_PATH_PREFIX": "/agent",
}
)
out = capsys.readouterr().out
# Magic-link guidance must point at the CloudFront URL (with ?key= unlock), not the
# direct ECS endpoints.
assert "https://d123.cloudfront.net/?key=secret" in out
assert "https://d123.cloudfront.net" in out
assert "/agent/" in out
assert "main.example.ecs.eu-west-2.on.aws" not in out
assert "pi.example.ecs.eu-west-2.on.aws" not in out
def test_print_express_mode_next_steps_prints_agent_login_url(capsys, monkeypatch):
outputs = {
"CloudFrontDistributionURL": "d123.cloudfront.net",
"RedactionLoginUrl": "https://d123.cloudfront.net/?key=secret",
"AgentRedactionLoginUrl": "https://d123.cloudfront.net/agent?key=secret",
}
monkeypatch.setattr(post, "get_stack_output", lambda _s, key, _r: outputs.get(key))
post.print_express_mode_next_steps(
{
"AWS_REGION": "eu-west-2",
"ENABLE_PI_AGENT_EXPRESS_SERVICE": "True",
"USE_CLOUDFRONT": "True",
"CLOUDFRONT_AUTH_MODE": "magic-link",
"AGENT_ALB_PATH_PREFIX": "/agent",
}
)
out = capsys.readouterr().out
assert "AgentRedactionLoginUrl" in out
assert "https://d123.cloudfront.net/agent?key=secret" in out
def test_sync_pi_agent_doc_redaction_url_for_agentcore(tmp_path, monkeypatch):
env_file = tmp_path / "agent.env"
env_file.write_text(
"AGENT_ORCHESTRATOR=agentcore\nDOC_REDACTION_GRADIO_URL=http://redaction:7860\n",
encoding="utf-8",
)
monkeypatch.setattr(
"cdk_config.ENABLE_AGENTCORE_RUNTIME",
"True",
raising=False,
)
monkeypatch.setattr(
post,
"get_stack_output",
lambda *_a, **_k: "main.example.ecs.eu-west-2.on.aws",
)
url = post.sync_pi_agent_doc_redaction_url_for_agentcore(
pi_agent_env_path=env_file,
)
assert url == "https://main.example.ecs.eu-west-2.on.aws"
text = env_file.read_text(encoding="utf-8")
assert "DOC_REDACTION_GRADIO_URL=https://main.example.ecs.eu-west-2.on.aws" in text
assert "http://redaction:7860" not in text
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_BATCH_INPUT_PREFIX": "input/",
"S3_BATCH_ENV_PREFIX": "input/config/",
"S3_BATCH_LAMBDA_FUNCTION_NAME": "Headless-Redaction-S3BatchEcsTrigger",
"ECS_LOG_GROUP_NAME": "/ecs/headless-redaction-ecsservice-logs",
}
)
out = capsys.readouterr().out
assert "Upload a PDF file for redaction 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-Redaction-S3BatchEcsTrigger" in out
assert "s3://my-output-bucket/output/<session-folder>/" 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")
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),
aws_region="eu-west-2",
)
put_calls = s3.put_object.call_args_list
assert len(put_calls) == 3
keys = [call.kwargs["Key"] for call in put_calls]
assert "input/" in keys
assert "input/config/" in keys
assert "input/config/example_headless_env_file.env" in keys
def _sg_with_open_ingress(sg_id="sg-abc", port=443):
return {
"SecurityGroups": [
{
"GroupId": sg_id,
"IpPermissions": [
{
"IpProtocol": "tcp",
"FromPort": port,
"ToPort": port,
"IpRanges": [{"CidrIp": "0.0.0.0/0"}],
"Ipv6Ranges": [{"CidrIpv6": "::/0"}],
"PrefixListIds": [],
}
],
}
]
}
def test_restrict_single_alb_sg_replaces_open_ingress_with_prefix_list():
order = []
ec2 = MagicMock()
ec2.describe_security_groups.return_value = _sg_with_open_ingress("sg-abc", 443)
ec2.revoke_security_group_ingress.side_effect = lambda **_k: order.append("revoke")
ec2.authorize_security_group_ingress.side_effect = lambda **_k: order.append(
"authorize"
)
status = post._restrict_single_alb_sg_to_cloudfront(ec2, "sg-abc", "pl-cloudfront")
assert status == "ok"
# Open rules are revoked BEFORE the prefix list is authorized (frees rule quota).
assert order == ["revoke", "authorize"]
auth = ec2.authorize_security_group_ingress.call_args
assert auth.kwargs["GroupId"] == "sg-abc"
auth_perm = auth.kwargs["IpPermissions"][0]
assert auth_perm["FromPort"] == 443
assert auth_perm["PrefixListIds"] == [
{"PrefixListId": "pl-cloudfront", "Description": "CloudFront origin-facing"}
]
revoke = ec2.revoke_security_group_ingress.call_args
assert revoke.kwargs["GroupId"] == "sg-abc"
revoke_perm = revoke.kwargs["IpPermissions"][0]
assert revoke_perm["IpRanges"] == [{"CidrIp": "0.0.0.0/0"}]
assert revoke_perm["Ipv6Ranges"] == [{"CidrIpv6": "::/0"}]
def test_restrict_single_alb_sg_restores_open_when_rules_limit_exceeded():
from botocore.exceptions import ClientError
limit_err = ClientError(
{
"Error": {
"Code": "RulesPerSecurityGroupLimitExceeded",
"Message": "The maximum number of rules per security group has been reached.",
}
},
"AuthorizeSecurityGroupIngress",
)
calls = {"n": 0}
def _authorize(**_kwargs):
calls["n"] += 1
if calls["n"] == 1: # first authorize = prefix list attempt -> fails
raise limit_err
return {} # second authorize = restore open ingress -> succeeds
ec2 = MagicMock()
ec2.describe_security_groups.return_value = _sg_with_open_ingress("sg-abc", 443)
ec2.authorize_security_group_ingress.side_effect = _authorize
ec2.describe_managed_prefix_lists.return_value = {
"PrefixLists": [{"PrefixListId": "pl-cloudfront", "MaxEntries": 55}]
}
status = post._restrict_single_alb_sg_to_cloudfront(ec2, "sg-abc", "pl-cloudfront")
assert status == "quota"
# Revoked open, tried prefix (failed), then restored the open ingress.
assert ec2.revoke_security_group_ingress.call_count == 1
assert ec2.authorize_security_group_ingress.call_count == 2
restore_perm = ec2.authorize_security_group_ingress.call_args_list[1].kwargs[
"IpPermissions"
][0]
assert restore_perm["IpRanges"] == [{"CidrIp": "0.0.0.0/0"}]
assert restore_perm["Ipv6Ranges"] == [{"CidrIpv6": "::/0"}]
def test_restrict_single_alb_sg_is_idempotent_when_already_locked_down():
ec2 = MagicMock()
ec2.describe_security_groups.return_value = {
"SecurityGroups": [
{
"GroupId": "sg-abc",
"IpPermissions": [
{
"IpProtocol": "tcp",
"FromPort": 443,
"ToPort": 443,
"IpRanges": [],
"Ipv6Ranges": [],
"PrefixListIds": [{"PrefixListId": "pl-cloudfront"}],
}
],
}
]
}
status = post._restrict_single_alb_sg_to_cloudfront(ec2, "sg-abc", "pl-cloudfront")
assert status == "noop"
ec2.authorize_security_group_ingress.assert_not_called()
ec2.revoke_security_group_ingress.assert_not_called()
def _mock_aws_for_split(sg_to_arn, *, authorize_side_effect=None, cf_prefix="sg-cf"):
"""MagicMock serving both the ec2 and elbv2 clients for the split lockdown flow."""
m = MagicMock()
counter = {"n": 0}
def _describe_sgs(**kwargs):
if "GroupIds" in kwargs: # managed SG lookup (has open ingress + VpcId)
gid = kwargs["GroupIds"][0]
data = _sg_with_open_ingress(gid, 443)
data["SecurityGroups"][0]["VpcId"] = "vpc-123"
return data
return {"SecurityGroups": []} # dedicated-SG reuse lookup -> none exists yet
m.describe_security_groups.side_effect = _describe_sgs
def _create_sg(**_kwargs):
counter["n"] += 1
return {"GroupId": f"{cf_prefix}-{counter['n']}"}
m.create_security_group.side_effect = _create_sg
if authorize_side_effect is not None:
m.authorize_security_group_ingress.side_effect = authorize_side_effect
lbs = [
{
"LoadBalancerArn": arn,
"VpcId": "vpc-123",
"SecurityGroups": [sg_id],
}
for sg_id, arn in sg_to_arn.items()
]
paginator = MagicMock()
paginator.paginate.return_value = [{"LoadBalancers": lbs}]
m.get_paginator.return_value = paginator
m.describe_managed_prefix_lists.return_value = {
"PrefixLists": [{"PrefixListId": "pl-cloudfront", "MaxEntries": 55}]
}
return m
def test_restrict_express_albs_auto_raises_quota_on_limit(monkeypatch):
from botocore.exceptions import ClientError
limit_err = ClientError(
{
"Error": {
"Code": "RulesPerSecurityGroupLimitExceeded",
"Message": "The maximum number of rules per security group has been reached.",
}
},
"AuthorizeSecurityGroupIngress",
)
# Even a single dedicated per-port SG can't fit the prefix list -> quota fallback.
m = _mock_aws_for_split(
{"sg-main": "arn-main"},
authorize_side_effect=lambda **_k: (_ for _ in ()).throw(limit_err),
)
outputs = {"AlbSecurityGroupIdOutput": "sg-main"}
monkeypatch.setattr(post, "get_stack_output", lambda _s, key, _r: outputs.get(key))
monkeypatch.setattr(post, "get_security_group_rules_quota", lambda _r: 60.0)
requested = {}
def _request(region, desired_value):
requested["region"] = region
requested["desired"] = desired_value
return True
monkeypatch.setattr(post, "request_security_group_rules_quota_increase", _request)
with patch("cdk_post_deploy.boto3.client", return_value=m):
post.restrict_express_albs_to_cloudfront(
stack_name="RedactionStack",
region="eu-west-2",
prefix_list_id="pl-cloudfront",
auto_raise_quota=True,
)
assert requested["region"] == "eu-west-2"
assert requested["desired"] >= 65
m.set_security_groups.assert_not_called()
def test_restrict_express_albs_no_quota_request_when_disabled(monkeypatch):
from botocore.exceptions import ClientError
limit_err = ClientError(
{"Error": {"Code": "RulesPerSecurityGroupLimitExceeded", "Message": "x"}},
"AuthorizeSecurityGroupIngress",
)
m = _mock_aws_for_split(
{"sg-main": "arn-main"},
authorize_side_effect=lambda **_k: (_ for _ in ()).throw(limit_err),
)
monkeypatch.setattr(
post,
"get_stack_output",
lambda _s, key, _r: {"AlbSecurityGroupIdOutput": "sg-main"}.get(key),
)
requested = {"called": False}
def _request(region, desired_value):
requested["called"] = True
return True
monkeypatch.setattr(post, "request_security_group_rules_quota_increase", _request)
with patch("cdk_post_deploy.boto3.client", return_value=m):
post.restrict_express_albs_to_cloudfront(
stack_name="RedactionStack",
region="eu-west-2",
prefix_list_id="pl-cloudfront",
auto_raise_quota=False,
)
assert requested["called"] is False
def test_restrict_express_albs_reads_both_stack_outputs(monkeypatch):
m = _mock_aws_for_split({"sg-main": "arn-main", "sg-agent": "arn-agent"})
outputs = {
"AlbSecurityGroupIdOutput": "sg-main",
"AgenticAlbSecurityGroupIdOutput": "sg-agent",
}
monkeypatch.setattr(post, "get_stack_output", lambda _s, key, _r: outputs.get(key))
monkeypatch.setattr(
post,
"resolve_cloudfront_origin_prefix_list_id",
lambda *_a, **_k: "pl-cloudfront",
)
with patch("cdk_post_deploy.boto3.client", return_value=m):
processed = post.restrict_express_albs_to_cloudfront(
stack_name="RedactionStack", region="eu-west-2"
)
assert processed == ["sg-main", "sg-agent"]
# One dedicated CloudFront SG created per ALB (one open port each) and attached.
assert m.create_security_group.call_count == 2
assert m.set_security_groups.call_count == 2
# Open internet ingress revoked from each managed SG after CF SGs are attached.
assert m.revoke_security_group_ingress.call_count == 2
def test_restrict_single_alb_split_attaches_then_revokes(monkeypatch):
"""The dedicated CF SG must be attached to the LB BEFORE open ingress is revoked
(union semantics => no downtime window)."""
order = []
m = _mock_aws_for_split({"sg-main": "arn-main"})
m.set_security_groups.side_effect = lambda **_k: order.append("attach")
m.revoke_security_group_ingress.side_effect = lambda **_k: order.append("revoke")
status = post._restrict_single_alb_to_cloudfront_split(
m, m, "sg-main", "pl-cloudfront"
)
assert status == "ok"
assert order == ["attach", "revoke"]
attached = m.set_security_groups.call_args.kwargs["SecurityGroups"]
assert "sg-main" in attached and any(s.startswith("sg-cf") for s in attached)
def test_restrict_single_alb_split_noop_when_already_locked():
"""No open ingress + extra SG already attached => idempotent no-op."""
m = MagicMock()
m.describe_security_groups.side_effect = lambda **kw: (
{
"SecurityGroups": [
{"GroupId": kw["GroupIds"][0], "VpcId": "vpc-1", "IpPermissions": []}
]
}
if "GroupIds" in kw
else {"SecurityGroups": []}
)
paginator = MagicMock()
paginator.paginate.return_value = [
{
"LoadBalancers": [
{
"LoadBalancerArn": "arn-main",
"VpcId": "vpc-1",
"SecurityGroups": ["sg-main", "sg-cf-1"],
}
]
}
]
m.get_paginator.return_value = paginator
status = post._restrict_single_alb_to_cloudfront_split(
m, m, "sg-main", "pl-cloudfront"
)
assert status == "noop"
m.set_security_groups.assert_not_called()
m.create_security_group.assert_not_called()
def test_restrict_single_alb_split_errors_when_no_load_balancer():
m = MagicMock()
paginator = MagicMock()
paginator.paginate.return_value = [{"LoadBalancers": []}]
m.get_paginator.return_value = paginator
status = post._restrict_single_alb_to_cloudfront_split(
m, m, "sg-main", "pl-cloudfront"
)
assert status == "error"
m.set_security_groups.assert_not_called()
def test_restrict_express_albs_skips_when_no_sg_outputs(monkeypatch, capsys):
monkeypatch.setattr(post, "get_stack_output", lambda *_a, **_k: None)
monkeypatch.setattr(
post,
"resolve_cloudfront_origin_prefix_list_id",
lambda *_a, **_k: "pl-cloudfront",
)
with patch("cdk_post_deploy.boto3.client", return_value=MagicMock()) as client:
processed = post.restrict_express_albs_to_cloudfront(region="eu-west-2")
assert processed == []
client.assert_not_called()
assert "no ALB security group ids" in capsys.readouterr().out