0d2cbdb423
Genericize forge-detection code: gitea→forge/generic_forge, GITEA_ACTOR→FORGE_ACTOR. Drop .gitea byte-identity test assertions (keep GitHub-side + contract conformance). Add test_no_forge_mentions.py guard test (REQ-230). Delete completed migration docs (NOVA_MIGRATION.md, NOVA_AWS_MIGRATION.md). Move NO_HUMANS_THESIS.md to .ciagent/ (internal artifact). Strip ciagent-internal provenance from synced docs (REQ-/D-/P-/CAP- IDs, milestone headers, .ciagent/PROJECT.md citations). Trim README.md (reusable deploy section, local key rotation paragraph). Fix version-tag drift (@v1.13→@v1.19, acdl/→nova/). ---ci--- project: acdl phase: 1 milestone: v1.20 status: execute requirements: [REQ-230, REQ-231, REQ-232] ---/ci---
645 lines
29 KiB
Python
645 lines
29 KiB
Python
"""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"]
|