afterhours-shift-manager/tests/slack_bot/test_handle_register_rate.py
Adam Moussa 43bfbf34c2 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
2026-06-18 11:55:57 -04:00

92 lines
4.4 KiB
Python

"""Tests for slack-bot _handle_register and _handle_rate."""
class TestRegister:
def test_register_links_account(
self, slackbot_app, schedule, seed, respond, text_of
):
seed.roster("114", "Alice")
slackbot_app._handle_register(respond, schedule, "U_ALICE", "register 114")
assert "Linked" in text_of(respond)
assert schedule.get_employee_by_extension("114")["slack_user_id"] == "U_ALICE"
def test_register_unknown_extension(self, slackbot_app, schedule, respond, text_of):
slackbot_app._handle_register(respond, schedule, "U_ALICE", "register 999")
assert "not found" in text_of(respond).lower()
def test_register_usage(self, slackbot_app, schedule, respond, text_of):
slackbot_app._handle_register(respond, schedule, "U_ALICE", "register")
assert "Usage" in text_of(respond)
def test_register_reregister_self_is_idempotent(
self, slackbot_app, schedule, seed, respond, text_of
):
seed.roster("114", "Alice")
slackbot_app._handle_register(respond, schedule, "U_ALICE", "register 114")
slackbot_app._handle_register(respond, schedule, "U_ALICE", "register 114")
assert "Linked" in text_of(respond)
assert schedule.get_employee_by_extension("114")["slack_user_id"] == "U_ALICE"
def test_register_cannot_hijack_others_extension(
self, slackbot_app, schedule, seed, respond, text_of
):
seed.roster("114", "Alice")
# Alice claims her extension first.
slackbot_app._handle_register(respond, schedule, "U_ALICE", "register 114")
# Mallory tries to claim Alice's extension — must be rejected.
slackbot_app._handle_register(respond, schedule, "U_MALLORY", "register 114")
assert "already registered" in text_of(respond).lower()
# The binding must still point at Alice, not Mallory.
assert schedule.get_employee_by_extension("114")["slack_user_id"] == "U_ALICE"
class TestRate:
def test_non_admin_rejected(self, slackbot_app, schedule, seed, respond, text_of):
seed.roster("114", "Alice")
slackbot_app._handle_rate(respond, schedule, "rate 114 90", False)
assert "restricted" in text_of(respond).lower()
# The rate must not have been changed by a non-admin.
assert schedule.get_shift_rate("114") == 0.0
def test_show_rates(self, slackbot_app, schedule, seed, respond, text_of):
seed.config(shift_rate="50")
seed.roster("114", "Alice", shift_rate="75")
slackbot_app._handle_rate(respond, schedule, "rate", True)
text = text_of(respond)
assert "$50.00" in text and "Alice" in text and "$75.00" in text
def test_show_rates_no_custom(self, slackbot_app, schedule, seed, respond, text_of):
seed.config(shift_rate="50")
slackbot_app._handle_rate(respond, schedule, "rate", True)
assert "No per-person rates" in text_of(respond)
def test_set_default(self, slackbot_app, schedule, seed, respond, text_of):
seed.config(shift_rate="50")
slackbot_app._handle_rate(respond, schedule, "rate default 60", True)
assert schedule.get_shift_rate() == 60.0
assert "$60.00" in text_of(respond)
def test_set_default_invalid_amount(self, slackbot_app, schedule, respond, text_of):
slackbot_app._handle_rate(respond, schedule, "rate default abc", True)
assert "Invalid amount" in text_of(respond)
def test_set_default_usage(self, slackbot_app, schedule, respond, text_of):
slackbot_app._handle_rate(respond, schedule, "rate default", True)
assert "Usage" in text_of(respond)
def test_set_per_employee(self, slackbot_app, schedule, seed, respond, text_of):
seed.roster("114", "Alice")
slackbot_app._handle_rate(respond, schedule, "rate 114 90", True)
assert schedule.get_shift_rate("114") == 90.0
assert "Alice" in text_of(respond) and "$90.00" in text_of(respond)
def test_set_per_employee_unknown(self, slackbot_app, schedule, respond, text_of):
slackbot_app._handle_rate(respond, schedule, "rate 999 90", True)
assert "not found" in text_of(respond).lower()
def test_set_per_employee_strips_dollar_sign(
self, slackbot_app, schedule, seed, respond
):
seed.roster("114", "Alice")
slackbot_app._handle_rate(respond, schedule, "rate 114 $90", True)
assert schedule.get_shift_rate("114") == 90.0