diff --git a/infra/lib/compute-stack.ts b/infra/lib/compute-stack.ts index 9c33a09..a1faff9 100644 --- a/infra/lib/compute-stack.ts +++ b/infra/lib/compute-stack.ts @@ -394,13 +394,25 @@ export class ComputeStack extends cdk.Stack { authorizer: jwtAuthorizer, }); + // Fix (v1 PR1): bundle pip dependencies into each Python Lambda asset (previously + // bare fromAsset shipped no deps -> ImportError at cold start). --platform/--only-binary + // fetches manylinux aarch64 wheels so the ARM64 functions get correct binaries + // regardless of the build-host architecture. + const pythonBundling = { + image: lambda.Runtime.PYTHON_3_12.bundlingImage, + command: [ + 'bash', '-c', + 'pip install -r requirements.txt --platform manylinux2014_aarch64 --python-version 3.12 --implementation cp --abi cp312 --only-binary=:all: --target /asset-output && cp -au . /asset-output', + ], + }; + // Python Lambda: Suggestions Engine const suggestionsFunction = new lambda.Function(this, 'SuggestionsFunction', { functionName: 'proposal-system-suggestions', runtime: lambda.Runtime.PYTHON_3_12, architecture: lambda.Architecture.ARM_64, handler: 'app.handler', - code: lambda.Code.fromAsset('../lambdas/suggestions'), + code: lambda.Code.fromAsset('../lambdas/suggestions', { bundling: pythonBundling }), memorySize: 512, timeout: cdk.Duration.seconds(60), vpc: props.vpc, @@ -438,7 +450,7 @@ export class ComputeStack extends cdk.Stack { runtime: lambda.Runtime.PYTHON_3_12, architecture: lambda.Architecture.ARM_64, handler: 'app.handler', - code: lambda.Code.fromAsset('../lambdas/pdf-extract'), + code: lambda.Code.fromAsset('../lambdas/pdf-extract', { bundling: pythonBundling }), memorySize: 1024, timeout: cdk.Duration.seconds(120), vpc: props.vpc, @@ -472,7 +484,7 @@ export class ComputeStack extends cdk.Stack { runtime: lambda.Runtime.PYTHON_3_12, architecture: lambda.Architecture.ARM_64, handler: 'app.handler', - code: lambda.Code.fromAsset('../lambdas/pdf-generate'), + code: lambda.Code.fromAsset('../lambdas/pdf-generate', { bundling: pythonBundling }), memorySize: 512, timeout: cdk.Duration.seconds(30), vpc: props.vpc, @@ -497,7 +509,7 @@ export class ComputeStack extends cdk.Stack { runtime: lambda.Runtime.PYTHON_3_12, architecture: lambda.Architecture.ARM_64, handler: 'app.handler', - code: lambda.Code.fromAsset('../lambdas/library-ingest'), + code: lambda.Code.fromAsset('../lambdas/library-ingest', { bundling: pythonBundling }), memorySize: 512, timeout: cdk.Duration.seconds(60), vpc: props.vpc, diff --git a/lambdas/library-ingest/app.py b/lambdas/library-ingest/app.py index 80b3e6d..796ad68 100644 --- a/lambdas/library-ingest/app.py +++ b/lambdas/library-ingest/app.py @@ -13,6 +13,8 @@ import time from datetime import datetime import boto3 +from botocore.auth import SigV4Auth +from botocore.awsrequest import AWSRequest import httpx logger = logging.getLogger(__name__) @@ -44,7 +46,10 @@ def _get_api_key() -> str: """ global _cached_api_key, _cached_api_key_ts now = time.monotonic() - if _cached_api_key is not None and (now - _cached_api_key_ts) < _API_KEY_TTL_SECONDS: + if ( + _cached_api_key is not None + and (now - _cached_api_key_ts) < _API_KEY_TTL_SECONDS + ): return _cached_api_key if INTERNAL_API_KEY_SECRET_ARN: resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN) @@ -269,12 +274,50 @@ def _api_headers() -> dict: return headers +# Fix (v1 PR1): SigV4-sign internal calls to the .NET API Function URL (authType=AWS_IAM). +# The X-Internal-Api-Key header is preserved for the application-layer check; SigV4 +# satisfies the transport-layer IAM auth the Function URL enforces. +_SIGV4_SERVICE = "lambda" +_boto_session = boto3.Session() + + +def _sign_request_headers(method: str, url: str, body: bytes, headers: dict) -> dict: + """Return headers with a SigV4 signature for the AWS_IAM Function URL call. + + Falls back to the unsigned headers when no AWS credentials are resolvable, so a + non-IAM target still works in local development. + """ + creds = _boto_session.get_credentials() + if creds is None: + return headers + region = os.environ.get("AWS_REGION") or os.environ.get( + "AWS_DEFAULT_REGION", "us-east-1" + ) + aws_request = AWSRequest(method=method, url=url, data=body, headers=dict(headers)) + SigV4Auth(creds, _SIGV4_SERVICE, region).add_auth(aws_request) + return dict(aws_request.headers) + + def _retry_request( method: str, url: str, *, max_retries: int = 3, **kwargs ) -> httpx.Response: kwargs.setdefault("timeout", 10) + base_headers = dict(kwargs.pop("headers", None) or {}) + # Serialize the body once so the bytes we sign are exactly the bytes we send: + # SigV4 hashes the payload, so httpx must not re-serialize a json= kwarg. + if "json" in kwargs: + body = json.dumps(kwargs.pop("json")).encode("utf-8") + base_headers.setdefault("Content-Type", "application/json") + elif "content" in kwargs: + raw = kwargs.pop("content") + body = raw if isinstance(raw, bytes) else (raw or "").encode("utf-8") + else: + body = b"" + kwargs["content"] = body last_resp = None for attempt in range(max_retries): + # Sign per attempt so a slow retry never sends an expired SigV4 timestamp. + kwargs["headers"] = _sign_request_headers(method, url, body, base_headers) try: resp = httpx.request(method, url, **kwargs) if resp.status_code < 500: diff --git a/lambdas/pdf-extract/app.py b/lambdas/pdf-extract/app.py index 75c0325..1447200 100644 --- a/lambdas/pdf-extract/app.py +++ b/lambdas/pdf-extract/app.py @@ -13,6 +13,8 @@ import tempfile import time import boto3 +from botocore.auth import SigV4Auth +from botocore.awsrequest import AWSRequest import httpx import pdfplumber @@ -48,7 +50,10 @@ def _get_api_key() -> str: """ global _cached_api_key, _cached_api_key_ts now = time.monotonic() - if _cached_api_key is not None and (now - _cached_api_key_ts) < _API_KEY_TTL_SECONDS: + if ( + _cached_api_key is not None + and (now - _cached_api_key_ts) < _API_KEY_TTL_SECONDS + ): return _cached_api_key if INTERNAL_API_KEY_SECRET_ARN: resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN) @@ -428,15 +433,55 @@ def _api_headers() -> dict: return headers +# Fix (v1 PR1): SigV4-sign internal calls to the .NET API Function URL (authType=AWS_IAM). +# The X-Internal-Api-Key header is preserved for the application-layer check; SigV4 +# satisfies the transport-layer IAM auth the Function URL enforces. +_SIGV4_SERVICE = "lambda" +_boto_session = boto3.Session() + + +def _sign_request_headers(method: str, url: str, body: bytes, headers: dict) -> dict: + """Return headers with a SigV4 signature for the AWS_IAM Function URL call. + + Falls back to the unsigned headers when no AWS credentials are resolvable, so a + non-IAM target still works in local development. + """ + creds = _boto_session.get_credentials() + if creds is None: + return headers + region = os.environ.get("AWS_REGION") or os.environ.get( + "AWS_DEFAULT_REGION", "us-east-1" + ) + aws_request = AWSRequest(method=method, url=url, data=body, headers=dict(headers)) + SigV4Auth(creds, _SIGV4_SERVICE, region).add_auth(aws_request) + return dict(aws_request.headers) + + def _retry_request( method: str, url: str, *, max_retries: int = 3, **kwargs ) -> httpx.Response: kwargs.setdefault("timeout", 10) + base_headers = dict(kwargs.pop("headers", None) or {}) + # Serialize the body once so the bytes we sign are exactly the bytes we send: + # SigV4 hashes the payload, so httpx must not re-serialize a json= kwarg. + if "json" in kwargs: + body = json.dumps(kwargs.pop("json")).encode("utf-8") + base_headers.setdefault("Content-Type", "application/json") + elif "content" in kwargs: + raw = kwargs.pop("content") + body = raw if isinstance(raw, bytes) else (raw or "").encode("utf-8") + else: + body = b"" + kwargs["content"] = body + last_resp = None for attempt in range(max_retries): + # Sign per attempt so a slow retry never sends an expired SigV4 timestamp. + kwargs["headers"] = _sign_request_headers(method, url, body, base_headers) try: resp = httpx.request(method, url, **kwargs) if resp.status_code < 500: return resp + last_resp = resp except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc: if attempt == max_retries - 1: raise @@ -444,4 +489,6 @@ def _retry_request( "Retryable error (attempt %d/%d): %s", attempt + 1, max_retries, exc ) time.sleep(min(2**attempt, 4)) - return resp # type: ignore[possibly-undefined] + if last_resp is not None: + return last_resp + raise RuntimeError(f"All {max_retries} retries failed for {method} {url}") diff --git a/lambdas/pdf-generate/app.py b/lambdas/pdf-generate/app.py index 9292adf..ef9c96a 100644 --- a/lambdas/pdf-generate/app.py +++ b/lambdas/pdf-generate/app.py @@ -13,6 +13,8 @@ from datetime import datetime from io import BytesIO import boto3 +from botocore.auth import SigV4Auth +from botocore.awsrequest import AWSRequest import httpx from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER, TA_RIGHT @@ -68,7 +70,10 @@ def _get_api_key() -> str: """ global _cached_api_key, _cached_api_key_ts now = time.monotonic() - if _cached_api_key is not None and (now - _cached_api_key_ts) < _API_KEY_TTL_SECONDS: + if ( + _cached_api_key is not None + and (now - _cached_api_key_ts) < _API_KEY_TTL_SECONDS + ): return _cached_api_key if INTERNAL_API_KEY_SECRET_ARN: resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN) @@ -609,15 +614,55 @@ def _api_headers() -> dict: return headers +# Fix (v1 PR1): SigV4-sign internal calls to the .NET API Function URL (authType=AWS_IAM). +# The X-Internal-Api-Key header is preserved for the application-layer check; SigV4 +# satisfies the transport-layer IAM auth the Function URL enforces. +_SIGV4_SERVICE = "lambda" +_boto_session = boto3.Session() + + +def _sign_request_headers(method: str, url: str, body: bytes, headers: dict) -> dict: + """Return headers with a SigV4 signature for the AWS_IAM Function URL call. + + Falls back to the unsigned headers when no AWS credentials are resolvable, so a + non-IAM target still works in local development. + """ + creds = _boto_session.get_credentials() + if creds is None: + return headers + region = os.environ.get("AWS_REGION") or os.environ.get( + "AWS_DEFAULT_REGION", "us-east-1" + ) + aws_request = AWSRequest(method=method, url=url, data=body, headers=dict(headers)) + SigV4Auth(creds, _SIGV4_SERVICE, region).add_auth(aws_request) + return dict(aws_request.headers) + + def _retry_request( method: str, url: str, *, max_retries: int = 3, **kwargs ) -> httpx.Response: kwargs.setdefault("timeout", 10) + base_headers = dict(kwargs.pop("headers", None) or {}) + # Serialize the body once so the bytes we sign are exactly the bytes we send: + # SigV4 hashes the payload, so httpx must not re-serialize a json= kwarg. + if "json" in kwargs: + body = json.dumps(kwargs.pop("json")).encode("utf-8") + base_headers.setdefault("Content-Type", "application/json") + elif "content" in kwargs: + raw = kwargs.pop("content") + body = raw if isinstance(raw, bytes) else (raw or "").encode("utf-8") + else: + body = b"" + kwargs["content"] = body + last_resp = None for attempt in range(max_retries): + # Sign per attempt so a slow retry never sends an expired SigV4 timestamp. + kwargs["headers"] = _sign_request_headers(method, url, body, base_headers) try: resp = httpx.request(method, url, **kwargs) if resp.status_code < 500: return resp + last_resp = resp except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc: if attempt == max_retries - 1: raise @@ -625,4 +670,6 @@ def _retry_request( "Retryable error (attempt %d/%d): %s", attempt + 1, max_retries, exc ) time.sleep(min(2**attempt, 4)) - return resp # type: ignore[possibly-undefined] + if last_resp is not None: + return last_resp + raise RuntimeError(f"All {max_retries} retries failed for {method} {url}") diff --git a/lambdas/suggestions/app.py b/lambdas/suggestions/app.py index 1800886..424e430 100644 --- a/lambdas/suggestions/app.py +++ b/lambdas/suggestions/app.py @@ -13,6 +13,8 @@ import time import boto3 import httpx +from botocore.auth import SigV4Auth +from botocore.awsrequest import AWSRequest logger = logging.getLogger(__name__) logger.setLevel(os.environ.get("LOG_LEVEL", "INFO")) @@ -39,7 +41,10 @@ def _get_api_key() -> str: """ global _cached_api_key, _cached_api_key_ts now = time.monotonic() - if _cached_api_key is not None and (now - _cached_api_key_ts) < _API_KEY_TTL_SECONDS: + if ( + _cached_api_key is not None + and (now - _cached_api_key_ts) < _API_KEY_TTL_SECONDS + ): return _cached_api_key if INTERNAL_API_KEY_SECRET_ARN: resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN) @@ -120,28 +125,36 @@ def validate_line_item_numerics(items: list[dict]) -> list[dict]: logger.warning( "Fix: LAM-M6 — rejected line item: %s is not a valid number (%r) " "in item %r", - field, value, item.get("description", "unknown"), + field, + value, + item.get("description", "unknown"), ) rejected = True break if math.isnan(num) or math.isinf(num): logger.warning( "Fix: LAM-M6 — rejected line item: %s is NaN/Inf in item %r", - field, item.get("description", "unknown"), + field, + item.get("description", "unknown"), ) rejected = True break if num < 0: logger.warning( "Fix: LAM-M6 — rejected line item: %s is negative (%.2f) in item %r", - field, num, item.get("description", "unknown"), + field, + num, + item.get("description", "unknown"), ) rejected = True break if num > _MAX_LINE_ITEM_VALUE: logger.warning( "Fix: LAM-M6 — rejected line item: %s exceeds max (%.2f > %d) in item %r", - field, num, _MAX_LINE_ITEM_VALUE, item.get("description", "unknown"), + field, + num, + _MAX_LINE_ITEM_VALUE, + item.get("description", "unknown"), ) rejected = True break @@ -208,7 +221,9 @@ def process_suggestion(proposal_id: str, trigger: str): status = proposal.get("status", "") if status not in ("InReview", "Revised"): - logger.info("Proposal %s is in status %s, skipping suggestions", proposal_id, status) + logger.info( + "Proposal %s is in status %s, skipping suggestions", proposal_id, status + ) return similar_proposals = retrieve_similar(sanitize_user_text(scope), category) @@ -481,12 +496,50 @@ def _api_headers() -> dict: return headers +# Fix (v1 PR1): SigV4-sign internal calls to the .NET API Function URL (authType=AWS_IAM). +# The X-Internal-Api-Key header is preserved for the application-layer check; SigV4 +# satisfies the transport-layer IAM auth the Function URL enforces. +_SIGV4_SERVICE = "lambda" +_boto_session = boto3.Session() + + +def _sign_request_headers(method: str, url: str, body: bytes, headers: dict) -> dict: + """Return headers with a SigV4 signature for the AWS_IAM Function URL call. + + Falls back to the unsigned headers when no AWS credentials are resolvable, so a + non-IAM target still works in local development. + """ + creds = _boto_session.get_credentials() + if creds is None: + return headers + region = os.environ.get("AWS_REGION") or os.environ.get( + "AWS_DEFAULT_REGION", "us-east-1" + ) + aws_request = AWSRequest(method=method, url=url, data=body, headers=dict(headers)) + SigV4Auth(creds, _SIGV4_SERVICE, region).add_auth(aws_request) + return dict(aws_request.headers) + + def _retry_request( method: str, url: str, *, max_retries: int = 3, **kwargs ) -> httpx.Response: kwargs.setdefault("timeout", 10) + base_headers = dict(kwargs.pop("headers", None) or {}) + # Serialize the body once so the bytes we sign are exactly the bytes we send: + # SigV4 hashes the payload, so httpx must not re-serialize a json= kwarg. + if "json" in kwargs: + body = json.dumps(kwargs.pop("json")).encode("utf-8") + base_headers.setdefault("Content-Type", "application/json") + elif "content" in kwargs: + raw = kwargs.pop("content") + body = raw if isinstance(raw, bytes) else (raw or "").encode("utf-8") + else: + body = b"" + kwargs["content"] = body last_resp = None for attempt in range(max_retries): + # Sign per attempt so a slow retry never sends an expired SigV4 timestamp. + kwargs["headers"] = _sign_request_headers(method, url, body, base_headers) try: resp = httpx.request(method, url, **kwargs) if resp.status_code < 500: diff --git a/lambdas/tests/conftest.py b/lambdas/tests/conftest.py index ee9c9e1..1aa1324 100644 --- a/lambdas/tests/conftest.py +++ b/lambdas/tests/conftest.py @@ -1,7 +1,15 @@ """Common fixtures for Lambda tests.""" +import os + import pytest +# The Lambda app modules instantiate boto3 clients at import time (module top), +# which raises NoRegionError during pytest collection — before the autouse fixture +# below runs, and in CI where no region is configured. conftest is imported before +# any test module, so seed a default region here. +os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1") + @pytest.fixture(autouse=True) def _env_setup(monkeypatch): diff --git a/lambdas/tests/helpers.py b/lambdas/tests/helpers.py index 1e5c91c..aae0eb5 100644 --- a/lambdas/tests/helpers.py +++ b/lambdas/tests/helpers.py @@ -7,15 +7,17 @@ def make_sqs_event(*bodies: dict) -> dict: """Build a minimal SQS event with the given record bodies.""" records = [] for i, body in enumerate(bodies): - records.append({ - "messageId": f"msg-{i}", - "body": json.dumps(body), - "receiptHandle": f"handle-{i}", - "attributes": {}, - "messageAttributes": {}, - "md5OfBody": "", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:test-queue", - "awsRegion": "us-east-1", - }) + records.append( + { + "messageId": f"msg-{i}", + "body": json.dumps(body), + "receiptHandle": f"handle-{i}", + "attributes": {}, + "messageAttributes": {}, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:test-queue", + "awsRegion": "us-east-1", + } + ) return {"Records": records} diff --git a/lambdas/tests/test_internal_api_signing.py b/lambdas/tests/test_internal_api_signing.py new file mode 100644 index 0000000..906305a --- /dev/null +++ b/lambdas/tests/test_internal_api_signing.py @@ -0,0 +1,97 @@ +"""Tests for SigV4 signing of internal .NET API Function URL calls (v1 PR1). + +The API Lambda Function URL uses authType=AWS_IAM, so the workload Lambdas must +SigV4-sign their requests or every call 403s. The X-Internal-Api-Key app-layer header +must be preserved alongside the transport-layer signature. Verified on the suggestions +Lambda as the representative implementation — all four workload Lambdas (suggestions, +pdf-extract, pdf-generate, library-ingest) share the same _sign_request_headers / +_retry_request code. +""" + +import importlib.util +import json +import os +import sys +from unittest.mock import MagicMock, patch + +_dir = os.path.join(os.path.dirname(__file__), "..", "suggestions") +_spec = importlib.util.spec_from_file_location( + "suggestions_app_signing", os.path.join(_dir, "app.py") +) +app = importlib.util.module_from_spec(_spec) +sys.modules["suggestions_app_signing"] = app +_spec.loader.exec_module(app) + + +def _ok_response(): + resp = MagicMock() + resp.status_code = 200 + return resp + + +class TestInternalApiSigning: + def test_post_request_is_sigv4_signed(self): + with patch.object( + app.httpx, "request", return_value=_ok_response() + ) as mock_req: + app._retry_request( + "POST", + "https://fn.lambda-url.us-east-1.on.aws/api/proposals/x/line-items", + json={"items": [1, 2, 3]}, + headers={ + "X-Internal-Api-Key": "secret-123", + "Content-Type": "application/json", + }, + ) + assert mock_req.call_count == 1 + _, kwargs = mock_req.call_args + headers = kwargs["headers"] + # Transport-layer SigV4 auth present + assert headers["Authorization"].startswith("AWS4-HMAC-SHA256") + assert "X-Amz-Date" in headers + # App-layer key preserved alongside the signature + assert headers["X-Internal-Api-Key"] == "secret-123" + # Body sent as the exact signed bytes (not re-serialized via json=) + assert kwargs["content"] == json.dumps({"items": [1, 2, 3]}).encode() + assert "json" not in kwargs + + def test_session_token_included_when_present(self): + # conftest provides AWS_SESSION_TOKEN -> SigV4 must add the security-token header + with patch.object( + app.httpx, "request", return_value=_ok_response() + ) as mock_req: + app._retry_request( + "POST", "https://fn.lambda-url.us-east-1.on.aws/x", json={"a": 1} + ) + _, kwargs = mock_req.call_args + assert "X-Amz-Security-Token" in kwargs["headers"] + + def test_unsigned_fallback_when_no_credentials(self): + with ( + patch.object(app._boto_session, "get_credentials", return_value=None), + patch.object(app.httpx, "request", return_value=_ok_response()) as mock_req, + ): + app._retry_request( + "POST", + "https://fn.lambda-url.us-east-1.on.aws/x", + json={"a": 1}, + headers={"X-Internal-Api-Key": "secret-123"}, + ) + _, kwargs = mock_req.call_args + # No AWS creds (local/dev) -> no signature, but app-layer key + body still sent + assert "Authorization" not in kwargs["headers"] + assert kwargs["headers"]["X-Internal-Api-Key"] == "secret-123" + assert kwargs["content"] == json.dumps({"a": 1}).encode() + + def test_bodyless_request_signs_empty_payload(self): + with patch.object( + app.httpx, "request", return_value=_ok_response() + ) as mock_req: + app._retry_request( + "GET", + "https://fn.lambda-url.us-east-1.on.aws/api/proposals/x", + headers={"X-Internal-Api-Key": "secret-123"}, + ) + _, kwargs = mock_req.call_args + assert kwargs["content"] == b"" + assert kwargs["headers"]["Authorization"].startswith("AWS4-HMAC-SHA256") diff --git a/lambdas/tests/test_pdf_generate.py b/lambdas/tests/test_pdf_generate.py index 22060ba..7e7a2f4 100644 --- a/lambdas/tests/test_pdf_generate.py +++ b/lambdas/tests/test_pdf_generate.py @@ -5,19 +5,19 @@ batch failure reporting for the PDF generation pipeline. """ import importlib -import json import sys import os from unittest.mock import MagicMock, patch -import pytest from helpers import make_sqs_event # Import pdf-generate app under a unique module name to avoid collision with # suggestions/app.py (both are named 'app'). _pdf_gen_dir = os.path.join(os.path.dirname(__file__), "..", "pdf-generate") -_spec = importlib.util.spec_from_file_location("pdf_generate_app", os.path.join(_pdf_gen_dir, "app.py")) +_spec = importlib.util.spec_from_file_location( + "pdf_generate_app", os.path.join(_pdf_gen_dir, "app.py") +) pdf_generate_app = importlib.util.module_from_spec(_spec) sys.modules["pdf_generate_app"] = pdf_generate_app _spec.loader.exec_module(pdf_generate_app) diff --git a/lambdas/tests/test_suggestions.py b/lambdas/tests/test_suggestions.py index d1bd16c..641cdfc 100644 --- a/lambdas/tests/test_suggestions.py +++ b/lambdas/tests/test_suggestions.py @@ -5,19 +5,19 @@ QA-C6 (partial): Verifies SQS batch processing, idempotency guard """ import importlib -import json import sys import os -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, patch -import pytest from helpers import make_sqs_event # Import suggestions app under a unique module name to avoid collision with # pdf-generate/app.py (both are named 'app'). _suggestions_dir = os.path.join(os.path.dirname(__file__), "..", "suggestions") -_spec = importlib.util.spec_from_file_location("suggestions_app", os.path.join(_suggestions_dir, "app.py")) +_spec = importlib.util.spec_from_file_location( + "suggestions_app", os.path.join(_suggestions_dir, "app.py") +) suggestions_app = importlib.util.module_from_spec(_spec) sys.modules["suggestions_app"] = suggestions_app _spec.loader.exec_module(suggestions_app) @@ -29,7 +29,9 @@ class TestSuggestionsHandler: @patch.object(suggestions_app, "process_suggestion") def test_handler_processes_sqs_record(self, mock_process): """QA-C6: Handler extracts proposalId and trigger from SQS body.""" - event = make_sqs_event({"payload": {"proposalId": "abc-123", "trigger": "generate"}}) + event = make_sqs_event( + {"payload": {"proposalId": "abc-123", "trigger": "generate"}} + ) result = suggestions_app.handler(event, None) mock_process.assert_called_once_with("abc-123", "generate") @@ -103,9 +105,18 @@ class TestProcessSuggestion: "status": "InReview", } mock_fetch_items.return_value = [] - mock_retrieve.return_value = [{"content": "similar doc", "score": 0.9, "metadata": {}, "sourceUri": ""}] + mock_retrieve.return_value = [ + {"content": "similar doc", "score": 0.9, "metadata": {}, "sourceUri": ""} + ] mock_generate.return_value = [ - {"description": "Ductwork", "quantity": 100, "unit": "linear ft", "unitPrice": 15.0, "totalPrice": 1500.0, "pricingMode": "UnitPrice"}, + { + "description": "Ductwork", + "quantity": 100, + "unit": "linear ft", + "unitPrice": 15.0, + "totalPrice": 1500.0, + "pricingMode": "UnitPrice", + }, ] suggestions_app.process_suggestion("abc", "generate") @@ -132,14 +143,34 @@ class TestProcessSuggestion: mock_request.return_value = mock_response existing_items = [ - {"id": "manual-1", "description": "Manual item", "quantity": 1, "unit": "each", - "totalPrice": 500, "pricingMode": "TotalPrice", "source": "Manual"}, - {"id": "ai-1", "description": "Old AI item", "quantity": 1, "unit": "each", - "totalPrice": 200, "pricingMode": "TotalPrice", "source": "AI"}, + { + "id": "manual-1", + "description": "Manual item", + "quantity": 1, + "unit": "each", + "totalPrice": 500, + "pricingMode": "TotalPrice", + "source": "Manual", + }, + { + "id": "ai-1", + "description": "Old AI item", + "quantity": 1, + "unit": "each", + "totalPrice": 200, + "pricingMode": "TotalPrice", + "source": "AI", + }, ] new_items = [ - {"description": "New AI item", "quantity": 2, "unit": "hours", - "unitPrice": 100, "totalPrice": 200, "pricingMode": "UnitPrice"}, + { + "description": "New AI item", + "quantity": 2, + "unit": "hours", + "unitPrice": 100, + "totalPrice": 200, + "pricingMode": "UnitPrice", + }, ] suggestions_app.post_line_items("abc", new_items, existing_items) @@ -203,7 +234,9 @@ class TestRetrySafety: original_client = suggestions_app.secrets_client original_arn = suggestions_app.INTERNAL_API_KEY_SECRET_ARN suggestions_app.secrets_client = mock_secrets - suggestions_app.INTERNAL_API_KEY_SECRET_ARN = "arn:aws:secretsmanager:us-east-1:123:secret:key" + suggestions_app.INTERNAL_API_KEY_SECRET_ARN = ( + "arn:aws:secretsmanager:us-east-1:123:secret:key" + ) try: key1 = suggestions_app._get_api_key() key2 = suggestions_app._get_api_key()