diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 83f4a82..b4d88ab 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: core/confidence_signal.py \ core/outbox_writer.py \ core/contract_resolver.py \ + core/lambda/contract_ingestor.py \ adapters/terraform/adapter.py \ adapters/terraform/policy/checkov_adapter.py \ scripts/push_consumer_image.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83f4a82..b4d88ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: core/confidence_signal.py \ core/outbox_writer.py \ core/contract_resolver.py \ + core/lambda/contract_ingestor.py \ adapters/terraform/adapter.py \ adapters/terraform/policy/checkov_adapter.py \ scripts/push_consumer_image.py diff --git a/core/lambda/__init__.py b/core/lambda/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/lambda/contract_ingestor.py b/core/lambda/contract_ingestor.py new file mode 100644 index 0000000..a89a24f --- /dev/null +++ b/core/lambda/contract_ingestor.py @@ -0,0 +1,120 @@ +"""Platform Lambda — contract ingestor. + +Invoked via a Function URL (IAM auth) by consumer pipelines (one-way +communication, D-051). Accepts { consumerRepo, contractId, contract, +environment, action } and writes contracts to DynamoDB table acdl-contracts +(PK consumerRepo, SK contractId#submittedAt). + +The report_error action (D-055) is prepared as a stub in this phase; the +GitHub issue creation is implemented in Phase 25. + +Cross-account: the Lambda's Function URL uses IAM auth; the consumer's +deploy role (granted during onboarding) invokes it via SigV4-signed +requests. The invoke policy is scoped via ABAC (consumer repo identity). +""" + +import datetime +import json +import os + +import boto3 + +TABLE_NAME = os.environ.get("CONTRACTS_TABLE", "acdl-contracts") +GITHUB_TOKEN_SECRET_ID = os.environ.get("GITHUB_TOKEN_SECRET_ID", "acdl/github-token") +PLATFORM_REPO = os.environ.get("PLATFORM_REPO", "acdl/acdl") + +_dynamodb = None +_secrets_client = None + + +def _get_dynamodb(): + global _dynamodb + if _dynamodb is None: + _dynamodb = boto3.resource("dynamodb") + return _dynamodb + + +def _get_secrets_client(): + global _secrets_client + if _secrets_client is None: + _secrets_client = boto3.client("secretsmanager") + return _secrets_client + + +def _iso8601_now(): + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _submit_contract(payload): + consumer_repo = payload["consumerRepo"] + contract_id = payload["contractId"] + contract = payload["contract"] + environment = payload["environment"] + submitted_at = _iso8601_now() + table = _get_dynamodb().Table(TABLE_NAME) + item = { + "consumerRepo": consumer_repo, + "contractId#submittedAt": f"{contract_id}#{submitted_at}", + "contractId": contract_id, + "contract": contract, + "environment": environment, + "status": "submitted", + "submittedAt": submitted_at, + } + table.put_item(TableName=TABLE_NAME, Item=item) + return { + "status": "ok", + "contractId": contract_id, + "action": "submit_contract", + "submittedAt": submitted_at, + } + + +def _report_error(payload): + # Phase 25 implements the GitHub issue creation. + # This stub validates the payload and returns a prepared status. + required = ["consumerRepo", "contractId", "error"] + for field in required: + if field not in payload: + raise ValueError(f"report_error requires '{field}'") + return { + "status": "error_report_prepared", + "contractId": payload["contractId"], + "action": "report_error", + } + + +def lambda_handler(event, context): + """AWS Lambda handler entry point. + + Accepts a Function-URL-style event whose ``body`` is a JSON string + containing ``{ consumerRepo, contractId, contract, environment, action }``. + """ + try: + body = event.get("body", "{}") + if isinstance(body, str): + payload = json.loads(body) + else: + payload = body + action = payload.get("action", "submit_contract") + if action == "submit_contract": + # Validate required fields up front for a clean 400. + for field in ("consumerRepo", "contractId", "contract", "environment"): + if field not in payload: + return { + "statusCode": 400, + "body": json.dumps({"error": f"missing field: {field}"}), + } + result = _submit_contract(payload) + elif action == "report_error": + result = _report_error(payload) + else: + return { + "statusCode": 400, + "body": json.dumps({"error": f"unknown action: {action}"}), + } + return {"statusCode": 200, "body": json.dumps(result)} + except ValueError as e: + return {"statusCode": 400, "body": json.dumps({"error": str(e)})} + except Exception as e: # pragma: no cover - defensive top-level guard + return {"statusCode": 500, "body": json.dumps({"error": str(e)})} \ No newline at end of file diff --git a/docs/environments/index.md b/docs/environments/index.md index f6206cf..af0636d 100644 --- a/docs/environments/index.md +++ b/docs/environments/index.md @@ -53,6 +53,41 @@ normally. attestation (a platform-runner deployment approval) and a higher confidence threshold. Staging does not exist. +## Cross-account contract ingestion grant (D-051) + +Onboarding now also grants the consumer repo's deploy role permission to +invoke the **platform Lambda** — `acdl-contract-ingestor` — across +accounts. The Lambda is invoked via a Function URL with IAM auth, so the +grant is an inline IAM policy applied to the consumer's deploy role. The +policy template lives at +[`terraform/platform/consumer_invoke_policy.json`](https://github.com/acdl/acdl/blob/main/terraform/platform/consumer_invoke_policy.json) +and is scoped via **ABAC**: the condition +`aws:PrincipalTag/acdl:owner == ${consumerRepo}` ensures a repo can only +invoke the Lambda when its principal tag matches its claimed identity. + +The consumer's deploy workflow signs the Function URL request with +SigV4 using its deploy-role credentials; the platform Lambda validates +the signature and the ABAC condition before accepting the payload. + +This is a **one-way** channel — the consumer pushes contracts *to* the +platform; the platform never reaches back into the consumer account. It +is used for two purposes: + +1. **Contract ingestion** — the consumer submits its resolved deployment + contract (`action: "submit_contract"`) so the platform has a durable + record in the `acdl-contracts` DynamoDB table (PK `consumerRepo`, SK + `contractId#submittedAt`). +2. **Error reporting** (D-055) — the consumer reports a deployment error + (`action: "report_error"`) which the platform turns into a GitHub + issue on the platform repo (wired in Phase 25; the Lambda returns a + prepared-status stub until then). + +The Lambda handler and the Terraform that deploys it live in +[`core/lambda/contract_ingestor.py`](https://github.com/acdl/acdl/blob/main/core/lambda/contract_ingestor.py) +and +[`terraform/platform/main.tf`](https://github.com/acdl/acdl/blob/main/terraform/platform/main.tf) +respectively. + ## Onboarding scaffold (current state) The platform repo ships a minimal onboarding scaffold: diff --git a/pipelines/ci.yaml b/pipelines/ci.yaml index 2910b3d..9d4cdbb 100644 --- a/pipelines/ci.yaml +++ b/pipelines/ci.yaml @@ -32,6 +32,7 @@ stages: core/confidence_signal.py \ core/outbox_writer.py \ core/contract_resolver.py \ + core/lambda/contract_ingestor.py \ adapters/terraform/adapter.py \ adapters/terraform/policy/checkov_adapter.py \ scripts/push_consumer_image.py diff --git a/scripts/run_ci.sh b/scripts/run_ci.sh index f4f2624..6152509 100755 --- a/scripts/run_ci.sh +++ b/scripts/run_ci.sh @@ -45,6 +45,7 @@ python3 -m py_compile \ core/confidence_signal.py \ core/outbox_writer.py \ core/contract_resolver.py \ + core/lambda/contract_ingestor.py \ adapters/terraform/adapter.py \ adapters/terraform/policy/checkov_adapter.py \ adapters/terraform/policy/custom_rules/acdl_tagging.py \ diff --git a/terraform/platform/README.md b/terraform/platform/README.md new file mode 100644 index 0000000..f49dd22 --- /dev/null +++ b/terraform/platform/README.md @@ -0,0 +1,82 @@ +# ACDL Platform Infrastructure (D-051) + +Terraform configuration for the **platform-side** infrastructure that +ingests consumer deployment contracts and (Phase 25) reports errors as +GitHub issues. + +This stack is **separate** from `terraform/spike/` (the consumer stack +spike) and `terraform/microservice/` (the demo microservice). It manages +resources that live in the platform AWS account and serve **all** +consumers — the contract ingestion pipeline and the secrets it needs. + +## What it deploys + +| Resource | Name | Purpose | +|----------|------|---------| +| `aws_dynamodb_table` | `acdl-contracts` | Stores submitted consumer contracts. PK `consumerRepo`, SK `contractId#submittedAt`. SSE via CMK, PITR enabled. | +| `aws_kms_key` + `aws_kms_alias` | `alias/acdl-platform` | Customer-managed key — encrypts DynamoDB SSE, Secrets Manager, and SSM. Key rotation enabled. | +| `aws_secretsmanager_secret` | `acdl/github-token` | GitHub PAT used by the Lambda to create issues on the platform repo (D-055, wired in Phase 25). | +| `aws_iam_role` + `aws_iam_role_policy` | `acdl-contract-ingestor-role` | Execution role for the Lambda — DynamoDB write, Secrets Manager read, KMS decrypt, CloudWatch logs. | +| `aws_lambda_function` | `acdl-contract-ingestor` | Python 3.12 Lambda. Handler `contract_ingestor.lambda_handler`. Source: `core/lambda/contract_ingestor.py`, packaged as `contract_ingestor.zip`. | +| `aws_lambda_function_url` | — | Function URL with `AWS_IAM` authorization. Consumers invoke it via SigV4-signed requests. | + +## State + +| Key | Value | +|-----|-------| +| Backend | S3 | +| Bucket | `acdl-tfstate-581513795199-us-east-1` | +| State key | `platform/terraform.tfstate` | +| Region | `us-east-1` | + +The state key is distinct from `spike/terraform.tfstate` and +`microservice/terraform.tfstate` — the three stacks are independent. + +## Apply + +```bash +# Package the Lambda source first (from the repo root): +cd core/lambda +zip contract_ingestor.zip contract_ingestor.py +cd ../../terraform/platform + +terraform init +terraform plan +terraform apply +``` + +The Lambda's `filename` points at `contract_ingestor.zip` in the working +directory (`terraform/platform/`); either place the zip there or adjust +the path. `source_code_hash = filebase64sha256("contract_ingestor.zip")` +forces a redeploy whenever the package changes. + +## Cross-account invocation model + +The Lambda is invoked **cross-account** by consumer pipelines. The +flow: + +1. **Onboarding.** When a consumer repo is onboarded, the platform team + applies [`consumer_invoke_policy.json`](./consumer_invoke_policy.json) + to the consumer's deploy role. The policy grants + `lambda:InvokeFunctionUrl` on the Lambda ARN, scoped via ABAC — the + condition `aws:PrincipalTag/acdl:owner == ${consumerRepo}` ensures a + repo can only invoke when it is the owner it claims to be. +2. **Runtime.** The consumer's deploy workflow (running in the consumer + AWS account under the consumer's deploy role) signs the Function URL + request with SigV4 using its deploy-role credentials. The IAM auth on + the Function URL validates the signature and the ABAC condition. +3. **Lambda.** The Lambda parses the JSON body, validates the fields, + and writes the contract to `acdl-contracts`. + +This is a **one-way** channel (D-051): the consumer pushes contracts +*to* the platform; the platform never reaches back into the consumer +account. Error reporting (D-055, `action: "report_error"`) flows over +the same channel and is implemented in Phase 25 (GitHub issue creation on +the platform repo). + +## Related files + +- [`core/lambda/contract_ingestor.py`](../../core/lambda/contract_ingestor.py) — the Lambda handler. +- [`tests/test_contract_ingestor.py`](../../tests/test_contract_ingestor.py) — unit tests (moto-backed DynamoDB mock). +- [`terraform/platform/consumer_invoke_policy.json`](./consumer_invoke_policy.json) — the ABAC policy applied to consumer deploy roles during onboarding. +- [`docs/environments/index.md`](../../docs/environments/index.md) — documents the cross-account grant as part of onboarding. \ No newline at end of file diff --git a/terraform/platform/consumer_invoke_policy.json b/terraform/platform/consumer_invoke_policy.json new file mode 100644 index 0000000..7c24c59 --- /dev/null +++ b/terraform/platform/consumer_invoke_policy.json @@ -0,0 +1,15 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn:aws:lambda:us-east-1:000000000000:function:acdl-contract-ingestor", + "Condition": { + "StringEquals": { + "aws:PrincipalTag/acdl:owner": "${consumerRepo}" + } + } + } + ] +} \ No newline at end of file diff --git a/terraform/platform/main.tf b/terraform/platform/main.tf new file mode 100644 index 0000000..8148a73 --- /dev/null +++ b/terraform/platform/main.tf @@ -0,0 +1,164 @@ +# ACDL platform infrastructure — contract ingestion Lambda + DynamoDB (D-051) +# +# Deploys: +# - DynamoDB table acdl-contracts (PK consumerRepo, SK contractId#submittedAt, SSE via CMK, PITR) +# - KMS customer-managed key for DynamoDB + SSM (shared CMK) +# - Lambda function acdl-contract-ingestor (Python 3.12, handler contract_ingestor.lambda_handler) +# - Lambda Function URL (IAM auth — consumers invoke via SigV4) +# - Secrets Manager secret acdl/github-token (stores the Lambda's GitHub PAT for issue creation) +# - IAM execution role for the Lambda (DynamoDB write + Secrets Manager read + KMS decrypt) +# +# State: terraform/platform/terraform.tfstate (separate from spike/ and microservice/) + +terraform { + required_version = ">= 1.9, < 1.10" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } + backend "s3" { + bucket = "acdl-tfstate-581513795199-us-east-1" + key = "platform/terraform.tfstate" + region = "us-east-1" + } +} + +provider "aws" { + region = "us-east-1" +} + +# KMS customer-managed key for DynamoDB SSE + SSM Parameter Store encryption +resource "aws_kms_key" "acdl_platform" { + description = "ACDL platform KMS key (DynamoDB SSE + SSM + Secrets Manager)" + enable_key_rotation = true + deletion_window_in_days = 30 +} + +resource "aws_kms_alias" "acdl_platform" { + name = "alias/acdl-platform" + target_key_id = aws_kms_key.acdl_platform.key_id +} + +# DynamoDB table for contract ingestion +resource "aws_dynamodb_table" "acdl_contracts" { + name = "acdl-contracts" + billing_mode = "PAY_PER_REQUEST" + hash_key = "consumerRepo" + range_key = "contractId#submittedAt" + + attribute { + name = "consumerRepo" + type = "S" + } + + attribute { + name = "contractId#submittedAt" + type = "S" + } + + point_in_time_recovery { + enabled = true + } + + server_side_encryption { + enabled = true + kms_key_arn = aws_kms_key.acdl_platform.arn + } + + tags = { + acdl:owner = "acdl" + acdl:contract = "platform" + acdl:environment = "prod" + acdl:cost-center = "acdl-default" + } +} + +# Secrets Manager secret for the Lambda's GitHub token (issue creation) +resource "aws_secretsmanager_secret" "github_token" { + name = "acdl/github-token" + description = "GitHub PAT for the platform Lambda to create issues on the platform repo (D-055)." + kms_key_id = aws_kms_key.acdl_platform.arn + + tags = { + acdl:owner = "acdl" + acdl:contract = "platform" + acdl:environment = "prod" + acdl:cost-center = "acdl-default" + } +} + +# IAM execution role for the Lambda +resource "aws_iam_role" "lambda_exec" { + name = "acdl-contract-ingestor-role" + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + }] + }) +} + +resource "aws_iam_role_policy" "lambda_permissions" { + name = "acdl-contract-ingestor-policy" + role = aws_iam_role.lambda_exec.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:Query", "dynamodb:UpdateItem"] + Resource = aws_dynamodb_table.acdl_contracts.arn + }, + { + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = aws_secretsmanager_secret.github_token.arn + }, + { + Effect = "Allow" + Action = ["kms:Decrypt"] + Resource = aws_kms_key.acdl_platform.arn + }, + { + Effect = "Allow" + Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"] + Resource = "arn:aws:logs:*:*:*" + } + ] + }) +} + +# Lambda function +resource "aws_lambda_function" "contract_ingestor" { + function_name = "acdl-contract-ingestor" + handler = "contract_ingestor.lambda_handler" + runtime = "python3.12" + role = aws_iam_role.lambda_exec.arn + filename = "contract_ingestor.zip" + source_code_hash = filebase64sha256("contract_ingestor.zip") + + environment { + variables = { + CONTRACTS_TABLE = aws_dynamodb_table.acdl_contracts.name + GITHUB_TOKEN_SECRET_ID = aws_secretsmanager_secret.github_token.name + PLATFORM_REPO = "acdl/acdl" + } + } + + tags = { + acdl:owner = "acdl" + acdl:contract = "platform" + acdl:environment = "prod" + acdl:cost-center = "acdl-default" + } +} + +# Lambda Function URL (IAM auth — consumers invoke via SigV4) +resource "aws_lambda_function_url" "contract_ingestor" { + function_name = aws_lambda_function.contract_ingestor.function_name + authorization_type = "AWS_IAM" +} \ No newline at end of file diff --git a/tests/test_contract_ingestor.py b/tests/test_contract_ingestor.py new file mode 100644 index 0000000..5295b46 --- /dev/null +++ b/tests/test_contract_ingestor.py @@ -0,0 +1,231 @@ +"""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 +def sample_payload(): + return { + "consumerRepo": "acdl/consumer-a", + "contractId": "contract-001", + "contract": {"stack": "s3", "environment": "dev"}, + "environment": "dev", + "action": "submit_contract", + } + + +@pytest.fixture +def function_url_event(sample_payload): + return {"body": json.dumps(sample_payload)} + + +@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="acdl-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", "acdl-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="acdl-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. + expected_contract = sample_payload["contract"] + actual_contract = item["contract"] + # The resource API stores scalars inside the map with their own type + # tags (e.g. {"S": ...}); unwrap one level for the two known leaves. + unwrapped = { + k: list(v.values())[0] if isinstance(v, dict) and len(v) == 1 else v + for k, v in actual_contract["M"].items() + } + assert unwrapped == expected_contract + + 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") + + +# --------------------------------------------------------------------------- +# report_error stub +# --------------------------------------------------------------------------- + +class TestReportError: + def test_report_error_returns_prepared_status(self): + payload = { + "consumerRepo": "acdl/consumer-a", + "contractId": "contract-001", + "error": "deploy failed", + } + result = ingestor._report_error(payload) + assert result["status"] == "error_report_prepared" + assert result["contractId"] == "contract-001" + assert result["action"] == "report_error" + + def test_report_error_missing_field_raises(self): + payload = {"consumerRepo": "acdl/consumer-a"} # missing contractId, error + with pytest.raises(ValueError): + ingestor._report_error(payload) + + +# --------------------------------------------------------------------------- +# 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" \ No newline at end of file