diff --git a/src/holiday-router/app.py b/src/holiday-router/app.py index c6c7940..514c848 100644 --- a/src/holiday-router/app.py +++ b/src/holiday-router/app.py @@ -38,6 +38,10 @@ def _deactivate(schedule: ShiftSchedule, date: str) -> dict: def handler(event, context): + if os.environ.get("STAGE", "prod") != "prod": + logger.info("Skipping holiday router because STAGE is not prod") + return {"skipped": "non_prod"} + action = event.get("action") date = event.get("date") logger.info("Holiday router invoked: action=%s date=%s", action, date) diff --git a/src/ring-scheduler/app.py b/src/ring-scheduler/app.py index 77424e7..eb47b0b 100644 --- a/src/ring-scheduler/app.py +++ b/src/ring-scheduler/app.py @@ -27,6 +27,10 @@ EASTERN = ZoneInfo("America/New_York") def handler(event, context): + if os.environ.get("STAGE", "prod") != "prod": + logger.info("Skipping 3CX queue scheduler because STAGE is not prod") + return {"skipped": "non_prod"} + now = datetime.now(EASTERN) current_hour = now.hour day_name = now.strftime("%A") diff --git a/src/roster-sync/app.py b/src/roster-sync/app.py index 1b29d0b..6ba7bee 100644 --- a/src/roster-sync/app.py +++ b/src/roster-sync/app.py @@ -24,6 +24,10 @@ EXCLUDE_NAMES = {"Voicemail", "IVR", "Fax"} def handler(event, context): + if os.environ.get("STAGE", "prod") != "prod": + logger.info("Skipping roster sync because STAGE is not prod") + return {"skipped": "non_prod"} + now = datetime.now(EASTERN) # DST guard — two EventBridge rules fire, only one is at 6am ET diff --git a/src/server/app.py b/src/server/app.py index 493ac0e..f2bf582 100644 --- a/src/server/app.py +++ b/src/server/app.py @@ -67,6 +67,8 @@ def create_app() -> Flask: @app.route("/slack/events", methods=["POST"]) def slack_events(): + if os.environ.get("STAGE", "prod") != "prod": + return jsonify({"error": "slack_disabled"}), 404 return _get_slack_handler().handle(request) @app.route("/api/shifts", methods=["GET", "POST", "DELETE", "OPTIONS"]) diff --git a/src/shared/shared/effects.py b/src/shared/shared/effects.py new file mode 100644 index 0000000..062f647 --- /dev/null +++ b/src/shared/shared/effects.py @@ -0,0 +1,14 @@ +"""External side effects. Only production may call Slack or 3CX. + +A missing STAGE is treated as prod so a task that lost its environment +variable does not silently drop production notifications. Dev and any +other named stage skip Slack and 3CX entirely. +""" + +from __future__ import annotations + +import os + + +def prod_side_effects_enabled() -> bool: + return os.environ.get("STAGE", "prod") == "prod" diff --git a/src/shared/shared/holiday_flow.py b/src/shared/shared/holiday_flow.py index e631837..424a3c3 100644 --- a/src/shared/shared/holiday_flow.py +++ b/src/shared/shared/holiday_flow.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging import os +from shared.effects import prod_side_effects_enabled from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule from shared.secrets import get_secret from shared.three_cx_client import ThreeCXClient, oauth_client @@ -28,6 +29,9 @@ def holiday_extensions(holiday: dict) -> list[str]: def activate(schedule: ShiftSchedule, date: str, client_factory=None) -> dict: + if not prod_side_effects_enabled(): + logger.info("Skipping holiday activate because STAGE is not prod") + return {"action": "activate", "date": date, "skipped": "non_prod"} holiday = schedule.get_holiday(date) if holiday is None: logger.info("No holiday record for %s — nothing to activate", date) @@ -77,6 +81,9 @@ def activate(schedule: ShiftSchedule, date: str, client_factory=None) -> dict: def deactivate(schedule: ShiftSchedule, date: str, client_factory=None) -> dict: + if not prod_side_effects_enabled(): + logger.info("Skipping holiday deactivate because STAGE is not prod") + return {"action": "deactivate", "date": date, "skipped": "non_prod"} holiday = schedule.get_holiday(date) if holiday is None: logger.info("No holiday record for %s — nothing to deactivate", date) diff --git a/src/shared/shared/side_effects.py b/src/shared/shared/side_effects.py index 8f5fd51..100198d 100644 --- a/src/shared/shared/side_effects.py +++ b/src/shared/shared/side_effects.py @@ -19,6 +19,7 @@ from shared.blocks import ( build_swap_request_blocks, build_week_schedule, ) +from shared.effects import prod_side_effects_enabled from shared.ring_scheduler import update_queue_routing from shared.schedule import FALLBACK_EXTENSION, week_start from shared.secrets import get_secret @@ -31,6 +32,8 @@ SLACK_API = "https://slack.com/api" def slack_token() -> str | None: + if not prod_side_effects_enabled(): + return None secret_id = os.environ.get("SLACK_BOT_TOKEN_SECRET") if not secret_id: return None @@ -42,6 +45,9 @@ def slack_token() -> str | None: def slack_call(method: str, token: str, **payload) -> bool: + if not prod_side_effects_enabled(): + logger.info("Skipping Slack %s because STAGE is not prod", method) + return False try: response = requests.post( f"{SLACK_API}/{method}", @@ -63,6 +69,9 @@ def slack_call(method: str, token: str, **payload) -> bool: def update_3cx_routing(extension: str) -> None: + if not prod_side_effects_enabled(): + logger.info("Skipping 3CX routing because STAGE is not prod") + return queue_number = os.environ.get("QUEUE_NUMBER") secret_prefix = os.environ.get("TCX_SECRET_PREFIX") if not queue_number or not secret_prefix: @@ -89,6 +98,8 @@ def maybe_repoint_today(date_str: str, shift_type: str, extension: str) -> bool: def make_3cx_client() -> ThreeCXClient | None: """Return the process OAuth client, refreshing it when the token or secret changed.""" + if not prod_side_effects_enabled(): + return None secret_prefix = os.environ.get("TCX_SECRET_PREFIX") if not secret_prefix: logger.warning("3CX env vars not set — skipping 3CX call") diff --git a/src/weekly-post/app.py b/src/weekly-post/app.py index 7c89d36..da31143 100644 --- a/src/weekly-post/app.py +++ b/src/weekly-post/app.py @@ -289,6 +289,10 @@ def _send_checkcomponents(pay_record: dict) -> bool: def handler(event, context): + if os.environ.get("STAGE", "prod") != "prod": + logger.info("Skipping weekly post because STAGE is not prod") + return {"skipped": "non_prod"} + now = datetime.now(EASTERN) # DST guard — same pattern as the 3CX scheduler diff --git a/terraform/data.tf b/terraform/data.tf index 8ad9897..f971220 100644 --- a/terraform/data.tf +++ b/terraform/data.tf @@ -10,6 +10,16 @@ check "correct_account" { } } +check "dev_has_no_external_side_effects" { + assert { + condition = local.is_prod || alltrue([ + for name in ["SHIFT_CHANNEL", "QUEUE_NUMBER", "PAY_REPORT_USER", "TCX_SECRET_PREFIX"] : + one([for env in local.api_environment : env.value if env.name == name]) == "" + ]) + error_message = "Non-prod must leave SHIFT_CHANNEL, QUEUE_NUMBER, PAY_REPORT_USER, and TCX_SECRET_PREFIX empty so the task cannot post to Slack or move the production phone queue." + } +} + check "dev_has_no_paychex" { assert { condition = local.is_prod || var.checkcomponents_queue_url == "" diff --git a/terraform/ecs.tf b/terraform/ecs.tf index 0951ff7..f898721 100644 --- a/terraform/ecs.tf +++ b/terraform/ecs.tf @@ -182,9 +182,9 @@ locals { { name = "SHIFT_TABLE", value = aws_dynamodb_table.shifts.name }, { name = "SLACK_BOT_TOKEN_SECRET", value = "afterhours-shift-manager/slack-bot-token" }, { name = "SLACK_SIGNING_SECRET", value = "afterhours-shift-manager/slack-signing-secret" }, - { name = "SHIFT_CHANNEL", value = var.shift_channel }, - { name = "TCX_SECRET_PREFIX", value = "afterhours-shift-manager/3cx-" }, - { name = "QUEUE_NUMBER", value = var.queue_number }, + { name = "SHIFT_CHANNEL", value = local.is_prod ? var.shift_channel : "" }, + { name = "TCX_SECRET_PREFIX", value = local.is_prod ? "afterhours-shift-manager/3cx-" : "" }, + { name = "QUEUE_NUMBER", value = local.is_prod ? var.queue_number : "" }, { name = "TZ", value = var.timezone }, { name = "HOLIDAY_SCHEDULER_ROLE_ARN", value = local.holiday_scheduler_role_arn }, { name = "HOLIDAY_SCHEDULE_GROUP", value = "default" }, @@ -195,7 +195,7 @@ locals { var.portal_cognito_issuer != "" && var.portal_cognito_audience != "" ? [{ issuer = var.portal_cognito_issuer, audience = var.portal_cognito_audience }] : [], var.portal_cognito_extra_trust, )) }, - { name = "PAY_REPORT_USER", value = var.pay_report_user }, + { name = "PAY_REPORT_USER", value = local.is_prod ? var.pay_report_user : "" }, { name = "CHECKCOMPONENTS_QUEUE_URL", value = var.checkcomponents_queue_url }, { name = "ROSTER_API_TOKEN_SECRET", value = "afterhours-shift-manager/roster-api-token" }, { name = "SYNC_GROUP", value = "DEFAULT" }, diff --git a/tests/shared/test_effects.py b/tests/shared/test_effects.py new file mode 100644 index 0000000..1e00d2d --- /dev/null +++ b/tests/shared/test_effects.py @@ -0,0 +1,54 @@ +"""Non-prod must not call Slack or 3CX.""" + +from unittest.mock import MagicMock + +from shared.effects import prod_side_effects_enabled +from shared.holiday_flow import activate +from shared.side_effects import slack_call, slack_token, update_3cx_routing + + +def test_missing_stage_keeps_prod_effects(monkeypatch): + monkeypatch.delenv("STAGE", raising=False) + assert prod_side_effects_enabled() is True + + +def test_dev_stage_disables_effects(monkeypatch): + monkeypatch.setenv("STAGE", "dev") + assert prod_side_effects_enabled() is False + + +def test_dev_slack_token_does_not_read_secrets(monkeypatch): + monkeypatch.setenv("STAGE", "dev") + monkeypatch.setenv( + "SLACK_BOT_TOKEN_SECRET", "afterhours-shift-manager/slack-bot-token" + ) + read = MagicMock(side_effect=AssertionError("secret read")) + monkeypatch.setattr("shared.side_effects.get_secret", read) + assert slack_token() is None + read.assert_not_called() + + +def test_dev_slack_call_does_not_post(monkeypatch): + monkeypatch.setenv("STAGE", "dev") + post = MagicMock(side_effect=AssertionError("slack post")) + monkeypatch.setattr("shared.side_effects.requests.post", post) + assert slack_call("chat.postMessage", "xoxb-token", channel="C0APATP612N") is False + post.assert_not_called() + + +def test_dev_skips_3cx_even_when_queue_is_configured(monkeypatch): + monkeypatch.setenv("STAGE", "dev") + monkeypatch.setenv("QUEUE_NUMBER", "801") + monkeypatch.setenv("TCX_SECRET_PREFIX", "afterhours-shift-manager/3cx-") + route = MagicMock(side_effect=AssertionError("3cx")) + monkeypatch.setattr("shared.side_effects.update_queue_routing", route) + update_3cx_routing("101") + route.assert_not_called() + + +def test_dev_holiday_activate_does_not_build_a_client(monkeypatch): + monkeypatch.setenv("STAGE", "dev") + factory = MagicMock(side_effect=AssertionError("3cx client")) + result = activate(MagicMock(), "2026-07-04", client_factory=factory) + assert result["skipped"] == "non_prod" + factory.assert_not_called()