mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 03:03:13 +00:00
fix(lambdas): SigV4-sign internal API calls and bundle Lambda dependencies (#122)
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run
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:
parent
252e52546e
commit
3d050bcf8e
10 changed files with 385 additions and 43 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
97
lambdas/tests/test_internal_api_signing.py
Normal file
97
lambdas/tests/test_internal_api_signing.py
Normal 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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue