fix(lambdas): SigV4-sign internal API calls and bundle Lambda dependencies (#122)
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run

The .NET API Lambda Function URL uses authType=AWS_IAM, but the four workload
Lambdas (suggestions, pdf-extract, pdf-generate, library-ingest) sent unsigned
requests with only X-Internal-Api-Key -> every internal call 403s. They also
used bare fromAsset() with no pip bundling -> ImportError at cold start. Both
made the SQS->Lambda->API pipeline non-functional when deployed (v1 pre-flight).

- Add _sign_request_headers (botocore SigV4Auth, service "lambda"); serialize the
  JSON body once and send via httpx content= so the signed payload hash matches
  the bytes sent; preserve X-Internal-Api-Key for the app-layer check. Sign per
  retry attempt to avoid SigV4 timestamp expiry on slow retries.
- Add CDK pip bundling (--platform manylinux2014_aarch64 --only-binary=:all:) to
  all four Lambdas so ARM64 wheels (reportlab, Pillow, pdfplumber) ship.
- Converge _retry_request across all four (fixes possibly-undefined return in
  pdf-extract/pdf-generate).
- Add SigV4 signing regression tests.

Verified: ruff clean, infra tsc clean, aarch64 wheels resolve for all four,
23 pytest pass. GPT-4.1 cross-family review: no BLOCK (FIX + NIT applied).
This commit is contained in:
Adam Moussa 2026-06-12 17:13:08 -04:00 • committed by GitHub
parent 252e52546e
commit 3d050bcf8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 385 additions and 43 deletions

View file

@ -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,

View file

@ -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:

View file

@ -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}")

View file

@ -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}")

View file

@ -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:

View file

@ -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):

View file

@ -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}

View file

@ -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")

View file

@ -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)

View file

@ -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()