diff --git a/analytics/tests/conftest.py b/analytics/tests/conftest.py index 3c696a2f6..8f58fe107 100644 --- a/analytics/tests/conftest.py +++ b/analytics/tests/conftest.py @@ -287,6 +287,12 @@ def reset_aws_env_vars(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1") +@pytest.fixture(autouse=True) +def use_cdn(monkeypatch: pytest.MonkeyPatch) -> None: + """Set up CDN URL environment variable for tests.""" + monkeypatch.setenv("CDN_URL", "http://localhost:4566") + + @pytest.fixture def mock_s3() -> boto3.resource: """Instantiate an S3 bucket resource.""" diff --git a/api/local.env b/api/local.env index 0ac1aa68f..7867cc68b 100644 --- a/api/local.env +++ b/api/local.env @@ -150,3 +150,5 @@ DEPLOY_GITHUB_REF=main DEPLOY_GITHUB_SHA=ffaca647223e0b6e54344122eefa73401f5ec131 DEPLOY_TIMESTAMP=2024-12-02T21:25:18Z DEPLOY_WHOAMI=local-developer + +CDN_URL=http://localhost:4566/local-mock-public-bucket diff --git a/api/src/services/opportunities_v1/get_opportunity.py b/api/src/services/opportunities_v1/get_opportunity.py index d11e18dce..4eae805f6 100644 --- a/api/src/services/opportunities_v1/get_opportunity.py +++ b/api/src/services/opportunities_v1/get_opportunity.py @@ -5,10 +5,17 @@ import src.adapters.db as db import src.util.datetime_util as datetime_util +from src.adapters.aws import S3Config from src.api.route_utils import raise_flask_error from src.db.models.agency_models import Agency from src.db.models.opportunity_models import Opportunity, OpportunityAttachment, OpportunitySummary -from src.util.file_util import pre_sign_file_location +from src.util.env_config import PydanticBaseEnvConfig +from src.util.file_util import convert_public_s3_to_cdn_url, pre_sign_file_location + + +class AttachmentConfig(PydanticBaseEnvConfig): + # If the CDN URL is set, we'll use it instead of pre-signing the file locations + cdn_url: str | None = None def _fetch_opportunity( @@ -50,7 +57,15 @@ def get_opportunity(db_session: db.Session, opportunity_id: int) -> Opportunity: db_session, opportunity_id, load_all_opportunity_summaries=False ) - pre_sign_opportunity_file_location(opportunity.opportunity_attachments) + attachment_config = AttachmentConfig() + if attachment_config.cdn_url is not None: + s3_config = S3Config() + for opp_att in opportunity.opportunity_attachments: + opp_att.download_path = convert_public_s3_to_cdn_url( # type: ignore + opp_att.file_location, attachment_config.cdn_url, s3_config + ) + else: + pre_sign_opportunity_file_location(opportunity.opportunity_attachments) return opportunity diff --git a/api/src/util/file_util.py b/api/src/util/file_util.py index 3322c3c5b..bd499e445 100644 --- a/api/src/util/file_util.py +++ b/api/src/util/file_util.py @@ -166,3 +166,16 @@ def read_file(path: str | Path, mode: str = "r", encoding: str | None = None) -> """Simple function for just getting all of the contents of a file""" with open_stream(path, mode, encoding) as input_file: return input_file.read() + + +def convert_public_s3_to_cdn_url(file_path: str, cdn_url: str, s3_config: S3Config) -> str: + """ + Convert an S3 URL to a CDN URL + + Example: + s3://bucket-name/path/to/file.txt -> https://cdn.example.com/path/to/file.txt + """ + if not is_s3_path(file_path): + raise ValueError(f"Expected s3:// path, got: {file_path}") + + return file_path.replace(s3_config.public_files_bucket_path, cdn_url) diff --git a/api/tests/src/api/opportunities_v1/test_opportunity_route_get.py b/api/tests/src/api/opportunities_v1/test_opportunity_route_get.py index 722e6df35..3d53d5d4c 100644 --- a/api/tests/src/api/opportunities_v1/test_opportunity_route_get.py +++ b/api/tests/src/api/opportunities_v1/test_opportunity_route_get.py @@ -109,8 +109,9 @@ def test_get_opportunity_with_agency_200(client, api_auth_token, enable_factory_ def test_get_opportunity_s3_endpoint_url_200( - client, api_auth_token, enable_factory_create, db_session, mock_s3_bucket + client, api_auth_token, enable_factory_create, db_session, mock_s3_bucket, monkeypatch_session ): + monkeypatch_session.delenv("CDN_URL") # Create an opportunity with a specific attachment opportunity = OpportunityFactory.create(opportunity_attachments=[]) object_name = "test_file_1.txt" @@ -153,3 +154,34 @@ def test_get_opportunity_404_not_found_is_draft(client, api_auth_token, enable_f resp.get_json()["message"] == f"Could not find Opportunity with ID {opportunity.opportunity_id}" ) + + +def test_get_opportunity_returns_cdn_urls( + client, api_auth_token, monkeypatch_session, enable_factory_create, db_session, mock_s3_bucket +): + monkeypatch_session.setenv("CDN_URL", "https://cdn.example.com") + """Test that S3 file locations are converted to CDN URLs in the response""" + # Create an opportunity with a specific attachment + opportunity = OpportunityFactory.create(opportunity_attachments=[]) + + object_name = "test_file_1.txt" + file_loc = f"s3://{mock_s3_bucket}/{object_name}" + OpportunityAttachmentFactory.create( + file_location=file_loc, opportunity=opportunity, file_contents="Hello, world" + ) + + # Make the GET request + resp = client.get( + f"/v1/opportunities/{opportunity.opportunity_id}", headers={"X-Auth": api_auth_token} + ) + + # Check the response + assert resp.status_code == 200 + response_data = resp.get_json()["data"] + + # Verify attachment URL is a CDN URL + assert len(response_data["attachments"]) == 1 + attachment = response_data["attachments"][0] + + assert attachment["download_path"].startswith("https://cdn.") + assert "s3://" not in attachment["download_path"] diff --git a/api/tests/src/util/test_file_util.py b/api/tests/src/util/test_file_util.py index 06cddbf0c..30eac915c 100644 --- a/api/tests/src/util/test_file_util.py +++ b/api/tests/src/util/test_file_util.py @@ -211,3 +211,35 @@ def test_move_file_local_disk(tmp_path): assert file_util.file_exists(other_file_path) is True assert file_util.read_file(other_file_path) == contents + + +@pytest.mark.parametrize( + "s3_path,cdn_url,expected", + [ + ( + "s3://local-mock-public-bucket/path/to/file.pdf", + "https://cdn.example.com", + "https://cdn.example.com/path/to/file.pdf", + ), + ( + "s3://local-mock-public-bucket/opportunities/9/attachments/79853231/manager.webm", + "https://cdn.example.com", + "https://cdn.example.com/opportunities/9/attachments/79853231/manager.webm", + ), + # Test with subdirectory in CDN URL + ( + "s3://local-mock-public-bucket/file.txt", + "https://cdn.example.com/assets", + "https://cdn.example.com/assets/file.txt", + ), + ], +) +def test_convert_s3_to_cdn_url(s3_path, cdn_url, expected, s3_config): + assert file_util.convert_public_s3_to_cdn_url(s3_path, cdn_url, s3_config) == expected + + +def test_convert_s3_to_cdn_url_invalid_path(s3_config): + with pytest.raises(ValueError, match="Expected s3:// path"): + file_util.convert_public_s3_to_cdn_url( + "http://not-s3/file.txt", "cdn.example.com", s3_config + )