Skip to content

Commit 76bc017

Browse files
authored
Unit tests parsing precedence instance (#26)
* BUGFIX: Corrected tag interpolation for unpack_tags * Add unit tests for tag parsing, config precedence and instance creation validation
1 parent 395d1e3 commit 76bc017

4 files changed

Lines changed: 203 additions & 1 deletion

File tree

src/ec2sandbox/_unpack_tags.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def unpack_tags(tags: str | None) -> Tuple[Tuple[str, str], ...]:
1212
except ValueError:
1313
raise ValueError(
1414
"Tags must be in the format 'key1=value1;key2=value2', "
15-
"but instead got {tags}"
15+
f"but instead got {tags}"
1616
)
1717
return tuple(tags_unpacked)
1818

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from typing import Any
2+
from unittest import mock
3+
4+
import pytest
5+
6+
from ec2sandbox._instance_provider import DefaultEc2InstanceProvider
7+
from ec2sandbox.schema import Ec2SandboxEnvironmentConfig
8+
9+
10+
def _make_config(**overrides: Any) -> Ec2SandboxEnvironmentConfig:
11+
defaults: dict[str, Any] = dict(
12+
instance_type="t3a.micro",
13+
ami_id="ami-123",
14+
region="eu-west-2",
15+
security_group_id="sg-1",
16+
subnet_id="subnet-1",
17+
instance_profile="profile-1",
18+
s3_bucket="bucket-1",
19+
)
20+
defaults.update(overrides)
21+
return Ec2SandboxEnvironmentConfig(**defaults)
22+
23+
24+
def _make_provider_with_mocks(
25+
config: Ec2SandboxEnvironmentConfig,
26+
) -> tuple[DefaultEc2InstanceProvider, mock.MagicMock]:
27+
ec2_client = mock.MagicMock()
28+
ec2_client.run_instances.return_value = {"Instances": [{"InstanceId": "i-abc"}]}
29+
30+
ssm_client = mock.MagicMock()
31+
ssm_client.describe_instance_information.return_value = {
32+
"InstanceInformationList": [{"PingStatus": "Online"}]
33+
}
34+
35+
def client(service: str, **kwargs: Any) -> mock.MagicMock:
36+
if service == "ec2":
37+
return ec2_client
38+
if service == "ssm":
39+
return ssm_client
40+
raise AssertionError(f"unexpected client: {service}")
41+
42+
session = mock.MagicMock()
43+
session.client.side_effect = client
44+
45+
return DefaultEc2InstanceProvider(config, session), ec2_client
46+
47+
48+
@pytest.mark.asyncio
49+
async def test_missing_config_fields_fail_fast() -> None:
50+
"""Missing config fields are all named, and no AWS client is built."""
51+
config = _make_config(subnet_id=None, s3_bucket=None)
52+
session = mock.MagicMock()
53+
provider = DefaultEc2InstanceProvider(config, session)
54+
55+
with pytest.raises(ValueError) as excinfo:
56+
await provider.create_instance(
57+
instance_type="t3a.micro",
58+
ami_id="ami-123",
59+
tags=[("Name", "x")],
60+
)
61+
62+
# Both missing fields are reported at once, not just the first found.
63+
assert "subnet_id" in str(excinfo.value)
64+
assert "s3_bucket" in str(excinfo.value)
65+
# Fail-fast: validation must reject before any AWS interaction.
66+
session.client.assert_not_called()
67+
68+
69+
@pytest.mark.asyncio
70+
async def test_tags_reach_run_instances() -> None:
71+
"""A tag passed to create_instance appears in the run_instances call."""
72+
provider, ec2_client = _make_provider_with_mocks(_make_config())
73+
74+
await provider.create_instance(
75+
instance_type="t3a.micro",
76+
ami_id="ami-123",
77+
tags=[("sentinel_key", "sentinel_value")],
78+
)
79+
80+
tag_specs = ec2_client.run_instances.call_args.kwargs["TagSpecifications"]
81+
sentinel = {"Key": "sentinel_key", "Value": "sentinel_value"}
82+
assert sentinel in tag_specs[0]["Tags"]
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import os
2+
from pathlib import Path
3+
from unittest import mock
4+
5+
import pytest
6+
7+
from ec2sandbox.schema import Ec2SandboxEnvironmentConfig
8+
9+
10+
@pytest.fixture(autouse=True)
11+
def _run_outside_repo(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
12+
"""Keep a contributor's local .env file out of pydantic-settings' reach."""
13+
monkeypatch.chdir(tmp_path)
14+
15+
16+
def env_vars_all() -> dict[str, str]:
17+
# AMI_ID must always be present: from_settings resolves a missing AMI
18+
# via a real SSM lookup, which these tests must never reach.
19+
return {
20+
"INSPECT_EC2_SANDBOX_REGION": "eu-west-2",
21+
"INSPECT_EC2_SANDBOX_VPC_ID": "vpc-123",
22+
"INSPECT_EC2_SANDBOX_SECURITY_GROUP_ID": "sg-456",
23+
"INSPECT_EC2_SANDBOX_SUBNET_ID": "subnet-654321",
24+
"INSPECT_EC2_SANDBOX_AMI_ID": "ami-789",
25+
"INSPECT_EC2_SANDBOX_INSTANCE_PROFILE": "profile-1",
26+
"INSPECT_EC2_SANDBOX_S3_BUCKET": "fake-bucket",
27+
}
28+
29+
30+
def test_kwarg_overrides_env_var() -> None:
31+
"""Config kwargs take precedence over environment variables."""
32+
env_vars = env_vars_all()
33+
env_vars["INSPECT_EC2_SANDBOX_INSTANCE_TYPE"] = "t3a.small"
34+
with mock.patch.dict(os.environ, env_vars, clear=True):
35+
config = Ec2SandboxEnvironmentConfig.from_settings(instance_type="t3a.xlarge")
36+
assert config.instance_type == "t3a.xlarge"
37+
38+
39+
def test_region_falls_back_to_aws_region() -> None:
40+
"""AWS_REGION is used when INSPECT_EC2_SANDBOX_REGION is unset."""
41+
env_vars = env_vars_all()
42+
env_vars.pop("INSPECT_EC2_SANDBOX_REGION")
43+
env_vars["AWS_REGION"] = "eu-west-1"
44+
with mock.patch.dict(os.environ, env_vars, clear=True):
45+
config = Ec2SandboxEnvironmentConfig.from_settings()
46+
assert config.region == "eu-west-1"
47+
48+
49+
def test_missing_region_raises_value_error() -> None:
50+
"""With no region from any source, from_settings raises a clear error."""
51+
env_vars = env_vars_all()
52+
env_vars.pop("INSPECT_EC2_SANDBOX_REGION")
53+
with mock.patch.dict(os.environ, env_vars, clear=True):
54+
with pytest.raises(ValueError, match="Region must be specified"):
55+
Ec2SandboxEnvironmentConfig.from_settings()
56+
57+
58+
def test_s3_key_prefix_leading_slash_raises() -> None:
59+
"""A leading '/' in s3_key_prefix is rejected; normal values pass."""
60+
with mock.patch.dict(os.environ, env_vars_all(), clear=True):
61+
with pytest.raises(ValueError, match="must not start with"):
62+
Ec2SandboxEnvironmentConfig.from_settings(s3_key_prefix="/bad")
63+
config = Ec2SandboxEnvironmentConfig.from_settings(
64+
s3_key_prefix="sandbox-comms"
65+
)
66+
assert config.s3_key_prefix == "sandbox-comms"
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import re
2+
3+
import pytest
4+
5+
from ec2sandbox._unpack_tags import convert_tags_for_aws_interface, unpack_tags
6+
7+
8+
def test_parses_multiple_tags() -> None:
9+
"""Pin the documented 'key1=value1;key2=value2' delimiter contract."""
10+
assert unpack_tags("team=research;env=dev") == (
11+
("team", "research"),
12+
("env", "dev"),
13+
)
14+
15+
16+
def test_missing_equals_raises_value_error() -> None:
17+
"""A segment with no '=' is rejected, not passed through mangled."""
18+
with pytest.raises(ValueError):
19+
unpack_tags("noequals")
20+
21+
22+
def test_equals_in_value_raises_value_error() -> None:
23+
"""A value containing '=' is rejected: parsing is a strict single split."""
24+
# Pins current behaviour deliberately: switching to split-on-first-'='
25+
# (allowing '=' in values) would be a behaviour change and should
26+
# surface here.
27+
with pytest.raises(ValueError):
28+
unpack_tags("key=a=b")
29+
30+
31+
def test_empty_and_none_return_empty_tuple() -> None:
32+
"""No extra tags configured (the default case) yields an empty tuple."""
33+
assert unpack_tags(None) == ()
34+
assert unpack_tags("") == ()
35+
36+
37+
def test_error_message_contains_offending_input() -> None:
38+
"""The parse error names the input that failed to parse."""
39+
with pytest.raises(ValueError, match=re.escape("bad-tag-input")):
40+
unpack_tags("bad-tag-input")
41+
42+
43+
def test_convert_tags_shape_for_aws() -> None:
44+
"""Tags convert to the TagSpecifications shape cleanup relies on."""
45+
result = convert_tags_for_aws_interface("instance", (("k1", "v1"), ("k2", "v2")))
46+
assert result == [
47+
{
48+
"ResourceType": "instance",
49+
"Tags": [
50+
{"Key": "k1", "Value": "v1"},
51+
{"Key": "k2", "Value": "v2"},
52+
],
53+
}
54+
]

0 commit comments

Comments
 (0)