"""Tests for ``deploy/scripts/render-env.sh`` — the SSM-to-.env renderer. Why this test exists: 2026-05-19 prod deploy failed because the previous ``aws ssm get-parameter ... 2>/dev/null || echo ""`` pattern in the helper swallowed an SSM transient error and wrote ``CORS_ORIGINS=`` into the rendered .env. pydantic_settings then died inside ``Settings()`` parsing empty-string as ``list[str]`` JSON, killing migration and the whole deploy. The fix (in the helper) is to retry SSM API failures, fail loud on hard failure, and only emit empty for ``ParameterNotFoundException``. These tests pin all three behaviours via a stub ``aws`` binary on PATH. Test plumbing: * The fake ``aws`` is a tiny shell script written into a tmp dir and put first on PATH for the subprocess only — the real AWS CLI on the host is not invoked. * The fake reads a control mode from ``$FAKE_AWS_MODE`` and per-param overrides from ``$FAKE_AWS_PARAM__MODE`` so each test can craft the exact SSM error scenario it wants. * ``RENDER_ENV_RETRY_BASE_DELAY=0`` keeps the throttle test under 100ms by collapsing the exponential-backoff sleeps to no-ops. """ from __future__ import annotations import os import subprocess import textwrap from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[3] RENDER_ENV_SH = REPO_ROOT / "deploy" / "scripts" / "render-env.sh" # Every parameter render-env.sh fetches from SSM. Keep this in sync with # the script body. ``CRITICAL_REQUIRED`` must be non-empty for the script # to exit 0; the rest are soft-optional and may legitimately come back # empty (ParameterNotFound). CRITICAL_REQUIRED = ( "DATABASE_URL", "DATABASE_URL_SYNC", "REDIS_URL", "JWT_SECRET_KEY", "GOOGLE_CLIENT_ID", "CREDENTIAL_ENCRYPTION_KEY", ) ALL_PARAMS = ( "APP_ENV", "AUTH_PASSWORD_LOGIN_ENABLED", "CORS_ORIGINS", "DATABASE_URL", "DATABASE_URL_SYNC", "REDIS_URL", "JWT_SECRET_KEY", "GOOGLE_CLIENT_ID", "FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_EVENT_VERIFICATION_TOKEN", "FEISHU_EVENT_ENCRYPT_KEY", "FEISHU_APPROVAL_LAPTOP_REPLACEMENT_CODE", "FEISHU_APPROVAL_COMPUTER_BORROW_CODE", "FEISHU_APPROVAL_ASSET_RETURN_CODE", "FEISHU_APPROVAL_OFFBOARDING_BUYBACK_CODE", "FEISHU_APPROVAL_SOFTWARE_SEAT_CODE", "FEISHU_APPROVAL_DATA_ACCESS_GROUP_GRANT_CODE", "FEISHU_APPROVAL_DATA_ACCESS_SERVICE_ACCOUNT_GRANT_CODE", "FEISHU_APPROVAL_DATA_ACCESS_PERSONAL_GRANT_CODE", "CREDENTIAL_ENCRYPTION_KEY", "SUCCESSFACTORS_BASE_URL", "SUCCESSFACTORS_USERNAME", "SUCCESSFACTORS_PASSWORD", ) def _write_fake_aws(stub_dir: Path) -> None: """Drop a fake ``aws`` binary into ``stub_dir``. Behaviour is driven by env vars set by each test: * ``FAKE_AWS_MODE``: default mode (``success`` / ``throttle`` / ``not_found``). * ``FAKE_AWS_PARAM__MODE``: override mode for a single parameter, so a test can say "everything succeeds except CORS_ORIGINS, which throttles". * ``FAKE_AWS_PARAM__VALUE``: override the success-value returned for ```` (so we can plant ``CORS_ORIGINS=https://x`` and assert it lands in the rendered file). * ``FAKE_AWS_LOG`` (optional file path): every invocation appends one line (`` ``) so the throttle test can count retries. The stub only implements ``aws ssm get-parameter`` because that's all render-env.sh ever calls. """ body = textwrap.dedent( r""" #!/usr/bin/env bash # Fake AWS CLI for render-env.sh tests. Recognises only the # exact invocation shape the helper produces. set -u if [[ "${1:-}" != "ssm" || "${2:-}" != "get-parameter" ]]; then echo "fake-aws: unsupported invocation: $*" >&2 exit 99 fi name="" while (( $# > 0 )); do case "$1" in --name) name="$2"; shift 2;; --name=*) name="${1#--name=}"; shift;; *) shift;; esac done # name comes in as /helix/prod/ — strip everything before # the last slash so tests can target by short key name. short="${name##*/}" mode_var="FAKE_AWS_PARAM_${short}_MODE" mode="${!mode_var:-${FAKE_AWS_MODE:-success}}" if [[ -n "${FAKE_AWS_LOG:-}" ]]; then printf '%s %s\n' "$short" "$mode" >> "$FAKE_AWS_LOG" fi case "$mode" in success) value_var="FAKE_AWS_PARAM_${short}_VALUE" # Default success value is the param name itself — easy to # assert against in the rendered file. printf '%s' "${!value_var:-stub-${short}}" exit 0 ;; not_found) echo "An error occurred (ParameterNotFound) when calling the GetParameter operation: Parameter /helix/prod/${short} not found." >&2 exit 254 ;; throttle) echo "An error occurred (ThrottlingException) when calling the GetParameter operation (reached max retries: 4): Rate exceeded" >&2 exit 254 ;; access_denied) echo "An error occurred (AccessDeniedException) when calling the GetParameter operation: User is not authorized to perform: ssm:GetParameter" >&2 exit 254 ;; *) echo "fake-aws: unknown mode '$mode' for $short" >&2 exit 98 ;; esac """ ).strip() stub = stub_dir / "aws" stub.write_text(body + "\n") stub.chmod(0o755) def _run_render( tmp_path: Path, *, env_overrides: dict[str, str] | None = None, timeout: float = 10.0, ) -> subprocess.CompletedProcess[str]: stub_dir = tmp_path / "bin" stub_dir.mkdir() _write_fake_aws(stub_dir) env_file = tmp_path / ".env.production" log_file = tmp_path / "aws_calls.log" env = { # Minimal PATH so the stub wins; bash + coreutils still resolvable # via the inherited PATH after our stub dir. "PATH": f"{stub_dir}:{os.environ.get('PATH', '/usr/bin:/bin')}", "PROJECT": "helix", "ENVIRONMENT": "prod", "AWS_REGION": "ap-southeast-1", "ENV_FILE": str(env_file), # Collapse retry backoff to ~0 so the throttle test isn't slow. "RENDER_ENV_RETRY_BASE_DELAY": "0", "FAKE_AWS_LOG": str(log_file), "FAKE_AWS_MODE": "success", } if env_overrides: env.update(env_overrides) result = subprocess.run( ["bash", str(RENDER_ENV_SH)], env=env, capture_output=True, text=True, timeout=timeout, ) # Attach the rendered file (if any) and call log for assertions. result.env_file = env_file # type: ignore[attr-defined] result.log_file = log_file # type: ignore[attr-defined] return result def _parse_env_file(path: Path) -> dict[str, str]: out: dict[str, str] = {} for line in path.read_text().splitlines(): if not line or line.startswith("#"): continue if "=" not in line: continue k, _, v = line.partition("=") out[k] = v return out def test_happy_path_renders_file(tmp_path: Path) -> None: """All SSM calls succeed → .env.production rendered with values, exit 0.""" result = _run_render(tmp_path) assert result.returncode == 0, result.stderr env = _parse_env_file(result.env_file) # Every critical key is present and non-empty. for key in CRITICAL_REQUIRED: assert env.get(key) == f"stub-{key}", f"{key} = {env.get(key)!r}" # Soft-optional keys also populated from the stub. assert env.get("CORS_ORIGINS") == "stub-CORS_ORIGINS" assert env.get("FEISHU_APP_ID") == "stub-FEISHU_APP_ID" def test_parameter_not_found_for_soft_optional_renders_empty(tmp_path: Path) -> None: """ParameterNotFound on a soft-optional key → empty value, exit 0. This is the legitimate "you didn't set this in SSM, fall back to the default in the template" case. It must NOT trigger retries and must NOT abort the deploy. """ result = _run_render( tmp_path, env_overrides={"FAKE_AWS_PARAM_CORS_ORIGINS_MODE": "not_found"}, ) assert result.returncode == 0, result.stderr env = _parse_env_file(result.env_file) assert env.get("CORS_ORIGINS") == "", ( f"CORS_ORIGINS expected empty on ParameterNotFound, got {env.get('CORS_ORIGINS')!r}" ) # Other critical keys still rendered. assert env.get("DATABASE_URL") == "stub-DATABASE_URL" # ParameterNotFound is NOT retried — exactly one call for CORS_ORIGINS. log_lines = result.log_file.read_text().splitlines() cors_calls = [ln for ln in log_lines if ln.startswith("CORS_ORIGINS ")] assert len(cors_calls) == 1, ( f"ParameterNotFound should not retry; got {len(cors_calls)} calls: {cors_calls}" ) def test_parameter_not_found_for_critical_aborts(tmp_path: Path) -> None: """ParameterNotFound on a critical key → MISSING[] guard, exit 1. Different from the soft-optional case: a missing critical key must still fail the deploy via the existing post-fetch validator. """ result = _run_render( tmp_path, env_overrides={ "FAKE_AWS_PARAM_CORS_ORIGINS_MODE": "success", "FAKE_AWS_PARAM_DATABASE_URL_MODE": "not_found", }, ) assert result.returncode == 1 assert "Missing critical SSM parameters" in result.stdout, result.stdout assert "DATABASE_URL" in result.stdout def test_throttle_retries_then_aborts_without_writing_empty(tmp_path: Path) -> None: """ThrottlingException → retries 3x then exit 1 (no silent-empty render). The exact bug from 2026-05-19: the helper used to swallow this and write ``CORS_ORIGINS=`` to the file. The fix is to retry and, on persistent failure, abort BEFORE the file is written. """ result = _run_render( tmp_path, env_overrides={"FAKE_AWS_PARAM_CORS_ORIGINS_MODE": "throttle"}, ) assert result.returncode != 0, ( "throttle must abort; got 0 — likely silently rendered empty value" ) # File must NOT exist (or if it does, must NOT contain CORS_ORIGINS=). if result.env_file.exists(): env = _parse_env_file(result.env_file) assert "CORS_ORIGINS" not in env, ( "throttle path leaked an empty CORS_ORIGINS into the rendered file" ) # Error message must mention the parameter that failed. assert "CORS_ORIGINS" in result.stderr assert "after 3 attempts" in result.stderr # Verify retry count via the call log: exactly 3 attempts for the # throttled param. log_lines = result.log_file.read_text().splitlines() throttled = [ln for ln in log_lines if ln.startswith("CORS_ORIGINS ")] assert len(throttled) == 3, ( f"expected 3 retry attempts on throttle, got {len(throttled)}: {throttled}" ) def test_access_denied_also_aborts_loudly(tmp_path: Path) -> None: """IAM permission errors are the same shape as throttles — must abort. A misconfigured IAM role on the EC2 instance must NOT silently render blank secrets and let the backend boot with a half-broken config. """ result = _run_render( tmp_path, env_overrides={"FAKE_AWS_PARAM_JWT_SECRET_KEY_MODE": "access_denied"}, ) assert result.returncode != 0 assert "JWT_SECRET_KEY" in result.stderr assert "after 3 attempts" in result.stderr