afterhours-shift-manager/tests/slack_bot/test_swap_accept_decline.py

187 lines
7.3 KiB
Python
Raw Normal View History

"""Tests for slack-bot handle_swap_accept / handle_swap_decline."""
from freezegun import freeze_time
# Monday 2026-06-01 08:00 ET — weekday, active shift is night, before 17:00 start.
MON = "2026-06-01 12:00:00"
# Same Monday but 18:00 ET — the night shift has already started.
MON_AFTER_START = "2026-06-01 22:00:00"
REQ = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
TGT = {"extension": "115", "name": "Bob", "slack_user_id": "U_BOB"}
def _accept_body(date_str, user_id="U_BOB", suffix=""):
return {
"actions": [{"action_id": f"swap_accept_{date_str}{suffix}"}],
"user": {"id": user_id},
"channel": {"id": "D1"},
}
def _decline_body(date_str, user_id="U_BOB", suffix=""):
return {
"actions": [{"action_id": f"swap_decline_{date_str}{suffix}"}],
"user": {"id": user_id},
"channel": {"id": "D1"},
}
def _pending(schedule, date_str, shift_type="night", target=TGT):
schedule.create_pending_swap(date_str, shift_type, REQ, target, 1_900_000_000)
def _resolved_text(respond):
return respond.call_args.kwargs["blocks"][0]["text"]["text"]
def _channels(client):
return [c.kwargs.get("channel") for c in client.chat_postMessage.call_args_list]
class TestAccept:
@freeze_time(MON)
def test_applies_override_marks_verified_notifies(
INFRA-106: nightly-sweep security remediation (auth/race/IAM) (#125) * Fix auth and race-condition flaws in shift commands Four confirmed findings from the 2026-06-17 security sweep: - register_user let any Slack user overwrite an extension already bound to a different user (account takeover). Add a DynamoDB ConditionExpression so a write only succeeds when the extension is unclaimed or already this user's; raise ExtensionAlreadyRegistered otherwise and surface a clear Slack message. - The `rate` subcommand was routed without the is_admin flag, so any user could set $0 pay rates. Gate _handle_rate on is_admin, matching the admin-command guard. - `/oncall pick` used a plain put_item (TOCTOU): two concurrent picks both won. Use the atomic claim_open_shift conditional claim so the loser gets an "already picked up" message. - swap-accept overwrote a shift independently claimed after the swap was initiated. Add reassign_if_held_by, a conditional write that only applies the swap while the override is still the requester's (or on the weekly fallback), and notify the accepter otherwise. Add tests for the register-ownership guard and the rate admin guard. Refs: INFRA * Scope shift-manager Lambda IAM to least privilege The nightly sweep flagged four over-broad permissions. Scope each to only what the function actually reads (verified against source): - WeeklyPost: secrets to slack-bot-token-* only (was the whole afterhours-shift-manager/* namespace); SES SendEmail to the single noreply@seahaven.com identity (was identity/*). - RosterSync and RingScheduler: secrets to 3cx-* only (was the whole namespace); both read only the 3cx domain/client-id/client-secret. SlackBotFunction and HolidayRouter wildcards are left unchanged — out of scope for this sweep. Refs: INFRA * fix: re-validate shift holder on swap-accept (sh-security-review RIHB-1) reassign_if_held_by trusted 'no override row' as 'still the requester's', but a weekly-held shift also has no override row. An admin clear or weekly edit between swap-init and accept could move the shift to a third party with no override, letting the accept steal it (CWE-367, confirmed HIGH). Re-resolve the current holder at accept and abort if it is no longer the requester. Adds regression test + seeds the holder in existing accept tests. * fix: complete IAM least-privilege sweep (sh-security-review) HolidayRouter secrets scope afterhours-shift-manager/* -> /3cx-* (reads only 3cx secrets); RingScheduler DynamoDBCrudPolicy -> DynamoDBReadPolicy (read-only at runtime). SlackBot wildcard left as-is (reads across all sub-prefixes; verified defensible).
2026-06-18 12:05:25 -04:00
self, slackbot_app, schedule, seed, respond, client, routing_spy
):
INFRA-106: nightly-sweep security remediation (auth/race/IAM) (#125) * Fix auth and race-condition flaws in shift commands Four confirmed findings from the 2026-06-17 security sweep: - register_user let any Slack user overwrite an extension already bound to a different user (account takeover). Add a DynamoDB ConditionExpression so a write only succeeds when the extension is unclaimed or already this user's; raise ExtensionAlreadyRegistered otherwise and surface a clear Slack message. - The `rate` subcommand was routed without the is_admin flag, so any user could set $0 pay rates. Gate _handle_rate on is_admin, matching the admin-command guard. - `/oncall pick` used a plain put_item (TOCTOU): two concurrent picks both won. Use the atomic claim_open_shift conditional claim so the loser gets an "already picked up" message. - swap-accept overwrote a shift independently claimed after the swap was initiated. Add reassign_if_held_by, a conditional write that only applies the swap while the override is still the requester's (or on the weekly fallback), and notify the accepter otherwise. Add tests for the register-ownership guard and the rate admin guard. Refs: INFRA * Scope shift-manager Lambda IAM to least privilege The nightly sweep flagged four over-broad permissions. Scope each to only what the function actually reads (verified against source): - WeeklyPost: secrets to slack-bot-token-* only (was the whole afterhours-shift-manager/* namespace); SES SendEmail to the single noreply@seahaven.com identity (was identity/*). - RosterSync and RingScheduler: secrets to 3cx-* only (was the whole namespace); both read only the 3cx domain/client-id/client-secret. SlackBotFunction and HolidayRouter wildcards are left unchanged — out of scope for this sweep. Refs: INFRA * fix: re-validate shift holder on swap-accept (sh-security-review RIHB-1) reassign_if_held_by trusted 'no override row' as 'still the requester's', but a weekly-held shift also has no override row. An admin clear or weekly edit between swap-init and accept could move the shift to a third party with no override, letting the accept steal it (CWE-367, confirmed HIGH). Re-resolve the current holder at accept and abort if it is no longer the requester. Adds regression test + seeds the holder in existing accept tests. * fix: complete IAM least-privilege sweep (sh-security-review) HolidayRouter secrets scope afterhours-shift-manager/* -> /3cx-* (reads only 3cx secrets); RingScheduler DynamoDBCrudPolicy -> DynamoDBReadPolicy (read-only at runtime). SlackBot wildcard left as-is (reads across all sub-prefixes; verified defensible).
2026-06-18 12:05:25 -04:00
# Requester holds the shift via the weekly schedule (production
# invariant: swap-create validates the requester is the holder).
seed.weekly("Monday", "114", "Alice")
_pending(schedule, "2026-06-01")
slackbot_app.handle_swap_accept(
_accept_body("2026-06-01"), respond, client, schedule, "C_TEST"
)
assert schedule.get_override("2026-06-01")["extension"] == "115"
assert schedule.get_swap("2026-06-01")["status"] == "verified"
# Today + active night → 3CX repointed to the new holder.
routing_spy.assert_called_once_with("115")
# Channel notification + requester DM both sent.
assert "C_TEST" in _channels(client) and "U_ALICE" in _channels(client)
assert "now covering" in _resolved_text(respond).lower()
@freeze_time(MON)
def test_future_shift_no_3cx(
INFRA-106: nightly-sweep security remediation (auth/race/IAM) (#125) * Fix auth and race-condition flaws in shift commands Four confirmed findings from the 2026-06-17 security sweep: - register_user let any Slack user overwrite an extension already bound to a different user (account takeover). Add a DynamoDB ConditionExpression so a write only succeeds when the extension is unclaimed or already this user's; raise ExtensionAlreadyRegistered otherwise and surface a clear Slack message. - The `rate` subcommand was routed without the is_admin flag, so any user could set $0 pay rates. Gate _handle_rate on is_admin, matching the admin-command guard. - `/oncall pick` used a plain put_item (TOCTOU): two concurrent picks both won. Use the atomic claim_open_shift conditional claim so the loser gets an "already picked up" message. - swap-accept overwrote a shift independently claimed after the swap was initiated. Add reassign_if_held_by, a conditional write that only applies the swap while the override is still the requester's (or on the weekly fallback), and notify the accepter otherwise. Add tests for the register-ownership guard and the rate admin guard. Refs: INFRA * Scope shift-manager Lambda IAM to least privilege The nightly sweep flagged four over-broad permissions. Scope each to only what the function actually reads (verified against source): - WeeklyPost: secrets to slack-bot-token-* only (was the whole afterhours-shift-manager/* namespace); SES SendEmail to the single noreply@seahaven.com identity (was identity/*). - RosterSync and RingScheduler: secrets to 3cx-* only (was the whole namespace); both read only the 3cx domain/client-id/client-secret. SlackBotFunction and HolidayRouter wildcards are left unchanged — out of scope for this sweep. Refs: INFRA * fix: re-validate shift holder on swap-accept (sh-security-review RIHB-1) reassign_if_held_by trusted 'no override row' as 'still the requester's', but a weekly-held shift also has no override row. An admin clear or weekly edit between swap-init and accept could move the shift to a third party with no override, letting the accept steal it (CWE-367, confirmed HIGH). Re-resolve the current holder at accept and abort if it is no longer the requester. Adds regression test + seeds the holder in existing accept tests. * fix: complete IAM least-privilege sweep (sh-security-review) HolidayRouter secrets scope afterhours-shift-manager/* -> /3cx-* (reads only 3cx secrets); RingScheduler DynamoDBCrudPolicy -> DynamoDBReadPolicy (read-only at runtime). SlackBot wildcard left as-is (reads across all sub-prefixes; verified defensible).
2026-06-18 12:05:25 -04:00
self, slackbot_app, schedule, seed, respond, client, routing_spy
):
INFRA-106: nightly-sweep security remediation (auth/race/IAM) (#125) * Fix auth and race-condition flaws in shift commands Four confirmed findings from the 2026-06-17 security sweep: - register_user let any Slack user overwrite an extension already bound to a different user (account takeover). Add a DynamoDB ConditionExpression so a write only succeeds when the extension is unclaimed or already this user's; raise ExtensionAlreadyRegistered otherwise and surface a clear Slack message. - The `rate` subcommand was routed without the is_admin flag, so any user could set $0 pay rates. Gate _handle_rate on is_admin, matching the admin-command guard. - `/oncall pick` used a plain put_item (TOCTOU): two concurrent picks both won. Use the atomic claim_open_shift conditional claim so the loser gets an "already picked up" message. - swap-accept overwrote a shift independently claimed after the swap was initiated. Add reassign_if_held_by, a conditional write that only applies the swap while the override is still the requester's (or on the weekly fallback), and notify the accepter otherwise. Add tests for the register-ownership guard and the rate admin guard. Refs: INFRA * Scope shift-manager Lambda IAM to least privilege The nightly sweep flagged four over-broad permissions. Scope each to only what the function actually reads (verified against source): - WeeklyPost: secrets to slack-bot-token-* only (was the whole afterhours-shift-manager/* namespace); SES SendEmail to the single noreply@seahaven.com identity (was identity/*). - RosterSync and RingScheduler: secrets to 3cx-* only (was the whole namespace); both read only the 3cx domain/client-id/client-secret. SlackBotFunction and HolidayRouter wildcards are left unchanged — out of scope for this sweep. Refs: INFRA * fix: re-validate shift holder on swap-accept (sh-security-review RIHB-1) reassign_if_held_by trusted 'no override row' as 'still the requester's', but a weekly-held shift also has no override row. An admin clear or weekly edit between swap-init and accept could move the shift to a third party with no override, letting the accept steal it (CWE-367, confirmed HIGH). Re-resolve the current holder at accept and abort if it is no longer the requester. Adds regression test + seeds the holder in existing accept tests. * fix: complete IAM least-privilege sweep (sh-security-review) HolidayRouter secrets scope afterhours-shift-manager/* -> /3cx-* (reads only 3cx secrets); RingScheduler DynamoDBCrudPolicy -> DynamoDBReadPolicy (read-only at runtime). SlackBot wildcard left as-is (reads across all sub-prefixes; verified defensible).
2026-06-18 12:05:25 -04:00
seed.weekly("Wednesday", "114", "Alice")
_pending(schedule, "2026-06-03") # Wednesday
slackbot_app.handle_swap_accept(
_accept_body("2026-06-03"), respond, client, schedule, "C_TEST"
)
assert schedule.get_override("2026-06-03")["extension"] == "115"
routing_spy.assert_not_called()
@freeze_time(MON)
def test_wrong_clicker_rejected(
self, slackbot_app, schedule, respond, client, routing_spy
):
_pending(schedule, "2026-06-01")
slackbot_app.handle_swap_accept(
_accept_body("2026-06-01", user_id="U_STRANGER"),
respond,
client,
schedule,
"C_TEST",
)
assert schedule.get_override("2026-06-01") is None
assert "no longer valid" in _resolved_text(respond).lower()
routing_spy.assert_not_called()
@freeze_time(MON)
def test_no_pending_swap(self, slackbot_app, schedule, respond, client):
slackbot_app.handle_swap_accept(
_accept_body("2026-06-01"), respond, client, schedule, "C_TEST"
)
assert "no longer valid" in _resolved_text(respond).lower()
@freeze_time(MON)
def test_already_verified_swap_not_reapplied(
self, slackbot_app, schedule, respond, client
):
_pending(schedule, "2026-06-01")
schedule.mark_swap_verified("2026-06-01")
slackbot_app.handle_swap_accept(
_accept_body("2026-06-01"), respond, client, schedule, "C_TEST"
)
assert "no longer valid" in _resolved_text(respond).lower()
assert schedule.get_override("2026-06-01") is None
@freeze_time(MON_AFTER_START)
def test_expired_after_shift_start(
self, slackbot_app, schedule, respond, client, routing_spy
):
_pending(schedule, "2026-06-01")
slackbot_app.handle_swap_accept(
_accept_body("2026-06-01"), respond, client, schedule, "C_TEST"
)
assert "expired" in _resolved_text(respond).lower()
assert schedule.get_override("2026-06-01") is None
assert schedule.get_swap("2026-06-01") is None # cleared
routing_spy.assert_not_called()
@freeze_time(MON)
INFRA-106: nightly-sweep security remediation (auth/race/IAM) (#125) * Fix auth and race-condition flaws in shift commands Four confirmed findings from the 2026-06-17 security sweep: - register_user let any Slack user overwrite an extension already bound to a different user (account takeover). Add a DynamoDB ConditionExpression so a write only succeeds when the extension is unclaimed or already this user's; raise ExtensionAlreadyRegistered otherwise and surface a clear Slack message. - The `rate` subcommand was routed without the is_admin flag, so any user could set $0 pay rates. Gate _handle_rate on is_admin, matching the admin-command guard. - `/oncall pick` used a plain put_item (TOCTOU): two concurrent picks both won. Use the atomic claim_open_shift conditional claim so the loser gets an "already picked up" message. - swap-accept overwrote a shift independently claimed after the swap was initiated. Add reassign_if_held_by, a conditional write that only applies the swap while the override is still the requester's (or on the weekly fallback), and notify the accepter otherwise. Add tests for the register-ownership guard and the rate admin guard. Refs: INFRA * Scope shift-manager Lambda IAM to least privilege The nightly sweep flagged four over-broad permissions. Scope each to only what the function actually reads (verified against source): - WeeklyPost: secrets to slack-bot-token-* only (was the whole afterhours-shift-manager/* namespace); SES SendEmail to the single noreply@seahaven.com identity (was identity/*). - RosterSync and RingScheduler: secrets to 3cx-* only (was the whole namespace); both read only the 3cx domain/client-id/client-secret. SlackBotFunction and HolidayRouter wildcards are left unchanged — out of scope for this sweep. Refs: INFRA * fix: re-validate shift holder on swap-accept (sh-security-review RIHB-1) reassign_if_held_by trusted 'no override row' as 'still the requester's', but a weekly-held shift also has no override row. An admin clear or weekly edit between swap-init and accept could move the shift to a third party with no override, letting the accept steal it (CWE-367, confirmed HIGH). Re-resolve the current holder at accept and abort if it is no longer the requester. Adds regression test + seeds the holder in existing accept tests. * fix: complete IAM least-privilege sweep (sh-security-review) HolidayRouter secrets scope afterhours-shift-manager/* -> /3cx-* (reads only 3cx secrets); RingScheduler DynamoDBCrudPolicy -> DynamoDBReadPolicy (read-only at runtime). SlackBot wildcard left as-is (reads across all sub-prefixes; verified defensible).
2026-06-18 12:05:25 -04:00
def test_aborts_if_requester_no_longer_holder(
self, slackbot_app, schedule, seed, respond, client, routing_spy
):
# RIHB-1 regression: requester held via weekly, swap pending to Bob,
# then the holder changes to Carol (e.g. weekly edit / admin reassign)
# before Bob accepts. The accept must NOT steal the shift from Carol.
seed.weekly("Monday", "114", "Alice")
_pending(schedule, "2026-06-01")
# Holder is now Carol (116), not the requester Alice (114).
seed.weekly("Monday", "116", "Carol")
slackbot_app.handle_swap_accept(
_accept_body("2026-06-01"), respond, client, schedule, "C_TEST"
)
# No override written to Bob; the swap is cleared and 3CX untouched.
assert schedule.get_override("2026-06-01") is None
assert "no longer assigned" in _resolved_text(respond).lower()
routing_spy.assert_not_called()
@freeze_time(MON)
def test_weekend_day_suffix(self, slackbot_app, schedule, seed, respond, client):
seed.weekly("Saturday", "114", "Alice", shift_type="day")
_pending(schedule, "2026-06-06", shift_type="day")
slackbot_app.handle_swap_accept(
_accept_body("2026-06-06", suffix="_day"),
respond,
client,
schedule,
"C_TEST",
)
assert schedule.get_override("2026-06-06", "day")["extension"] == "115"
assert schedule.get_swap("2026-06-06", "day")["status"] == "verified"
class TestDecline:
@freeze_time(MON)
def test_clears_and_dms_requester(self, slackbot_app, schedule, respond, client):
_pending(schedule, "2026-06-01")
slackbot_app.handle_swap_decline(
_decline_body("2026-06-01"), respond, client, schedule, "C_TEST"
)
assert schedule.get_swap("2026-06-01") is None
assert schedule.get_override("2026-06-01") is None # never applied
assert "declined" in _resolved_text(respond).lower()
assert "U_ALICE" in _channels(client) # requester notified
@freeze_time(MON)
def test_wrong_clicker_rejected(self, slackbot_app, schedule, respond, client):
_pending(schedule, "2026-06-01")
slackbot_app.handle_swap_decline(
_decline_body("2026-06-01", user_id="U_STRANGER"),
respond,
client,
schedule,
"C_TEST",
)
assert "no longer valid" in _resolved_text(respond).lower()
assert schedule.get_swap("2026-06-01") is not None # untouched
@freeze_time(MON)
def test_no_pending_swap(self, slackbot_app, schedule, respond, client):
slackbot_app.handle_swap_decline(
_decline_body("2026-06-01"), respond, client, schedule, "C_TEST"
)
assert "no longer valid" in _resolved_text(respond).lower()