Merge milestone/v1.16-nova-simplification — v1.16 complete (Nova Simplification: 20-phase NFR sweep + final; tag v1.15.26)
This commit is contained in:
@@ -90,6 +90,15 @@ class TestModuleAssembly:
|
||||
assert 'backend "s3"' in terraform_tf
|
||||
assert 'spike/s3/dev/terraform.tfstate' in terraform_tf
|
||||
|
||||
def test_adapt_emits_nova_state_bucket(self, tmp_path):
|
||||
"""P1 (REQ-165): the emitted backend references nova-tfstate-*
|
||||
(not acdl-tfstate-*); the live bucket was renamed in v1.15 P4."""
|
||||
instance = json.load(open(ROOT / "modules/l1/s3/instance.json"))
|
||||
adapt(instance, str(tmp_path))
|
||||
terraform_tf = (tmp_path / "terraform.tf").read_text()
|
||||
assert "nova-tfstate-" in terraform_tf
|
||||
assert "acdl-tfstate-" not in terraform_tf
|
||||
|
||||
def test_adapt_emits_root_outputs(self, tmp_path):
|
||||
instance = json.load(open(ROOT / "modules/l1/s3/instance.json"))
|
||||
instance["outputs"] = {
|
||||
|
||||
+129
-27
@@ -37,12 +37,29 @@ _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": {"stack": "s3", "environment": "dev"},
|
||||
"contract": {
|
||||
"id": "test",
|
||||
"name": "test-contract",
|
||||
"environment": "dev",
|
||||
"infrastructure": {"s3": {"version": "1.0.0", "inputs": {}}},
|
||||
},
|
||||
"environment": "dev",
|
||||
"action": "submit_contract",
|
||||
}
|
||||
@@ -50,7 +67,8 @@ def sample_payload():
|
||||
|
||||
@pytest.fixture
|
||||
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
|
||||
@@ -123,16 +141,15 @@ class TestSubmitContract:
|
||||
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
|
||||
# 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)
|
||||
@@ -143,6 +160,24 @@ class TestSubmitContract:
|
||||
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
|
||||
@@ -264,20 +299,21 @@ class TestReportError:
|
||||
ingestor._report_error(error_payload)
|
||||
|
||||
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.
|
||||
error_payload["stackTrace"] = "x" * 5000
|
||||
# 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 2000 'x' chars.
|
||||
# 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" * 2000 in body["body"]
|
||||
assert "x" * 2001 not in body["body"]
|
||||
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.
|
||||
@@ -361,9 +397,21 @@ class TestLambdaHandler:
|
||||
class TestCallerIdentityValidation:
|
||||
"""P1-2: the Lambda validates consumerRepo against the invoking principal."""
|
||||
|
||||
def test_no_identity_skips_check(self, moto_contracts_table, function_url_event):
|
||||
# No requestContext.identity in the event — check is skipped (relies on IAM ABAC).
|
||||
resp = ingestor.lambda_handler(function_url_event, None)
|
||||
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):
|
||||
@@ -496,7 +544,7 @@ class TestValidateChangeRequest:
|
||||
"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"])
|
||||
@@ -505,33 +553,39 @@ class TestValidateChangeRequest:
|
||||
|
||||
class TestV14IdentityValidation:
|
||||
"""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):
|
||||
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)
|
||||
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": {}}
|
||||
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": {}}
|
||||
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": {}}
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": arn}}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 200
|
||||
|
||||
@@ -539,4 +593,52 @@ class TestV14IdentityValidation:
|
||||
"""The _validate_caller_identity docstring documents the ABAC reliance."""
|
||||
docstring = ingestor._validate_caller_identity.__doc__
|
||||
assert "ABAC" in docstring
|
||||
assert "PrincipalTag" 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"]
|
||||
|
||||
@@ -78,6 +78,7 @@ def test_run_platform_sh_has_environment_flag():
|
||||
text = (ROOT / "scripts" / "run_platform.sh").read_text()
|
||||
assert "--environment" in text
|
||||
assert "ENVIRONMENT_OVERRIDE" in text
|
||||
# P2 (REQ-159): NOVA_* preferred; ACDL_* kept as dual-read fallback until P5.
|
||||
# P3 (REQ-167): NOVA_* only; the dead ACDL_ENVIRONMENT_OVERRIDE export
|
||||
# (comment said "removed in P5" but the line was present) is gone.
|
||||
assert "NOVA_ENVIRONMENT_OVERRIDE" in text
|
||||
assert "ACDL_ENVIRONMENT_OVERRIDE" in text # legacy fallback, removed in P5
|
||||
assert "ACDL_ENVIRONMENT_OVERRIDE" not in text
|
||||
@@ -34,4 +34,15 @@ class TestDocsCoverage:
|
||||
assert "How to Write an Adapter" in content
|
||||
assert "How to Wire" in content
|
||||
assert "How to Test" in content
|
||||
assert "Existing Adapters" in content
|
||||
assert "Existing Adapters" in content
|
||||
|
||||
def test_github_workflows_readme_catalogs_all_workflows():
|
||||
"""P16 (REQ-180): .github/workflows/README.md catalogs all 7 workflows."""
|
||||
from pathlib import Path
|
||||
readme = Path(__file__).resolve().parent.parent / ".github" / "workflows" / "README.md"
|
||||
assert readme.is_file(), ".github/workflows/README.md missing"
|
||||
text = readme.read_text()
|
||||
for wf in ["ci.yml", "deploy.yml", "modules-lifecycle.yml",
|
||||
"platform-test.yml", "primitives-plan.yml", "patterns-plan.yml",
|
||||
"release.yml"]:
|
||||
assert wf in text, f"{wf} not cataloged in .github/workflows/README.md"
|
||||
|
||||
@@ -21,7 +21,10 @@ class TestEnvironmentCheck:
|
||||
assert ok is False
|
||||
assert "nonexistent-env" in msg
|
||||
assert "onboarding" in msg.lower() or "Environment Onboarding" in msg
|
||||
assert "platform team" in msg.lower()
|
||||
# P19 (REQ-183): the message now routes to the self-service
|
||||
# request path (onboard_consumer), not "contact the platform team".
|
||||
assert "platform team" not in msg.lower()
|
||||
assert "onboard_consumer" in msg or "self-service" in msg.lower()
|
||||
|
||||
def test_onboarding_message_lists_platform_provisions(self):
|
||||
msg = _onboarding_message("qa")
|
||||
@@ -31,6 +34,12 @@ class TestEnvironmentCheck:
|
||||
assert "state backend" in msg.lower()
|
||||
assert "IAM role" in msg
|
||||
|
||||
def test_onboarding_message_says_nova_not_acdl(self):
|
||||
"""P2 (REQ-166): the onboarding message is rebranded Nova."""
|
||||
msg = _onboarding_message("qa")
|
||||
assert "Nova Environment Onboarding" in msg
|
||||
assert "ACDL" not in msg
|
||||
|
||||
def test_contract_with_dev_environment_passes(self):
|
||||
ok, msg = check(contract_path=str(ROOT / "contracts/static-assets.yml"), root=ROOT)
|
||||
assert ok is True
|
||||
@@ -96,4 +105,16 @@ class TestRunPlatformWireIn:
|
||||
)
|
||||
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"
|
||||
assert "PLATFORM CHECK OK" in result.stdout
|
||||
assert "environment" in result.stdout.lower() or "Step 0" in result.stdout
|
||||
assert "environment" in result.stdout.lower() or "Step 0" in result.stdout
|
||||
|
||||
class TestOnboardingMessageSelfService:
|
||||
"""P19 (REQ-183): the onboarding message is self-service, not 'contact
|
||||
the platform team'."""
|
||||
|
||||
def test_no_contact_platform_team(self):
|
||||
msg = _onboarding_message("qa")
|
||||
assert "contact the platform team" not in msg.lower()
|
||||
|
||||
def test_mentions_self_service_request(self):
|
||||
msg = _onboarding_message("qa")
|
||||
assert "self-service" in msg.lower() or "onboard_consumer" in msg
|
||||
|
||||
@@ -74,4 +74,52 @@ class TestMapPath:
|
||||
|
||||
def test_preserves_value_segment_exactly(self):
|
||||
# Hyphens, dots, underscores in output names are preserved
|
||||
assert map_path("/acdl/dev/c-1/my.output-name_2") == "/nova/dev/c-1/my.output-name_2"
|
||||
assert map_path("/acdl/dev/c-1/my.output-name_2") == "/nova/dev/c-1/my.output-name_2"
|
||||
|
||||
class TestNarrowedException:
|
||||
"""P4 (REQ-168): the copy_one_param except is narrowed to
|
||||
ParameterNotFound; non-ParameterNotFound errors surface (not swallowed)."""
|
||||
|
||||
def test_parameter_not_found_proceeds_to_put(self):
|
||||
"""A ParameterNotFound on the dest get_parameter (target absent) is
|
||||
the expected 'proceed to put' path — not an error."""
|
||||
from unittest import mock
|
||||
import migrate_ssm_paths as m
|
||||
|
||||
class FakeExceptions:
|
||||
ParameterNotFound = type("ParameterNotFound", (Exception,), {})
|
||||
|
||||
fake_client = mock.Mock()
|
||||
fake_client.exceptions = FakeExceptions
|
||||
# source get_parameter succeeds; dest get_parameter raises ParameterNotFound
|
||||
fake_client.get_parameter.side_effect = [
|
||||
{"Parameter": {"Value": "v", "Type": "String", "KeyId": None}},
|
||||
FakeExceptions.ParameterNotFound(),
|
||||
]
|
||||
fake_client.put_parameter.return_value = {"Version": 1}
|
||||
result = m.copy_one_param(fake_client, "/acdl/dev/c/out", "/nova/dev/c/out")
|
||||
assert result == "copied"
|
||||
fake_client.put_parameter.assert_called_once()
|
||||
|
||||
def test_non_parameter_not_found_error_is_raised(self):
|
||||
"""A non-ParameterNotFound AWS error (e.g. ThrottlingException) on
|
||||
the dest get_parameter is raised, not swallowed (P4, REQ-168)."""
|
||||
from unittest import mock
|
||||
import migrate_ssm_paths as m
|
||||
|
||||
class FakeExceptions:
|
||||
ParameterNotFound = type("ParameterNotFound", (Exception,), {})
|
||||
|
||||
class ThrottlingException(Exception):
|
||||
pass
|
||||
|
||||
fake_client = mock.Mock()
|
||||
fake_client.exceptions = FakeExceptions
|
||||
# source get_parameter succeeds; dest get_parameter raises Throttling
|
||||
fake_client.get_parameter.side_effect = [
|
||||
{"Parameter": {"Value": "v", "Type": "String", "KeyId": None}},
|
||||
ThrottlingException("slow down"),
|
||||
]
|
||||
with pytest.raises(ThrottlingException):
|
||||
m.copy_one_param(fake_client, "/acdl/dev/c/out", "/nova/dev/c/out")
|
||||
fake_client.put_parameter.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Unit tests for core/onboarding.py (P19, REQ-183)."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from core.onboarding import generate_env_file, _onboarding_request_message
|
||||
|
||||
|
||||
class TestGenerateEnvFile:
|
||||
"""P19 (REQ-183): generate_env_file produces a valid env JSON."""
|
||||
|
||||
def test_generates_env_with_request_fields(self):
|
||||
request = {
|
||||
"consumerRepo": "acdl/consumer-b",
|
||||
"requestedEnvironment": "qa",
|
||||
"ownerId": "team-b",
|
||||
"billingTag": "cost-center-b",
|
||||
}
|
||||
env = generate_env_file(request, template_env="dev")
|
||||
assert env["name"] == "qa"
|
||||
assert env["ownerId"] == "team-b"
|
||||
assert env["billingTag"] == "cost-center-b"
|
||||
assert env["account_id"] == "000000000000" # placeholder
|
||||
assert "consumer-b" in env["description"]
|
||||
|
||||
def test_preserves_template_network_and_state(self):
|
||||
request = {
|
||||
"consumerRepo": "acdl/c",
|
||||
"requestedEnvironment": "prod",
|
||||
"ownerId": "team-a",
|
||||
"billingTag": "cc-a",
|
||||
}
|
||||
env = generate_env_file(request, template_env="dev")
|
||||
assert "vpc_cidr" in env["network"]
|
||||
assert "bucket" in env["state_backend"]
|
||||
assert env["region"] == "us-east-1"
|
||||
|
||||
|
||||
class TestOnboardingRequestMessage:
|
||||
"""P19 (REQ-183): the request message is self-service."""
|
||||
|
||||
def test_message_mentions_onboard_consumer(self):
|
||||
msg = _onboarding_request_message("dev")
|
||||
assert "onboard_consumer" in msg
|
||||
assert "Nova" in msg
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Unit tests for terraform/onboarding (P20, REQ-184)."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ONBOARDING_DIR = ROOT / "terraform" / "onboarding"
|
||||
|
||||
|
||||
def test_onboarding_terraform_dir_exists():
|
||||
"""P20 (REQ-184): terraform/onboarding/ exists with main.tf + README."""
|
||||
assert ONBOARDING_DIR.is_dir()
|
||||
assert (ONBOARDING_DIR / "main.tf").is_file()
|
||||
assert (ONBOARDING_DIR / "README.md").is_file()
|
||||
|
||||
|
||||
def test_onboarding_terraform_validates():
|
||||
"""P20 (REQ-184): terraform validate passes for the onboarding module
|
||||
(offline-proven, D-114). Skipped if terraform is not installed."""
|
||||
if not subprocess.call(["which", "terraform"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0:
|
||||
pytest.skip("terraform not installed")
|
||||
rc = subprocess.call(
|
||||
["terraform", "validate"],
|
||||
cwd=str(ONBOARDING_DIR),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
assert rc == 0, "terraform validate failed for terraform/onboarding/"
|
||||
|
||||
|
||||
def test_onboarding_main_tf_has_nova_tags():
|
||||
"""P20 (REQ-184): the deploy role is tagged with nova:owner + nova:contract."""
|
||||
main_tf = (ONBOARDING_DIR / "main.tf").read_text()
|
||||
assert '"nova:owner"' in main_tf
|
||||
assert '"nova:contract"' in main_tf
|
||||
assert "aws_iam_role" in main_tf
|
||||
assert "lambda:InvokeFunctionUrl" in main_tf
|
||||
@@ -134,7 +134,11 @@ class TestPublishToSsm:
|
||||
def flaky_put(**kwargs):
|
||||
call_count["n"] += 1
|
||||
if "bad" in kwargs["Name"]:
|
||||
raise Exception("simulated failure")
|
||||
from botocore.exceptions import ClientError
|
||||
raise ClientError(
|
||||
{"Error": {"Code": "InternalError", "Message": "simulated"}},
|
||||
"PutParameter",
|
||||
)
|
||||
return real_put(**kwargs)
|
||||
|
||||
with mock.patch("core.output_publisher._ssm_client", return_value=ssm):
|
||||
@@ -272,10 +276,11 @@ class TestPostGithubComment:
|
||||
assert "/issues/5/comments" in captured["url"]
|
||||
|
||||
def test_returns_false_on_exception(self, monkeypatch):
|
||||
import urllib.error
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "tok")
|
||||
monkeypatch.setenv("GITHUB_REPOSITORY", "acdl/acdl")
|
||||
monkeypatch.setenv("GITHUB_REF", "refs/pull/1/merge")
|
||||
with mock.patch("urllib.request.urlopen", side_effect=Exception("boom")):
|
||||
with mock.patch("urllib.request.urlopen", side_effect=urllib.error.URLError("boom")):
|
||||
assert post_github_comment("body") is False
|
||||
|
||||
def test_uses_gh_token_fallback(self, monkeypatch):
|
||||
|
||||
@@ -101,10 +101,25 @@ class TestWorkflowConformance:
|
||||
assert (ROOT / ".github/workflows/ci.yml").is_file()
|
||||
|
||||
def test_workflows_are_byte_identical(self):
|
||||
# P8 (REQ-172): the byte-identity is now enforced by
|
||||
# scripts/sync_workflows.py --check (generated from workflows-src/).
|
||||
# The two dirs must still be byte-identical (the generator writes
|
||||
# the same source to both); this assertion is the belt, the
|
||||
# generator --check is the suspenders.
|
||||
gitea = open(ROOT / ".gitea/workflows/ci.yml", "rb").read()
|
||||
github = open(ROOT / ".github/workflows/ci.yml", "rb").read()
|
||||
assert gitea == github, "Gitea and GitHub workflows must be byte-identical"
|
||||
|
||||
def test_sync_workflows_check_passes(self):
|
||||
"""P8 (REQ-172): sync_workflows.py --check exits 0 (committed
|
||||
files match the workflows-src/ sources)."""
|
||||
import subprocess
|
||||
rc = subprocess.call(
|
||||
[sys.executable, "scripts/sync_workflows.py", "--check"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
assert rc == 0, "sync_workflows.py --check failed — run scripts/sync_workflows.py --write"
|
||||
|
||||
def test_gitea_workflow_name_matches_contract(self):
|
||||
wf = _load_workflow(".gitea/workflows/ci.yml")
|
||||
contract = _load_yaml("pipelines/ci.yml")
|
||||
|
||||
Reference in New Issue
Block a user