Compare commits

..

No commits in common. "main" and "v1.17.0" have entirely different histories.

26 changed files with 49 additions and 307 deletions

View file

@ -13,7 +13,7 @@ permissions:
jobs: jobs:
autofix: autofix:
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19 uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
permissions: permissions:
contents: write contents: write
secrets: inherit secrets: inherit
@ -24,7 +24,7 @@ jobs:
lint: lint:
needs: autofix needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19 uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
with: with:
python-version: "3.12" python-version: "3.12"
@ -59,7 +59,7 @@ jobs:
terraform: terraform:
needs: autofix needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19 uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
with: with:
terraform-version: "1.16.0" terraform-version: "1.16.0"

View file

@ -7,4 +7,4 @@ permissions:
jobs: jobs:
review: review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19 uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16

View file

@ -43,7 +43,7 @@ jobs:
deploy-dev: deploy-dev:
name: Deploy API to dev name: Deploy API to dev
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev') if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19 uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
permissions: permissions:
contents: read contents: read
id-token: write id-token: write
@ -57,7 +57,7 @@ jobs:
deploy-prod: deploy-prod:
name: Deploy API to prod name: Deploy API to prod
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod') if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19 uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
permissions: permissions:
contents: read contents: read
id-token: write id-token: write

View file

@ -10,4 +10,4 @@ permissions:
jobs: jobs:
label: label:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19 uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16

View file

@ -12,6 +12,10 @@ Use one of: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, `refactor/`, `rele
- **Body sections** (exactly, in order): `Summary`, `Validation`, `Tests`, `Notes`. Use "None." under Notes when empty. - **Body sections** (exactly, in order): `Summary`, `Validation`, `Tests`, `Notes`. Use "None." under Notes when empty.
- State verifiable facts only. Do not justify changes by citing the handbook. No AI-attribution footers. - State verifiable facts only. Do not justify changes by citing the handbook. No AI-attribution footers.
## Security and Cross-Review
- Sensitive surfaces (payment flows, authentication, secrets handling, untrusted input) require security review.
- IAM role, policy, or resource-permission changes require cross-family review. Lambda handler signature changes alone do not.
## CI and Workflow References ## CI and Workflow References
- CI must pass before merge. - CI must pass before merge.
- Org-level reusable workflow refs must be pinned to a full commit SHA with a `# vX.Y.Z` comment. - Org-level reusable workflow refs must be pinned to a full commit SHA with a `# vX.Y.Z` comment.

View file

@ -542,9 +542,6 @@ components:
targetExtension: targetExtension:
type: string type: string
minLength: 1 minLength: 1
note:
type: string
description: Optional. Stored after trim, and the trimmed value must be 500 characters or fewer.
AdminOverrideBody: AdminOverrideBody:
type: object type: object
@ -701,9 +698,6 @@ components:
type: string type: string
incoming: incoming:
type: boolean type: boolean
note:
type: string
maxLength: 500
PendingPickup: PendingPickup:
type: object type: object

8
package-lock.json generated
View file

@ -8,13 +8,13 @@
"name": "afterhours-shift-manager", "name": "afterhours-shift-manager",
"version": "1.0.0", "version": "1.0.0",
"devDependencies": { "devDependencies": {
"@redocly/cli": "2.54.2" "@redocly/cli": "2.53.3"
} }
}, },
"node_modules/@redocly/cli": { "node_modules/@redocly/cli": {
"version": "2.54.2", "version": "2.53.3",
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.54.2.tgz", "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.53.3.tgz",
"integrity": "sha512-YQ53kSQV/zpYSdY3WiQvf7JxuVLD8jTEX37YOY6MS+G3tyHDCeONpUkAt6qr9n2/nmGm6m+A+PB/mGFvhkt1uQ==", "integrity": "sha512-hzNAWzHCOZ05vwRx0ehTxNeJaxxizjGV505eKtfs9MR/8ieD/8lYhAK4GF5FaqRRwOEpLO0PvzQwBSKYXmxhRA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {

View file

@ -6,6 +6,6 @@
"openapi:lint": "redocly lint --config .redocly.yaml openapi.yaml" "openapi:lint": "redocly lint --config .redocly.yaml openapi.yaml"
}, },
"devDependencies": { "devDependencies": {
"@redocly/cli": "2.54.2" "@redocly/cli": "2.53.3"
} }
} }

View file

@ -38,10 +38,6 @@ 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)

View file

@ -27,10 +27,6 @@ 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")

View file

@ -24,10 +24,6 @@ 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

View file

@ -67,8 +67,6 @@ 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"])

View file

@ -254,15 +254,8 @@ def build_shift_change_message(
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}] return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
def _mrkdwn_text(value: str) -> str:
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def build_swap_request_blocks( def build_swap_request_blocks(
requester_slack: str, requester_slack: str, date_str: str, shift_type: str = "night"
date_str: str,
shift_type: str = "night",
note: str | None = None,
) -> list[dict]: ) -> list[dict]:
"""Build the interactive Accept / Decline message DMed to a swap target.""" """Build the interactive Accept / Decline message DMed to a swap target."""
dt = datetime.strptime(date_str, "%Y-%m-%d") dt = datetime.strptime(date_str, "%Y-%m-%d")
@ -273,18 +266,15 @@ def build_swap_request_blocks(
else "" else ""
) )
action_suffix = "_day" if shift_type == "day" else "" action_suffix = "_day" if shift_type == "day" else ""
text = (
f"<@{requester_slack}> wants you to cover the "
f"*{day_label}*{type_label} shift. Accept to take it on."
)
if note:
text += f"\n\nNote: {_mrkdwn_text(note)}"
return [ return [
{ {
"type": "section", "type": "section",
"text": { "text": {
"type": "mrkdwn", "type": "mrkdwn",
"text": text, "text": (
f"<@{requester_slack}> wants you to cover the "
f"*{day_label}*{type_label} shift. Accept to take it on."
),
}, },
}, },
{ {

View file

@ -1,14 +0,0 @@
"""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"

View file

@ -5,7 +5,6 @@ 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
@ -29,9 +28,6 @@ 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)
@ -81,9 +77,6 @@ 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)

View file

@ -100,7 +100,6 @@ def dispatch(
body.get("date", ""), body.get("date", ""),
body.get("targetExtension", ""), body.get("targetExtension", ""),
body.get("shiftType"), body.get("shiftType"),
note=body.get("note"),
) )
if ( if (
method == "POST" method == "POST"

View file

@ -160,25 +160,9 @@ def snapshot(schedule: ShiftSchedule, employee: dict, week: str = "this") -> dic
return payload return payload
NOTE_MAX = 500
def _clean_swap_note(note: str | None) -> str | None:
if note is None:
return None
if not isinstance(note, str):
raise ActionError(400, "INVALID_NOTE", "Note must be text.")
cleaned = note.strip()
if not cleaned:
return None
if len(cleaned) > NOTE_MAX:
raise ActionError(400, "NOTE_TOO_LONG", "Note must be 500 characters or fewer.")
return cleaned
def _swap_payload(item: dict, my_ext: str) -> dict: def _swap_payload(item: dict, my_ext: str) -> dict:
date_str, shift_type = _sk_to_date_shift(item.get("SK", "")) date_str, shift_type = _sk_to_date_shift(item.get("SK", ""))
payload = { return {
"date": date_str, "date": date_str,
"shiftType": item.get("shift_type") or shift_type, "shiftType": item.get("shift_type") or shift_type,
"requesterExt": item.get("requester_ext", ""), "requesterExt": item.get("requester_ext", ""),
@ -187,10 +171,6 @@ def _swap_payload(item: dict, my_ext: str) -> dict:
"targetName": item.get("target_name", ""), "targetName": item.get("target_name", ""),
"incoming": item.get("target_ext") == my_ext, "incoming": item.get("target_ext") == my_ext,
} }
note = item.get("note")
if isinstance(note, str) and note.strip():
payload["note"] = note.strip()
return payload
def _pickup_payload(item: dict) -> dict: def _pickup_payload(item: dict) -> dict:
@ -439,7 +419,6 @@ def swap(
date_str: str, date_str: str,
target_extension: str, target_extension: str,
shift_type: str | None, shift_type: str | None,
note: str | None = None,
) -> dict: ) -> dict:
date = _parse_date(date_str) date = _parse_date(date_str)
date_str = date.strftime(DATE_FMT) date_str = date.strftime(DATE_FMT)
@ -475,11 +454,8 @@ def swap(
raise ActionError(400, "UNKNOWN_TARGET", "That extension is not on the roster.") raise ActionError(400, "UNKNOWN_TARGET", "That extension is not on the roster.")
if target["extension"] == employee["extension"]: if target["extension"] == employee["extension"]:
raise ActionError(400, "SELF_SWAP", "That shift is already yours.") raise ActionError(400, "SELF_SWAP", "That shift is already yours.")
cleaned_note = _clean_swap_note(note)
expires_at = int(shift_start(date_str, resolved).timestamp()) expires_at = int(shift_start(date_str, resolved).timestamp())
schedule.create_pending_swap( schedule.create_pending_swap(date_str, resolved, employee, target, expires_at)
date_str, resolved, employee, target, expires_at, note=cleaned_note
)
token = effects.slack_token() token = effects.slack_token()
if target.get("slack_user_id"): if target.get("slack_user_id"):
effects.dm_swap_request( effects.dm_swap_request(
@ -489,7 +465,6 @@ def swap(
date_str, date_str,
resolved, resolved,
employee["name"], employee["name"],
note=cleaned_note,
) )
return { return {
"ok": True, "ok": True,

View file

@ -261,32 +261,29 @@ class ShiftSchedule:
requester: dict, requester: dict,
target: dict, target: dict,
expires_at: int, expires_at: int,
note: str | None = None,
) -> None: ) -> None:
"""Create (or supersede) a pending swap request for a shift. """Create (or supersede) a pending swap request for a shift.
One swap per shift (unique SK), so a new request overwrites any prior One swap per shift (unique SK), so a new request overwrites any prior
pending one. ``expires_at`` is an epoch timestamp used for DynamoDB TTL. pending one. ``expires_at`` is an epoch timestamp used for DynamoDB TTL.
``note`` is omitted when empty so Slack ``/oncall swap`` stays unchanged.
""" """
sk = f"{date_str}-DAY" if shift_type == "day" else date_str sk = f"{date_str}-DAY" if shift_type == "day" else date_str
item = { self.table.put_item(
"PK": "SWAP", Item={
"SK": sk, "PK": "SWAP",
"shift_type": shift_type, "SK": sk,
"status": "pending", "shift_type": shift_type,
"requester_ext": requester["extension"], "status": "pending",
"requester_name": requester["name"], "requester_ext": requester["extension"],
"requester_slack": requester.get("slack_user_id", ""), "requester_name": requester["name"],
"target_ext": target["extension"], "requester_slack": requester.get("slack_user_id", ""),
"target_name": target["name"], "target_ext": target["extension"],
"target_slack": target.get("slack_user_id", ""), "target_name": target["name"],
"created_at": datetime.now(EASTERN).isoformat(), "target_slack": target.get("slack_user_id", ""),
"expires_at": expires_at, "created_at": datetime.now(EASTERN).isoformat(),
} "expires_at": expires_at,
if note: }
item["note"] = note )
self.table.put_item(Item=item)
def get_swap(self, date_str: str, shift_type: str = "night") -> dict | None: def get_swap(self, date_str: str, shift_type: str = "night") -> dict | None:
sk = f"{date_str}-DAY" if shift_type == "day" else date_str sk = f"{date_str}-DAY" if shift_type == "day" else date_str

View file

@ -15,11 +15,9 @@ from shared.blocks import (
build_holiday_added_blocks, build_holiday_added_blocks,
build_pickup_request_blocks, build_pickup_request_blocks,
build_shift_change_message, build_shift_change_message,
_mrkdwn_text,
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
@ -32,8 +30,6 @@ 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
@ -45,9 +41,6 @@ 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}",
@ -69,9 +62,6 @@ 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:
@ -98,8 +88,6 @@ 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")
@ -284,19 +272,15 @@ def dm_swap_request(
date_str: str, date_str: str,
shift_type: str, shift_type: str,
requester_name: str, requester_name: str,
note: str | None = None,
) -> bool: ) -> bool:
if not token or not target_slack: if not token or not target_slack:
return False return False
text = f"{requester_name} wants to swap you the {date_str} shift"
if note:
text += f"\nNote: {_mrkdwn_text(note)}"
return slack_call( return slack_call(
"chat.postMessage", "chat.postMessage",
token, token,
channel=target_slack, channel=target_slack,
blocks=build_swap_request_blocks(requester_slack, date_str, shift_type, note), blocks=build_swap_request_blocks(requester_slack, date_str, shift_type),
text=text, text=f"{requester_name} wants to swap you the {date_str} shift",
) )

View file

@ -289,10 +289,6 @@ 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

View file

@ -10,16 +10,6 @@ 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 == ""

View file

@ -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 = local.is_prod ? var.shift_channel : "" }, { name = "SHIFT_CHANNEL", value = var.shift_channel },
{ name = "TCX_SECRET_PREFIX", value = local.is_prod ? "afterhours-shift-manager/3cx-" : "" }, { name = "TCX_SECRET_PREFIX", value = "afterhours-shift-manager/3cx-" },
{ name = "QUEUE_NUMBER", value = local.is_prod ? var.queue_number : "" }, { name = "QUEUE_NUMBER", value = 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 = local.is_prod ? var.pay_report_user : "" }, { name = "PAY_REPORT_USER", value = 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" },

View file

@ -101,7 +101,7 @@ def test_ecs_task_boundary_uses_static_arns():
def test_deploy_api_workflow_exists(): def test_deploy_api_workflow_exists():
deploy_api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text() deploy_api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text()
pin = "cd-hcp-fargate.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19" pin = "cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16"
assert deploy_api.count(pin) == 2 assert deploy_api.count(pin) == 2
assert "ssm-prefix: /afterhours-shift-manager/deploy" in deploy_api assert "ssm-prefix: /afterhours-shift-manager/deploy" in deploy_api
assert "docker-platform: linux/arm64" in deploy_api assert "docker-platform: linux/arm64" in deploy_api
@ -119,9 +119,9 @@ def test_in_repo_hcptf_roles():
def test_ci_runs_pytest_and_terraform_validate(): def test_ci_runs_pytest_and_terraform_validate():
assert "ci-python-sam" not in CI assert "ci-python-sam" not in CI
assert "ci-python-app.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19" in CI assert "ci-python-app.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
assert "ci-terraform.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19" in CI assert "ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
assert "ci-autofix.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19" in CI assert "ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
assert "name: ci-complete" in CI assert "name: ci-complete" in CI
assert "pytest" in CI assert "pytest" in CI
assert "terraform fmt -check" not in CI assert "terraform fmt -check" not in CI

View file

@ -259,37 +259,6 @@ class TestBuildSwapRequestBlocks:
assert elements[0]["style"] == "primary" # Accept assert elements[0]["style"] == "primary" # Accept
assert elements[1]["style"] == "danger" # Decline assert elements[1]["style"] == "danger" # Decline
def test_note_is_appended_without_changing_actions(self):
blocks = build_swap_request_blocks(
"U_REQ", "2026-06-03", "night", note="Family <commitment> & more"
)
assert "Note: Family &lt;commitment&gt; &amp; more" in blocks[0]["text"]["text"]
ids = self._action_ids(blocks)
assert ids == ["swap_accept_2026-06-03", "swap_decline_2026-06-03"]
def test_dm_swap_fallback_escapes_the_note(monkeypatch):
from shared import side_effects as effects
captured = {}
def fake_slack_call(_method, _token, **kwargs):
captured.update(kwargs)
return True
monkeypatch.setattr(effects, "slack_call", fake_slack_call)
assert effects.dm_swap_request(
"tok",
"U_REQ",
"U_TGT",
"2026-06-03",
"night",
"Alice",
note="ping <@U_ADMIN>",
)
assert "Note: ping &lt;@U_ADMIN&gt;" in captured["text"]
assert "<@U_ADMIN>" not in captured["text"]
class TestBuildSwapResolvedBlocks: class TestBuildSwapResolvedBlocks:
def test_renders_text_no_buttons(self): def test_renders_text_no_buttons(self):

View file

@ -1,54 +0,0 @@
"""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()

View file

@ -75,73 +75,6 @@ def test_swap_creates_pending(schedule, seed, quiet_slack):
assert snap["pendingSwaps"][0]["incoming"] is True assert snap["pendingSwaps"][0]["incoming"] is True
mine = snapshot(schedule, employee) mine = snapshot(schedule, employee)
assert mine["pendingSwaps"][0]["incoming"] is False assert mine["pendingSwaps"][0]["incoming"] is False
assert "note" not in pending
assert "note" not in snap["pendingSwaps"][0]
def test_swap_stores_returns_and_dms_note(schedule, seed, quiet_slack, monkeypatch):
employee = _alice(schedule, seed)
seed.override("2026-06-12", "114", "Alice")
calls = []
def capture(*args, **kwargs):
calls.append((args, kwargs))
return True
monkeypatch.setattr(effects, "dm_swap_request", capture)
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
swap(
schedule,
employee,
"2026-06-12",
"115",
"night",
note=" Family commitment ",
)
pending = schedule.get_swap("2026-06-12", "night")
assert pending["note"] == "Family commitment"
snap = snapshot(schedule, employee)
assert snap["pendingSwaps"][0]["note"] == "Family commitment"
assert calls[0][1]["note"] == "Family commitment"
def test_swap_accepts_note_that_trims_to_500(schedule, seed, quiet_slack):
employee = _alice(schedule, seed)
seed.override("2026-06-12", "114", "Alice")
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
swap(schedule, employee, "2026-06-12", "115", "night", note=f" {'a' * 500} ")
assert schedule.get_swap("2026-06-12", "night")["note"] == "a" * 500
def test_swap_omits_blank_note(schedule, seed, quiet_slack):
employee = _alice(schedule, seed)
seed.override("2026-06-12", "114", "Alice")
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
swap(schedule, employee, "2026-06-12", "115", "night", note=" ")
pending = schedule.get_swap("2026-06-12", "night")
assert "note" not in pending
snap = snapshot(schedule, employee)
assert "note" not in snap["pendingSwaps"][0]
def test_swap_rejects_long_note(schedule, seed, quiet_slack):
employee = _alice(schedule, seed)
seed.override("2026-06-12", "114", "Alice")
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
with pytest.raises(ActionError) as err:
swap(schedule, employee, "2026-06-12", "115", "night", note="a" * 501)
assert err.value.code == "NOTE_TOO_LONG"
assert schedule.get_swap("2026-06-12", "night") is None
def test_swap_rejects_non_string_note(schedule, seed, quiet_slack):
employee = _alice(schedule, seed)
seed.override("2026-06-12", "114", "Alice")
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
with pytest.raises(ActionError) as err:
swap(schedule, employee, "2026-06-12", "115", "night", note=12)
assert err.value.code == "INVALID_NOTE"
assert schedule.get_swap("2026-06-12", "night") is None
def test_late_pickup_creates_request(schedule, seed, quiet_slack): def test_late_pickup_creates_request(schedule, seed, quiet_slack):