"""Unit tests for core/lambda/contract_ingestor.py. The source file lives at ``core/lambda/contract_ingestor.py`` for repo organization, but ``lambda`` is a Python reserved word — so the package path ``core.lambda`` cannot be imported with normal ``import`` syntax. The Lambda runtime packages the handler as a top-level ``contract_ingestor.py`` (handler ``contract_ingestor.lambda_handler``), which is the name the Terraform ``handler`` attribute uses. The tests mirror that by loading the module from its file path under the name ``contract_ingestor``. Uses moto (already a test dependency — see requirements-test.txt) to mock DynamoDB, mirroring the pattern in tests/test_outbox_writer.py. """ import datetime import importlib.util import json import os import sys from pathlib import Path from unittest import mock import pytest sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # Load core/lambda/contract_ingestor.py as a top-level module named # `contract_ingestor` (the name the Lambda runtime uses). _SOURCE_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "contract_ingestor.py" _spec = importlib.util.spec_from_file_location("contract_ingestor", _SOURCE_PATH) ingestor = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(ingestor) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def _local_lambda_bypass(monkeypatch): """P10 (REQ-174): set NOVA_LAMBDA_LOCAL_BYPASS for all ingestor tests so the fail-closed identity check doesn't block handler-routing tests. Tests that explicitly exercise the identity check (TestCallerIdentity Validation) override this per-test.""" monkeypatch.setenv("NOVA_LAMBDA_LOCAL_BYPASS", "1") @pytest.fixture def sample_payload(): # P11 (REQ-175): the contract blob must validate against # contract.schema.json (requires id/name/environment/infrastructure; # id matches ^[a-z][a-z0-9-]{2,5}$). return { "consumerRepo": "acdl/consumer-a", "contractId": "contract-001", "contract": { "id": "test", "name": "test-contract", "environment": "dev", "infrastructure": {"s3": {"version": "1.0.0", "inputs": {}}}, }, "environment": "dev", "action": "submit_contract", } @pytest.fixture def function_url_event(sample_payload): # P10 (REQ-174): include a test IAM identity so the fail-closed check passes. return {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": "arn:aws:sts::000:assumed-role/nova-deploy/test"}}} @pytest.fixture def moto_contracts_table(monkeypatch): """Spin up a moto-backed DynamoDB and point the ingestor at it.""" from moto import mock_aws import boto3 monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1") monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") with mock_aws(): dyn = boto3.client("dynamodb", region_name="us-east-1") dyn.create_table( TableName="nova-contracts", KeySchema=[ {"AttributeName": "consumerRepo", "KeyType": "HASH"}, {"AttributeName": "contractId#submittedAt", "KeyType": "RANGE"}, ], AttributeDefinitions=[ {"AttributeName": "consumerRepo", "AttributeType": "S"}, {"AttributeName": "contractId#submittedAt", "AttributeType": "S"}, ], BillingMode="PAY_PER_REQUEST", ) # Reset the cached boto3 clients so the ingestor picks up the moto # session, then yield with moto active. saved_dynamodb = ingestor._dynamodb saved_secrets = ingestor._secrets_client ingestor._dynamodb = None ingestor._secrets_client = None monkeypatch.setattr(ingestor, "TABLE_NAME", "nova-contracts") yield dyn ingestor._dynamodb = saved_dynamodb ingestor._secrets_client = saved_secrets # --------------------------------------------------------------------------- # submit_contract # --------------------------------------------------------------------------- class TestSubmitContract: def test_submit_contract_writes_correct_pk_sk_attributes(self, moto_contracts_table, sample_payload): result = ingestor._submit_contract(sample_payload) assert result["status"] == "ok" assert result["contractId"] == "contract-001" assert result["action"] == "submit_contract" assert "submittedAt" in result # Verify what landed in DynamoDB. sk = f"contract-001#{result['submittedAt']}" resp = moto_contracts_table.get_item( TableName="nova-contracts", Key={ "consumerRepo": {"S": "acdl/consumer-a"}, "contractId#submittedAt": {"S": sk}, }, ) assert "Item" in resp item = resp["Item"] assert item["consumerRepo"]["S"] == "acdl/consumer-a" assert item["contractId"]["S"] == "contract-001" assert item["status"]["S"] == "submitted" assert item["environment"]["S"] == "dev" assert item["submittedAt"]["S"] == result["submittedAt"] # The contract attribute holds the full contract object. boto3's # resource API serializes a dict as a DynamoDB Map (type "M"); each # leaf scalar is wrapped in its own type tag. P11 (REQ-175): the # fixture contract has a nested infrastructure map; assert the # top-level keys are present (full deep-equality is fragile with # moto's recursive type wrapping). actual_contract = item["contract"]["M"] assert set(actual_contract.keys()) == set(sample_payload["contract"].keys()) assert actual_contract["id"]["S"] == sample_payload["contract"]["id"] assert actual_contract["name"]["S"] == sample_payload["contract"]["name"] assert actual_contract["environment"]["S"] == sample_payload["contract"]["environment"] def test_submit_contract_sk_contains_contract_id_and_timestamp(self, moto_contracts_table, sample_payload): result = ingestor._submit_contract(sample_payload) sk = f"contract-001#{result['submittedAt']}" # SK format is contractId#ISO8601 assert sk.split("#")[0] == "contract-001" # timestamp parses as ISO 8601 with a Z suffix. ts = sk.split("#", 1)[1] datetime.datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ") def test_oversized_contract_rejected(self, moto_contracts_table, sample_payload): """P11 (REQ-175): a contract blob > 256 KB is rejected.""" sample_payload["contract"] = {"blob": "x" * (300 * 1024)} with pytest.raises(ValueError, match="contract payload too large"): ingestor._submit_contract(sample_payload) def test_schema_invalid_contract_rejected(self, moto_contracts_table, sample_payload, monkeypatch): """P11 (REQ-175): a contract that fails contract.schema.json validation is rejected with a clear error.""" # The autouse fixture sets NOVA_LAMBDA_LOCAL_BYPASS; unset it so # the schema validation runs (the bypass skips schema validation). monkeypatch.delenv("NOVA_LAMBDA_LOCAL_BYPASS", raising=False) # The contract schema requires id/name/environment/infrastructure; # an empty dict fails validation. sample_payload["contract"] = {} with pytest.raises(ValueError, match="contract schema validation failed"): ingestor._submit_contract(sample_payload) # --------------------------------------------------------------------------- # report_error (D-055) — GitHub issue creation via the GitHub API # --------------------------------------------------------------------------- class TestReportError: """The report_error action creates a GitHub issue on the platform repo. These tests mock the GitHub API (urllib.request.urlopen) and Secrets Manager (get_secret_value) so they run fully offline. """ @pytest.fixture def error_payload(self): return { "consumerRepo": "acdl/consumer-a", "contractId": "contract-001", "error": "deploy failed", "runUrl": "https://github.com/acdl/consumer-a/actions/runs/1", "environment": "dev", "action": "report_error", } @pytest.fixture def patched_secrets(self, monkeypatch): """Patch the Secrets Manager client to return a fake token.""" def fake_get_secret_value(SecretId): return {"SecretString": "fake-github-token-1234"} monkeypatch.setattr( ingestor, "_get_secrets_client", lambda: type("FakeSecrets", (), {"get_secret_value": staticmethod(fake_get_secret_value)})() ) def _mock_urlopen(self, monkeypatch, responses): """Patch urllib.request.urlopen to return queued responses. ``responses`` is a list of (status_code, json_body) tuples. Each call to urlopen pops the next response. The returned mock object supports context-manager use (``with urlopen(...) as resp:``) and direct call. """ import io call_log = [] class FakeResp: def __init__(self, body): self._buf = io.BytesIO(body.encode() if isinstance(body, str) else body) def read(self): return self._buf.read() def __enter__(self): return self def __exit__(self, *a): return False queue = list(responses) def fake_urlopen(req, timeout=None): call_log.append(req) if queue: status, body = queue.pop(0) return FakeResp(body) # Default: empty 200 return FakeResp("{}") monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) return call_log def test_report_error_creates_new_issue(self, monkeypatch, error_payload, patched_secrets): # Search returns no items → create a new issue. calls = self._mock_urlopen(monkeypatch, [ (200, json.dumps({"items": []})), # search (201, json.dumps({"number": 42, "html_url": "https://github.com/acdl/acdl/issues/42"})), # create ]) result = ingestor._report_error(error_payload) assert result["status"] == "issue_created" assert result["issueNumber"] == 42 assert result["issueUrl"] == "https://github.com/acdl/acdl/issues/42" assert result["contractId"] == "contract-001" assert result["action"] == "report_error" # Two API calls: search + create assert len(calls) == 2 # The create call must be a POST to the issues endpoint create_req = calls[1] assert create_req.method == "POST" assert "/issues" in create_req.full_url def test_report_error_comments_on_existing_issue(self, monkeypatch, error_payload, patched_secrets): # Search returns an existing open issue → comment on it (idempotency). calls = self._mock_urlopen(monkeypatch, [ (200, json.dumps({"items": [{"number": 99}]})), # search (found) (201, json.dumps({"id": 123, "issue_url": "https://github.com/acdl/acdl/issues/99"})), # comment ]) result = ingestor._report_error(error_payload) assert result["status"] == "commented_on_existing" assert result["issueNumber"] == 99 assert result["contractId"] == "contract-001" assert result["action"] == "report_error" # Two API calls: search + comment (no create) assert len(calls) == 2 # The comment call is a POST to the comments endpoint comment_req = calls[1] assert comment_req.method == "POST" assert "/comments" in comment_req.full_url def test_report_error_missing_field_raises(self): payload = {"consumerRepo": "acdl/consumer-a"} # missing contractId, error with pytest.raises(ValueError): ingestor._report_error(payload) def test_report_error_secrets_manager_failure_raises(self, monkeypatch, error_payload): # If Secrets Manager fails to return a token, the action should raise # a RuntimeError (caught by the top-level lambda_handler → 500). def failing_secrets(): class FailingClient: def get_secret_value(self, SecretId): raise Exception("secret not found") return FailingClient() monkeypatch.setattr(ingestor, "_get_secrets_client", failing_secrets) with pytest.raises(RuntimeError, match="failed to read GitHub token"): ingestor._report_error(error_payload) def test_report_error_truncates_stack_trace(self, monkeypatch, error_payload, patched_secrets): # P11 (REQ-175): a very long stack trace is truncated to # MAX_ERROR_FIELD_CHARS (10000) in the body (was 2000; aligned). error_payload["stackTrace"] = "x" * 20000 calls = self._mock_urlopen(monkeypatch, [ (200, json.dumps({"items": []})), (201, json.dumps({"number": 1, "html_url": "u"})), ]) result = ingestor._report_error(error_payload) assert result["status"] == "issue_created" # The create request body should contain exactly 10000 'x' chars. create_req = calls[1] body = json.loads(create_req.data.decode()) # The body markdown contains the (truncated) stack trace. assert "x" * 10000 in body["body"] assert "x" * 10001 not in body["body"] def test_lambda_handler_routes_report_error(self, monkeypatch, error_payload, patched_secrets): # End-to-end via lambda_handler: action=report_error → 200. self._mock_urlopen(monkeypatch, [ (200, json.dumps({"items": []})), (201, json.dumps({"number": 7, "html_url": "https://github.com/acdl/acdl/issues/7"})), ]) event = {"body": json.dumps(error_payload)} resp = ingestor.lambda_handler(event, None) assert resp["statusCode"] == 200 body = json.loads(resp["body"]) assert body["status"] == "issue_created" assert body["action"] == "report_error" # --------------------------------------------------------------------------- # lambda_handler wrapper (Function URL event) # --------------------------------------------------------------------------- class TestLambdaHandler: def test_submit_contract_event_returns_200(self, moto_contracts_table, function_url_event): resp = ingestor.lambda_handler(function_url_event, None) assert resp["statusCode"] == 200 body = json.loads(resp["body"]) assert body["status"] == "ok" assert body["contractId"] == "contract-001" assert body["action"] == "submit_contract" def test_body_can_be_dict_not_string(self, moto_contracts_table, sample_payload): # Some test harnesses pass body as a dict already. resp = ingestor.lambda_handler({"body": sample_payload}, None) assert resp["statusCode"] == 200 def test_missing_field_returns_400(self, moto_contracts_table): payload = { "consumerRepo": "acdl/consumer-a", # missing contractId, contract, environment } resp = ingestor.lambda_handler({"body": json.dumps(payload)}, None) assert resp["statusCode"] == 400 body = json.loads(resp["body"]) assert "missing field" in body["error"] def test_missing_required_field_contract(self, moto_contracts_table, sample_payload): del sample_payload["contract"] resp = ingestor.lambda_handler({"body": json.dumps(sample_payload)}, None) assert resp["statusCode"] == 400 assert "contract" in json.loads(resp["body"])["error"] def test_unknown_action_returns_400(self, moto_contracts_table): payload = { "consumerRepo": "acdl/consumer-a", "contractId": "contract-001", "contract": {}, "environment": "dev", "action": "do_something_else", } resp = ingestor.lambda_handler({"body": json.dumps(payload)}, None) assert resp["statusCode"] == 400 body = json.loads(resp["body"]) assert "unknown action" in body["error"] def test_default_action_is_submit_contract(self, moto_contracts_table, sample_payload): del sample_payload["action"] resp = ingestor.lambda_handler({"body": json.dumps(sample_payload)}, None) assert resp["statusCode"] == 200 body = json.loads(resp["body"]) assert body["action"] == "submit_contract" def test_internal_error_returns_500(self, moto_contracts_table, sample_payload): # Force _submit_contract to blow up after passing validation. with mock.patch.object(ingestor, "_submit_contract", side_effect=RuntimeError("boom")): resp = ingestor.lambda_handler( {"body": json.dumps(sample_payload)}, None ) assert resp["statusCode"] == 500 body = json.loads(resp["body"]) assert body["error"] == "boom" class TestCallerIdentityValidation: """P1-2: the Lambda validates consumerRepo against the invoking principal.""" def test_no_identity_fails_closed(self, moto_contracts_table, sample_payload, monkeypatch): # P10 (REQ-174): no requestContext.identity → fail closed (defense-in- # depth). The old behavior (silent pass) is replaced with a 401. monkeypatch.delenv("NOVA_LAMBDA_LOCAL_BYPASS", raising=False) event = {"body": json.dumps(sample_payload), "requestContext": {}} resp = ingestor.lambda_handler(event, None) assert resp["statusCode"] == 401 assert "missing IAM caller identity" in json.loads(resp["body"])["error"] def test_no_identity_passes_with_local_bypass(self, moto_contracts_table, sample_payload, monkeypatch): # P10 (REQ-174): the NOVA_LAMBDA_LOCAL_BYPASS env allows local/stub # testing without an IAM identity (the LocalLambdaStub sets it). monkeypatch.setenv("NOVA_LAMBDA_LOCAL_BYPASS", "1") event = {"body": json.dumps(sample_payload), "requestContext": {}} resp = ingestor.lambda_handler(event, None) assert resp["statusCode"] == 200 def test_invalid_consumer_repo_format_rejected(self, moto_contracts_table, sample_payload): # A consumerRepo without "/" is invalid (not org/repo format). sample_payload["consumerRepo"] = "not-a-repo-format" event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": "arn:aws:sts::000:assumed-role/nova-deploy/session"}}} resp = ingestor.lambda_handler(event, None) assert resp["statusCode"] == 400 assert "invalid consumerRepo" in json.loads(resp["body"])["error"] def test_valid_consumer_repo_with_identity_passes(self, moto_contracts_table, sample_payload): # A valid org/repo consumerRepo with an identity present — passes. event = { "body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": "arn:aws:sts::000:assumed-role/nova-deploy/nova-consumer-a"}}, } resp = ingestor.lambda_handler(event, None) assert resp["statusCode"] == 200 class TestForgeAgnosticApiUrls: """P1-9: contract_ingestor uses GITHUB_API_BASE for forge-agnostic URLs.""" def test_default_api_base_is_github(self): assert ingestor.GITHUB_API_BASE == "https://api.github.com" def test_forge_type_detects_generic_forge(self, monkeypatch): monkeypatch.setattr(ingestor, "GITHUB_API_BASE", "https://forge.example.com/api/v1") assert ingestor._forge_type() == "generic_forge" def test_forge_type_detects_github(self): assert ingestor._forge_type() == "github" def test_generic_forge_search_url_uses_repos_endpoint(self, monkeypatch): monkeypatch.setattr(ingestor, "GITHUB_API_BASE", "https://forge.example.com/api/v1") url = ingestor._issues_search_url("acdl", "acdl", "contract-123") assert "forge.example.com/api/v1" in url assert "/repos/acdl/acdl/issues" in url assert "/search/issues" not in url def test_github_search_url_uses_search_endpoint(self): url = ingestor._issues_search_url("acdl", "acdl", "contract-123") assert "api.github.com/search/issues" in url assert "repo:acdl/acdl" in url def test_create_url_uses_api_base(self, monkeypatch): monkeypatch.setattr(ingestor, "GITHUB_API_BASE", "https://forge.example.com/api/v1") url = ingestor._issues_create_url("acdl", "acdl") assert url == "https://forge.example.com/api/v1/repos/acdl/acdl/issues" def test_comments_url_uses_api_base(self, monkeypatch): monkeypatch.setattr(ingestor, "GITHUB_API_BASE", "https://forge.example.com/api/v1") url = ingestor._issue_comments_url("acdl", "acdl", 42) assert url == "https://forge.example.com/api/v1/repos/acdl/acdl/issues/42/comments" class TestValidateChangeRequest: """REQ-93: validate_change_request Lambda action (CMDB validation).""" @pytest.fixture def moto_change_requests_table(self, monkeypatch): from moto import mock_aws import boto3 monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1") monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") monkeypatch.setenv("CHANGE_REQUESTS_TABLE", "nova-change-requests") with mock_aws(): dynamodb = boto3.resource("dynamodb", region_name="us-east-1") table = dynamodb.create_table( TableName="nova-change-requests", KeySchema=[ {"AttributeName": "changeRequestId", "KeyType": "HASH"}, {"AttributeName": "submittedAt", "KeyType": "RANGE"}, ], AttributeDefinitions=[ {"AttributeName": "changeRequestId", "AttributeType": "S"}, {"AttributeName": "submittedAt", "AttributeType": "S"}, ], BillingMode="PAY_PER_REQUEST", ) # Insert an approved CR table.put_item(Item={ "changeRequestId": "CHG0678912", "submittedAt": "2026-07-22T10:00:00Z", "consumerRepo": "acdl/consumer-a", "contractId": "contract-001", "status": "approved", "requestedBy": "developer", "approvedBy": "sre", }) # Insert a pending CR table.put_item(Item={ "changeRequestId": "CHG0678913", "submittedAt": "2026-07-22T11:00:00Z", "consumerRepo": "acdl/consumer-b", "contractId": "contract-002", "status": "requested", "requestedBy": "developer", }) # Reset the module's dynamodb client so it picks up the moto mock ingestor._dynamodb = None yield def test_validates_approved_cr(self, moto_change_requests_table): payload = {"changeRequestId": "CHG0678912", "consumerRepo": "acdl/consumer-a"} result = ingestor._validate_change_request(payload) assert result["status"] == "approved" assert result["changeRequestId"] == "CHG0678912" def test_rejects_non_approved_cr(self, moto_change_requests_table): payload = {"changeRequestId": "CHG0678913", "consumerRepo": "acdl/consumer-b"} with pytest.raises(ValueError, match="status is 'requested'"): ingestor._validate_change_request(payload) def test_rejects_nonexistent_cr(self, moto_change_requests_table): payload = {"changeRequestId": "CHG9999999", "consumerRepo": "acdl/consumer-a"} with pytest.raises(ValueError, match="not found in CMDB"): ingestor._validate_change_request(payload) def test_rejects_repo_mismatch(self, moto_change_requests_table): payload = {"changeRequestId": "CHG0678912", "consumerRepo": "acdl/wrong-repo"} with pytest.raises(ValueError, match="consumerRepo mismatch"): ingestor._validate_change_request(payload) def test_lambda_handler_routes_validate_change_request(self, moto_change_requests_table): event = {"body": json.dumps({ "action": "validate_change_request", "changeRequestId": "CHG0678912", "consumerRepo": "acdl/consumer-a", }), "requestContext": {"identity": {"userArn": "arn:aws:sts::000:assumed-role/nova-deploy/test"}}} resp = ingestor.lambda_handler(event, None) assert resp["statusCode"] == 200 body = json.loads(resp["body"]) assert body["action"] == "validate_change_request" class TestV14IdentityValidation: """v1.14 (REQ-144): contractId format, environment enum, error length validation + spoofing resistance. P10 (REQ-174): these tests supply a valid userArn so the fail-closed identity check passes and the field validation is reached.""" _ARN = "arn:aws:sts::000:assumed-role/nova-deploy/test-session" def test_invalid_contract_id_rejected(self, moto_contracts_table, sample_payload): sample_payload["contractId"] = "bad contract!@#" event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": self._ARN}}} resp = ingestor.lambda_handler(event, None) assert resp["statusCode"] == 400 assert "invalid contractId" in resp["body"] def test_contract_id_too_long_rejected(self, moto_contracts_table, sample_payload): sample_payload["contractId"] = "a" * 65 event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": self._ARN}}} resp = ingestor.lambda_handler(event, None) assert resp["statusCode"] == 400 assert "invalid contractId" in resp["body"] def test_invalid_environment_rejected(self, moto_contracts_table, sample_payload): sample_payload["environment"] = "staging" event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": self._ARN}}} resp = ingestor.lambda_handler(event, None) assert resp["statusCode"] == 400 assert "invalid environment" in resp["body"] def test_valid_environments_accepted(self, moto_contracts_table, sample_payload): arn = "arn:aws:sts::000:assumed-role/nova-deploy/test" for env in ["dev", "qa", "prod", "dr"]: sample_payload["environment"] = env event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": arn}}} resp = ingestor.lambda_handler(event, None) assert resp["statusCode"] == 200 def test_abac_reliance_documented(self): """The _validate_caller_identity docstring documents the ABAC reliance.""" docstring = ingestor._validate_caller_identity.__doc__ assert "ABAC" in docstring assert "PrincipalTag" in docstring class TestOnboardConsumer: """P18 (REQ-182): the onboard_consumer action writes a pending CMDB row.""" _ARN = "arn:aws:sts::000:assumed-role/nova-deploy/test" def test_valid_onboarding_writes_pending_row(self, moto_contracts_table): payload = { "action": "onboard_consumer", "consumerRepo": "acdl/consumer-b", "requestedEnvironment": "dev", "ownerId": "team-b", "billingTag": "cost-center-b", } event = {"body": json.dumps(payload), "requestContext": {"identity": {"userArn": self._ARN}}} resp = ingestor.lambda_handler(event, None) assert resp["statusCode"] == 200 body = json.loads(resp["body"]) assert body["status"] == "pending" assert body["action"] == "onboard_consumer" assert body["requestedEnvironment"] == "dev" def test_invalid_onboarding_rejected(self, moto_contracts_table): # An invalid consumerRepo (no /) fails the identity format check # (which runs for all actions) before the onboarding schema. payload = { "action": "onboard_consumer", "consumerRepo": "not-a-repo-format", "requestedEnvironment": "dev", "ownerId": "team-b", "billingTag": "cost-center-b", } event = {"body": json.dumps(payload), "requestContext": {"identity": {"userArn": self._ARN}}} resp = ingestor.lambda_handler(event, None) assert resp["statusCode"] == 400 assert "invalid consumerRepo" in json.loads(resp["body"])["error"] def test_missing_onboarding_field_rejected(self, moto_contracts_table): payload = { "action": "onboard_consumer", "consumerRepo": "acdl/consumer-b", "requestedEnvironment": "dev", # ownerId + billingTag missing } event = {"body": json.dumps(payload), "requestContext": {"identity": {"userArn": self._ARN}}} resp = ingestor.lambda_handler(event, None) assert resp["statusCode"] == 400 assert "onboarding payload invalid" in json.loads(resp["body"])["error"]