mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 04:33:10 +00:00
fix(side-effects): keep non-prod off Slack and 3CX (DEV-306) (#287)
* fix(side-effects): keep non-prod off Slack and 3CX Dev portal actions could still name the production Slack channel and phone queue. Skip those calls unless STAGE is prod, and leave the identifiers empty on non-prod tasks. * style: apply formatter --------- Co-authored-by: sea-haven-auto-fix[bot] <332630863+sea-haven-auto-fix[bot]@users.noreply.github.com>
This commit is contained in:
parent
3030b134fa
commit
54ad5a7e34
11 changed files with 118 additions and 4 deletions
|
|
@ -38,6 +38,10 @@ def _deactivate(schedule: ShiftSchedule, date: str) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
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")
|
action = event.get("action")
|
||||||
date = event.get("date")
|
date = event.get("date")
|
||||||
logger.info("Holiday router invoked: action=%s date=%s", action, date)
|
logger.info("Holiday router invoked: action=%s date=%s", action, date)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ EASTERN = ZoneInfo("America/New_York")
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
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)
|
now = datetime.now(EASTERN)
|
||||||
current_hour = now.hour
|
current_hour = now.hour
|
||||||
day_name = now.strftime("%A")
|
day_name = now.strftime("%A")
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ EXCLUDE_NAMES = {"Voicemail", "IVR", "Fax"}
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
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)
|
now = datetime.now(EASTERN)
|
||||||
|
|
||||||
# DST guard — two EventBridge rules fire, only one is at 6am ET
|
# DST guard — two EventBridge rules fire, only one is at 6am ET
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,8 @@ def create_app() -> Flask:
|
||||||
|
|
||||||
@app.route("/slack/events", methods=["POST"])
|
@app.route("/slack/events", methods=["POST"])
|
||||||
def slack_events():
|
def slack_events():
|
||||||
|
if os.environ.get("STAGE", "prod") != "prod":
|
||||||
|
return jsonify({"error": "slack_disabled"}), 404
|
||||||
return _get_slack_handler().handle(request)
|
return _get_slack_handler().handle(request)
|
||||||
|
|
||||||
@app.route("/api/shifts", methods=["GET", "POST", "DELETE", "OPTIONS"])
|
@app.route("/api/shifts", methods=["GET", "POST", "DELETE", "OPTIONS"])
|
||||||
|
|
|
||||||
14
src/shared/shared/effects.py
Normal file
14
src/shared/shared/effects.py
Normal file
|
|
@ -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"
|
||||||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from shared.effects import prod_side_effects_enabled
|
||||||
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
||||||
from shared.secrets import get_secret
|
from shared.secrets import get_secret
|
||||||
from shared.three_cx_client import ThreeCXClient, oauth_client
|
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:
|
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)
|
holiday = schedule.get_holiday(date)
|
||||||
if holiday is None:
|
if holiday is None:
|
||||||
logger.info("No holiday record for %s — nothing to activate", date)
|
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:
|
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)
|
holiday = schedule.get_holiday(date)
|
||||||
if holiday is None:
|
if holiday is None:
|
||||||
logger.info("No holiday record for %s — nothing to deactivate", date)
|
logger.info("No holiday record for %s — nothing to deactivate", date)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from shared.blocks import (
|
||||||
build_swap_request_blocks,
|
build_swap_request_blocks,
|
||||||
build_week_schedule,
|
build_week_schedule,
|
||||||
)
|
)
|
||||||
|
from shared.effects import prod_side_effects_enabled
|
||||||
from shared.ring_scheduler import update_queue_routing
|
from shared.ring_scheduler import update_queue_routing
|
||||||
from shared.schedule import FALLBACK_EXTENSION, week_start
|
from shared.schedule import FALLBACK_EXTENSION, week_start
|
||||||
from shared.secrets import get_secret
|
from shared.secrets import get_secret
|
||||||
|
|
@ -31,6 +32,8 @@ SLACK_API = "https://slack.com/api"
|
||||||
|
|
||||||
|
|
||||||
def slack_token() -> str | None:
|
def slack_token() -> str | None:
|
||||||
|
if not prod_side_effects_enabled():
|
||||||
|
return None
|
||||||
secret_id = os.environ.get("SLACK_BOT_TOKEN_SECRET")
|
secret_id = os.environ.get("SLACK_BOT_TOKEN_SECRET")
|
||||||
if not secret_id:
|
if not secret_id:
|
||||||
return None
|
return None
|
||||||
|
|
@ -42,6 +45,9 @@ def slack_token() -> str | None:
|
||||||
|
|
||||||
|
|
||||||
def slack_call(method: str, token: str, **payload) -> bool:
|
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:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{SLACK_API}/{method}",
|
f"{SLACK_API}/{method}",
|
||||||
|
|
@ -63,6 +69,9 @@ def slack_call(method: str, token: str, **payload) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def update_3cx_routing(extension: str) -> None:
|
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")
|
queue_number = os.environ.get("QUEUE_NUMBER")
|
||||||
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
|
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
|
||||||
if not queue_number or not 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:
|
def make_3cx_client() -> ThreeCXClient | None:
|
||||||
"""Return the process OAuth client, refreshing it when the token or secret changed."""
|
"""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")
|
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
|
||||||
if not secret_prefix:
|
if not secret_prefix:
|
||||||
logger.warning("3CX env vars not set — skipping 3CX call")
|
logger.warning("3CX env vars not set — skipping 3CX call")
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,10 @@ def _send_checkcomponents(pay_record: dict) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
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)
|
now = datetime.now(EASTERN)
|
||||||
|
|
||||||
# DST guard — same pattern as the 3CX scheduler
|
# DST guard — same pattern as the 3CX scheduler
|
||||||
|
|
|
||||||
|
|
@ -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" {
|
check "dev_has_no_paychex" {
|
||||||
assert {
|
assert {
|
||||||
condition = local.is_prod || var.checkcomponents_queue_url == ""
|
condition = local.is_prod || var.checkcomponents_queue_url == ""
|
||||||
|
|
|
||||||
|
|
@ -182,9 +182,9 @@ locals {
|
||||||
{ name = "SHIFT_TABLE", value = aws_dynamodb_table.shifts.name },
|
{ name = "SHIFT_TABLE", value = aws_dynamodb_table.shifts.name },
|
||||||
{ name = "SLACK_BOT_TOKEN_SECRET", value = "afterhours-shift-manager/slack-bot-token" },
|
{ name = "SLACK_BOT_TOKEN_SECRET", value = "afterhours-shift-manager/slack-bot-token" },
|
||||||
{ name = "SLACK_SIGNING_SECRET", value = "afterhours-shift-manager/slack-signing-secret" },
|
{ name = "SLACK_SIGNING_SECRET", value = "afterhours-shift-manager/slack-signing-secret" },
|
||||||
{ name = "SHIFT_CHANNEL", value = var.shift_channel },
|
{ name = "SHIFT_CHANNEL", value = local.is_prod ? var.shift_channel : "" },
|
||||||
{ name = "TCX_SECRET_PREFIX", value = "afterhours-shift-manager/3cx-" },
|
{ name = "TCX_SECRET_PREFIX", value = local.is_prod ? "afterhours-shift-manager/3cx-" : "" },
|
||||||
{ name = "QUEUE_NUMBER", value = var.queue_number },
|
{ name = "QUEUE_NUMBER", value = local.is_prod ? var.queue_number : "" },
|
||||||
{ name = "TZ", value = var.timezone },
|
{ name = "TZ", value = var.timezone },
|
||||||
{ name = "HOLIDAY_SCHEDULER_ROLE_ARN", value = local.holiday_scheduler_role_arn },
|
{ name = "HOLIDAY_SCHEDULER_ROLE_ARN", value = local.holiday_scheduler_role_arn },
|
||||||
{ name = "HOLIDAY_SCHEDULE_GROUP", value = "default" },
|
{ 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_issuer != "" && var.portal_cognito_audience != "" ? [{ issuer = var.portal_cognito_issuer, audience = var.portal_cognito_audience }] : [],
|
||||||
var.portal_cognito_extra_trust,
|
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 = "CHECKCOMPONENTS_QUEUE_URL", value = var.checkcomponents_queue_url },
|
||||||
{ name = "ROSTER_API_TOKEN_SECRET", value = "afterhours-shift-manager/roster-api-token" },
|
{ name = "ROSTER_API_TOKEN_SECRET", value = "afterhours-shift-manager/roster-api-token" },
|
||||||
{ name = "SYNC_GROUP", value = "DEFAULT" },
|
{ name = "SYNC_GROUP", value = "DEFAULT" },
|
||||||
|
|
|
||||||
54
tests/shared/test_effects.py
Normal file
54
tests/shared/test_effects.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Add table
Reference in a new issue