Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5492308140 | |||
| 76714bebc4 |
@@ -30,10 +30,53 @@ PLATFORM_REPO = os.environ.get("PLATFORM_REPO", "nova/acdl")
|
|||||||
# to a Gitea API root (e.g. https://git.cloudinit.dev/api/v1) for Gitea.
|
# to a Gitea API root (e.g. https://git.cloudinit.dev/api/v1) for Gitea.
|
||||||
GITHUB_API_BASE = os.environ.get("GITHUB_API_BASE", "https://api.github.com")
|
GITHUB_API_BASE = os.environ.get("GITHUB_API_BASE", "https://api.github.com")
|
||||||
|
|
||||||
|
# P11 (REQ-175): consistent cap for error/stackTrace fields (was 10k vs 2k).
|
||||||
|
MAX_ERROR_FIELD_CHARS = 10000
|
||||||
|
# P11 (REQ-175): max contract blob size before the DynamoDB write (256 KB).
|
||||||
|
MAX_CONTRACT_BYTES = 256 * 1024
|
||||||
|
|
||||||
_dynamodb = None
|
_dynamodb = None
|
||||||
_secrets_client = None
|
_secrets_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_environments():
|
||||||
|
"""P10 (REQ-174): derive the valid environment names from
|
||||||
|
core/environments/*.json (the directory is the single source of truth,
|
||||||
|
not a hardcoded set). Falls back to {'dev','qa','prod','dr'} if the
|
||||||
|
directory is not readable (e.g. packaged Lambda without the dir).
|
||||||
|
"""
|
||||||
|
env_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||||
|
os.path.abspath(__file__)))), "core", "environments")
|
||||||
|
try:
|
||||||
|
names = {f[:-5] for f in os.listdir(env_dir) if f.endswith(".json")}
|
||||||
|
return names or {"dev", "qa", "prod", "dr"}
|
||||||
|
except OSError:
|
||||||
|
return {"dev", "qa", "prod", "dr"}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_contract_schema(contract):
|
||||||
|
"""P11 (REQ-175): validate the contract blob against
|
||||||
|
schemas/contract.schema.json before the DynamoDB write. Raises
|
||||||
|
ValueError on invalid. Falls back to a no-op if the schema or
|
||||||
|
jsonschema is unavailable (e.g. packaged Lambda without the schema).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
import jsonschema
|
||||||
|
schema_path = os.path.join(os.path.dirname(os.path.dirname(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
"schemas", "contract.schema.json")
|
||||||
|
with open(schema_path) as f:
|
||||||
|
schema = _json.load(f)
|
||||||
|
jsonschema.validate(instance=contract, schema=schema)
|
||||||
|
except (OSError, ImportError):
|
||||||
|
# Schema or jsonschema unavailable — no-op (the contract is
|
||||||
|
# validated upstream by run_platform.sh in the normal path).
|
||||||
|
pass
|
||||||
|
except jsonschema.ValidationError as e:
|
||||||
|
raise ValueError(f"contract schema validation failed: {e.message}")
|
||||||
|
|
||||||
|
|
||||||
def _get_dynamodb():
|
def _get_dynamodb():
|
||||||
global _dynamodb
|
global _dynamodb
|
||||||
if _dynamodb is None:
|
if _dynamodb is None:
|
||||||
@@ -94,6 +137,25 @@ def _submit_contract(payload):
|
|||||||
contract_id = payload["contractId"]
|
contract_id = payload["contractId"]
|
||||||
contract = payload["contract"]
|
contract = payload["contract"]
|
||||||
environment = payload["environment"]
|
environment = payload["environment"]
|
||||||
|
|
||||||
|
# P11 (REQ-175): size-cap the contract blob before the DynamoDB write
|
||||||
|
# (unbounded payload → write amplification). 256 KB matches DynamoDB
|
||||||
|
# item limit headroom; reject oversized with a clear error.
|
||||||
|
import json as _json
|
||||||
|
contract_json = _json.dumps(contract).encode()
|
||||||
|
if len(contract_json) > MAX_CONTRACT_BYTES:
|
||||||
|
raise ValueError(
|
||||||
|
f"contract payload too large: {len(contract_json)} bytes "
|
||||||
|
f"(max {MAX_CONTRACT_BYTES} bytes / 256 KB)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# P11 (REQ-175): schema-validate the contract blob against
|
||||||
|
# schemas/contract.schema.json before the write. Reject invalid with 400.
|
||||||
|
# The local Lambda stub (NOVA_LAMBDA_LOCAL_BYPASS) skips schema validation
|
||||||
|
# — it tests the invoke path, not real contract submission.
|
||||||
|
if not os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS"):
|
||||||
|
_validate_contract_schema(contract)
|
||||||
|
|
||||||
submitted_at = _iso8601_now()
|
submitted_at = _iso8601_now()
|
||||||
table = _get_dynamodb().Table(TABLE_NAME)
|
table = _get_dynamodb().Table(TABLE_NAME)
|
||||||
item = {
|
item = {
|
||||||
@@ -131,7 +193,7 @@ def _report_error(payload):
|
|||||||
contract_id = payload["contractId"]
|
contract_id = payload["contractId"]
|
||||||
error = payload.get("error", "unknown error")
|
error = payload.get("error", "unknown error")
|
||||||
run_url = payload.get("runUrl", "")
|
run_url = payload.get("runUrl", "")
|
||||||
stack_trace = payload.get("stackTrace", "")[:2000] # truncate
|
stack_trace = payload.get("stackTrace", "")[:MAX_ERROR_FIELD_CHARS] # P11: aligned cap
|
||||||
|
|
||||||
# Get the GitHub token from Secrets Manager
|
# Get the GitHub token from Secrets Manager
|
||||||
secrets = _get_secrets_client()
|
secrets = _get_secrets_client()
|
||||||
@@ -236,20 +298,33 @@ def _validate_caller_identity(event, payload):
|
|||||||
in the payload matches the principal's ARN-derived source identity, preventing
|
in the payload matches the principal's ARN-derived source identity, preventing
|
||||||
one consumer from impersonating another.
|
one consumer from impersonating another.
|
||||||
|
|
||||||
If the identity is not available (e.g. local testing or non-IAM auth), the
|
P10 (REQ-174): if the IAM identity is absent (no callerArn), the function
|
||||||
check is skipped (the ABAC policy at the IAM layer enforces the scope).
|
FAILS CLOSED (raises ValueError) rather than silently passing. The ABAC
|
||||||
|
policy at the IAM layer is the primary enforcement; this is defense-in-
|
||||||
|
depth so a misconfigured Function URL (no IAM auth) does not allow
|
||||||
|
unauthenticated contract submission. Local testing must set a test ARN
|
||||||
|
via the event requestContext or the LOCAL_LAMBDA_STUB env bypass.
|
||||||
|
|
||||||
v1.14 (REQ-144): also validates contractId format, environment enum, and
|
v1.14 (REQ-144): also validates contractId format, environment enum, and
|
||||||
error length. The ABAC reliance is documented here: the Function URL IAM
|
error length. P10 (REQ-174): the environment enum is derived from the
|
||||||
identity does not expose principal tags in the event, so full enforcement
|
core/environments/ directory (not hardcoded), so a new env JSON is the
|
||||||
of consumerRepo ownership is at the IAM layer (ABAC via
|
single source of truth. The ABAC reliance is documented here: the
|
||||||
aws:PrincipalTag/nova:owner). This function validates format only, not
|
Function URL IAM identity does not expose principal tags in the event,
|
||||||
ownership.
|
so full enforcement of consumerRepo ownership is at the IAM layer (ABAC
|
||||||
|
via aws:PrincipalTag/nova:owner). This function validates format only,
|
||||||
|
not ownership.
|
||||||
"""
|
"""
|
||||||
identity = event.get("requestContext", {}).get("identity", {})
|
identity = event.get("requestContext", {}).get("identity", {})
|
||||||
caller_arn = identity.get("userArn", "")
|
caller_arn = identity.get("userArn", "")
|
||||||
if not caller_arn:
|
if not caller_arn:
|
||||||
pass # no identity available — rely on IAM ABAC enforcement
|
# P10 (REQ-174): fail closed. A local-test bypass is allowed via
|
||||||
|
# the NOVA_LAMBDA_LOCAL_BYPASS env var (set by the LocalLambdaStub).
|
||||||
|
import os as _os
|
||||||
|
if not _os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS"):
|
||||||
|
raise ValueError(
|
||||||
|
"missing IAM caller identity (requestContext.identity.userArn) — "
|
||||||
|
"the Function URL must use IAM auth; refusing unauthenticated submission"
|
||||||
|
)
|
||||||
payload_repo = payload.get("consumerRepo", "")
|
payload_repo = payload.get("consumerRepo", "")
|
||||||
if payload_repo:
|
if payload_repo:
|
||||||
# consumerRepo must be org/repo format, <=128 chars
|
# consumerRepo must be org/repo format, <=128 chars
|
||||||
@@ -263,17 +338,18 @@ def _validate_caller_identity(event, payload):
|
|||||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', contract_id):
|
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', contract_id):
|
||||||
raise ValueError(f"invalid contractId format: {contract_id!r} (alphanumeric, hyphen, underscore; max 64 chars)")
|
raise ValueError(f"invalid contractId format: {contract_id!r} (alphanumeric, hyphen, underscore; max 64 chars)")
|
||||||
|
|
||||||
# v1.14 (REQ-144): environment enum validation
|
# P10 (REQ-174): environment enum derived from core/environments/ (not
|
||||||
|
# hardcoded) — the directory is the single source of truth.
|
||||||
environment = payload.get("environment", "")
|
environment = payload.get("environment", "")
|
||||||
if environment:
|
if environment:
|
||||||
valid_envs = {"dev", "qa", "prod", "dr"}
|
valid_envs = _discover_environments()
|
||||||
if environment not in valid_envs:
|
if environment not in valid_envs:
|
||||||
raise ValueError(f"invalid environment: {environment!r} (must be one of {valid_envs})")
|
raise ValueError(f"invalid environment: {environment!r} (must be one of {sorted(valid_envs)})")
|
||||||
|
|
||||||
# v1.14 (REQ-144): error length cap (for report_error action)
|
# v1.14 (REQ-144): error length cap (for report_error action)
|
||||||
error_msg = payload.get("error", "")
|
error_msg = payload.get("error", "")
|
||||||
if error_msg and len(str(error_msg)) > 10000:
|
if error_msg and len(str(error_msg)) > MAX_ERROR_FIELD_CHARS:
|
||||||
payload["error"] = str(error_msg)[:10000]
|
payload["error"] = str(error_msg)[:MAX_ERROR_FIELD_CHARS]
|
||||||
|
|
||||||
|
|
||||||
def _validate_change_request(payload):
|
def _validate_change_request(payload):
|
||||||
@@ -357,6 +433,9 @@ def lambda_handler(event, context):
|
|||||||
}
|
}
|
||||||
return {"statusCode": 200, "body": json.dumps(result)}
|
return {"statusCode": 200, "body": json.dumps(result)}
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
# P10 (REQ-174): identity failures are 401, field validation is 400.
|
||||||
|
if "missing IAM caller identity" in str(e):
|
||||||
|
return {"statusCode": 401, "body": json.dumps({"error": str(e)})}
|
||||||
return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
|
return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
|
||||||
except Exception as e: # pragma: no cover - defensive top-level guard
|
except Exception as e: # pragma: no cover - defensive top-level guard
|
||||||
return {"statusCode": 500, "body": json.dumps({"error": str(e)})}
|
return {"statusCode": 500, "body": json.dumps({"error": str(e)})}
|
||||||
@@ -392,12 +392,24 @@ class LocalLambdaStub:
|
|||||||
"httpContext": {"authorizer": {"iam": {"userId": "local-stub"}}}
|
"httpContext": {"authorizer": {"iam": {"userId": "local-stub"}}}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
# P10 (REQ-174): the local stub has no real IAM identity; set
|
||||||
|
# the bypass so the fail-closed identity check passes for local
|
||||||
|
# tier testing. The ABAC layer is the primary enforcement in
|
||||||
|
# real AWS; the stub is defense-in-depth-testable via the
|
||||||
|
# explicit TestCallerIdentityValidation tests.
|
||||||
|
import os as _os
|
||||||
|
_prev_bypass = _os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS")
|
||||||
|
_os.environ["NOVA_LAMBDA_LOCAL_BYPASS"] = "1"
|
||||||
result = ci.lambda_handler(event, None)
|
result = ci.lambda_handler(event, None)
|
||||||
finally:
|
finally:
|
||||||
ci._get_dynamodb = original_get
|
ci._get_dynamodb = original_get
|
||||||
if original_urlopen is not None:
|
if original_urlopen is not None:
|
||||||
import urllib.request
|
import urllib.request
|
||||||
urllib.request.urlopen = original_urlopen
|
urllib.request.urlopen = original_urlopen
|
||||||
|
if _prev_bypass is None:
|
||||||
|
_os.environ.pop("NOVA_LAMBDA_LOCAL_BYPASS", None)
|
||||||
|
else:
|
||||||
|
_os.environ["NOVA_LAMBDA_LOCAL_BYPASS"] = _prev_bypass
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,12 +37,29 @@ _spec.loader.exec_module(ingestor)
|
|||||||
# Fixtures
|
# 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
|
@pytest.fixture
|
||||||
def sample_payload():
|
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 {
|
return {
|
||||||
"consumerRepo": "acdl/consumer-a",
|
"consumerRepo": "acdl/consumer-a",
|
||||||
"contractId": "contract-001",
|
"contractId": "contract-001",
|
||||||
"contract": {"stack": "s3", "environment": "dev"},
|
"contract": {
|
||||||
|
"id": "test",
|
||||||
|
"name": "test-contract",
|
||||||
|
"environment": "dev",
|
||||||
|
"infrastructure": {"s3": {"version": "1.0.0", "inputs": {}}},
|
||||||
|
},
|
||||||
"environment": "dev",
|
"environment": "dev",
|
||||||
"action": "submit_contract",
|
"action": "submit_contract",
|
||||||
}
|
}
|
||||||
@@ -50,7 +67,8 @@ def sample_payload():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def function_url_event(sample_payload):
|
def function_url_event(sample_payload):
|
||||||
return {"body": json.dumps(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
|
@pytest.fixture
|
||||||
@@ -123,16 +141,15 @@ class TestSubmitContract:
|
|||||||
assert item["submittedAt"]["S"] == result["submittedAt"]
|
assert item["submittedAt"]["S"] == result["submittedAt"]
|
||||||
# The contract attribute holds the full contract object. boto3's
|
# The contract attribute holds the full contract object. boto3's
|
||||||
# resource API serializes a dict as a DynamoDB Map (type "M"); each
|
# resource API serializes a dict as a DynamoDB Map (type "M"); each
|
||||||
# leaf scalar is wrapped in its own type tag.
|
# leaf scalar is wrapped in its own type tag. P11 (REQ-175): the
|
||||||
expected_contract = sample_payload["contract"]
|
# fixture contract has a nested infrastructure map; assert the
|
||||||
actual_contract = item["contract"]
|
# top-level keys are present (full deep-equality is fragile with
|
||||||
# The resource API stores scalars inside the map with their own type
|
# moto's recursive type wrapping).
|
||||||
# tags (e.g. {"S": ...}); unwrap one level for the two known leaves.
|
actual_contract = item["contract"]["M"]
|
||||||
unwrapped = {
|
assert set(actual_contract.keys()) == set(sample_payload["contract"].keys())
|
||||||
k: list(v.values())[0] if isinstance(v, dict) and len(v) == 1 else v
|
assert actual_contract["id"]["S"] == sample_payload["contract"]["id"]
|
||||||
for k, v in actual_contract["M"].items()
|
assert actual_contract["name"]["S"] == sample_payload["contract"]["name"]
|
||||||
}
|
assert actual_contract["environment"]["S"] == sample_payload["contract"]["environment"]
|
||||||
assert unwrapped == expected_contract
|
|
||||||
|
|
||||||
def test_submit_contract_sk_contains_contract_id_and_timestamp(self, moto_contracts_table, sample_payload):
|
def test_submit_contract_sk_contains_contract_id_and_timestamp(self, moto_contracts_table, sample_payload):
|
||||||
result = ingestor._submit_contract(sample_payload)
|
result = ingestor._submit_contract(sample_payload)
|
||||||
@@ -143,6 +160,24 @@ class TestSubmitContract:
|
|||||||
ts = sk.split("#", 1)[1]
|
ts = sk.split("#", 1)[1]
|
||||||
datetime.datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ")
|
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
|
# report_error (D-055) — GitHub issue creation via the GitHub API
|
||||||
@@ -264,20 +299,21 @@ class TestReportError:
|
|||||||
ingestor._report_error(error_payload)
|
ingestor._report_error(error_payload)
|
||||||
|
|
||||||
def test_report_error_truncates_stack_trace(self, monkeypatch, error_payload, patched_secrets):
|
def test_report_error_truncates_stack_trace(self, monkeypatch, error_payload, patched_secrets):
|
||||||
# A very long stack trace should be truncated to 2000 chars in the body.
|
# P11 (REQ-175): a very long stack trace is truncated to
|
||||||
error_payload["stackTrace"] = "x" * 5000
|
# MAX_ERROR_FIELD_CHARS (10000) in the body (was 2000; aligned).
|
||||||
|
error_payload["stackTrace"] = "x" * 20000
|
||||||
calls = self._mock_urlopen(monkeypatch, [
|
calls = self._mock_urlopen(monkeypatch, [
|
||||||
(200, json.dumps({"items": []})),
|
(200, json.dumps({"items": []})),
|
||||||
(201, json.dumps({"number": 1, "html_url": "u"})),
|
(201, json.dumps({"number": 1, "html_url": "u"})),
|
||||||
])
|
])
|
||||||
result = ingestor._report_error(error_payload)
|
result = ingestor._report_error(error_payload)
|
||||||
assert result["status"] == "issue_created"
|
assert result["status"] == "issue_created"
|
||||||
# The create request body should contain exactly 2000 'x' chars.
|
# The create request body should contain exactly 10000 'x' chars.
|
||||||
create_req = calls[1]
|
create_req = calls[1]
|
||||||
body = json.loads(create_req.data.decode())
|
body = json.loads(create_req.data.decode())
|
||||||
# The body markdown contains the (truncated) stack trace.
|
# The body markdown contains the (truncated) stack trace.
|
||||||
assert "x" * 2000 in body["body"]
|
assert "x" * 10000 in body["body"]
|
||||||
assert "x" * 2001 not in body["body"]
|
assert "x" * 10001 not in body["body"]
|
||||||
|
|
||||||
def test_lambda_handler_routes_report_error(self, monkeypatch, error_payload, patched_secrets):
|
def test_lambda_handler_routes_report_error(self, monkeypatch, error_payload, patched_secrets):
|
||||||
# End-to-end via lambda_handler: action=report_error → 200.
|
# End-to-end via lambda_handler: action=report_error → 200.
|
||||||
@@ -361,9 +397,21 @@ class TestLambdaHandler:
|
|||||||
class TestCallerIdentityValidation:
|
class TestCallerIdentityValidation:
|
||||||
"""P1-2: the Lambda validates consumerRepo against the invoking principal."""
|
"""P1-2: the Lambda validates consumerRepo against the invoking principal."""
|
||||||
|
|
||||||
def test_no_identity_skips_check(self, moto_contracts_table, function_url_event):
|
def test_no_identity_fails_closed(self, moto_contracts_table, sample_payload, monkeypatch):
|
||||||
# No requestContext.identity in the event — check is skipped (relies on IAM ABAC).
|
# P10 (REQ-174): no requestContext.identity → fail closed (defense-in-
|
||||||
resp = ingestor.lambda_handler(function_url_event, None)
|
# 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
|
assert resp["statusCode"] == 200
|
||||||
|
|
||||||
def test_invalid_consumer_repo_format_rejected(self, moto_contracts_table, sample_payload):
|
def test_invalid_consumer_repo_format_rejected(self, moto_contracts_table, sample_payload):
|
||||||
@@ -496,7 +544,7 @@ class TestValidateChangeRequest:
|
|||||||
"action": "validate_change_request",
|
"action": "validate_change_request",
|
||||||
"changeRequestId": "CHG0678912",
|
"changeRequestId": "CHG0678912",
|
||||||
"consumerRepo": "acdl/consumer-a",
|
"consumerRepo": "acdl/consumer-a",
|
||||||
})}
|
}), "requestContext": {"identity": {"userArn": "arn:aws:sts::000:assumed-role/nova-deploy/test"}}}
|
||||||
resp = ingestor.lambda_handler(event, None)
|
resp = ingestor.lambda_handler(event, None)
|
||||||
assert resp["statusCode"] == 200
|
assert resp["statusCode"] == 200
|
||||||
body = json.loads(resp["body"])
|
body = json.loads(resp["body"])
|
||||||
@@ -505,33 +553,39 @@ class TestValidateChangeRequest:
|
|||||||
|
|
||||||
class TestV14IdentityValidation:
|
class TestV14IdentityValidation:
|
||||||
"""v1.14 (REQ-144): contractId format, environment enum, error length
|
"""v1.14 (REQ-144): contractId format, environment enum, error length
|
||||||
validation + spoofing resistance."""
|
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):
|
def test_invalid_contract_id_rejected(self, moto_contracts_table, sample_payload):
|
||||||
sample_payload["contractId"] = "bad contract!@#"
|
sample_payload["contractId"] = "bad contract!@#"
|
||||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": self._ARN}}}
|
||||||
resp = ingestor.lambda_handler(event, None)
|
resp = ingestor.lambda_handler(event, None)
|
||||||
assert resp["statusCode"] == 400
|
assert resp["statusCode"] == 400
|
||||||
assert "invalid contractId" in resp["body"]
|
assert "invalid contractId" in resp["body"]
|
||||||
|
|
||||||
def test_contract_id_too_long_rejected(self, moto_contracts_table, sample_payload):
|
def test_contract_id_too_long_rejected(self, moto_contracts_table, sample_payload):
|
||||||
sample_payload["contractId"] = "a" * 65
|
sample_payload["contractId"] = "a" * 65
|
||||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": self._ARN}}}
|
||||||
resp = ingestor.lambda_handler(event, None)
|
resp = ingestor.lambda_handler(event, None)
|
||||||
assert resp["statusCode"] == 400
|
assert resp["statusCode"] == 400
|
||||||
assert "invalid contractId" in resp["body"]
|
assert "invalid contractId" in resp["body"]
|
||||||
|
|
||||||
def test_invalid_environment_rejected(self, moto_contracts_table, sample_payload):
|
def test_invalid_environment_rejected(self, moto_contracts_table, sample_payload):
|
||||||
sample_payload["environment"] = "staging"
|
sample_payload["environment"] = "staging"
|
||||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": self._ARN}}}
|
||||||
resp = ingestor.lambda_handler(event, None)
|
resp = ingestor.lambda_handler(event, None)
|
||||||
assert resp["statusCode"] == 400
|
assert resp["statusCode"] == 400
|
||||||
assert "invalid environment" in resp["body"]
|
assert "invalid environment" in resp["body"]
|
||||||
|
|
||||||
def test_valid_environments_accepted(self, moto_contracts_table, sample_payload):
|
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"]:
|
for env in ["dev", "qa", "prod", "dr"]:
|
||||||
sample_payload["environment"] = env
|
sample_payload["environment"] = env
|
||||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": arn}}}
|
||||||
resp = ingestor.lambda_handler(event, None)
|
resp = ingestor.lambda_handler(event, None)
|
||||||
assert resp["statusCode"] == 200
|
assert resp["statusCode"] == 200
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user