test_sms_sender.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import datetime
  2. import uuid
  3. from datetime import timedelta
  4. import pytest
  5. from httpx import AsyncClient
  6. from sqlalchemy.orm import Session
  7. from app.core.config import settings
  8. from app.main import app
  9. from app.models import GlobalRole, OrgRole, Project, ServerStatus, Sms
  10. from app.schemas.responses import EnumServerStatus
  11. pytestmark = pytest.mark.asyncio
  12. @pytest.fixture
  13. def super_admin_headers(make_org_user) -> dict:
  14. """Fixture to get headers for a user with SUPER_ADMIN global role."""
  15. _, headers = make_org_user(role=OrgRole.ORG_ADMIN, global_role=GlobalRole.SUPER_ADMIN)
  16. return headers
  17. @pytest.fixture
  18. def standard_user_headers(make_org_user) -> dict:
  19. """Fixture to get headers for a standard user."""
  20. _, headers = make_org_user(role=OrgRole.ORG_ADMIN, global_role=GlobalRole.USER)
  21. return headers
  22. @pytest.fixture
  23. def sms_records(session: Session, default_project: Project) -> dict[str, Sms]:
  24. """Creates a matrix of SMS records to test various time and state conditions."""
  25. now = datetime.datetime.now()
  26. # Ready to send (within the default 10 min window)
  27. sms_to_send = Sms(
  28. project_id=default_project.id,
  29. content="to send",
  30. phone_number="0600000001",
  31. sending_time=now - timedelta(minutes=5),
  32. )
  33. # Too old (missed the default 10 min window)
  34. sms_too_old = Sms(
  35. project_id=default_project.id,
  36. content="too old",
  37. phone_number="0600000002",
  38. sending_time=now - timedelta(minutes=15),
  39. )
  40. # Scheduled for the future
  41. sms_future = Sms(
  42. project_id=default_project.id,
  43. content="future",
  44. phone_number="0600000003",
  45. sending_time=now + timedelta(minutes=10),
  46. )
  47. # 4. Already sent
  48. sms_sent = Sms(
  49. project_id=default_project.id,
  50. content="sent",
  51. phone_number="0600000004",
  52. sending_time=now - timedelta(minutes=5),
  53. send_time=now - timedelta(minutes=1),
  54. )
  55. session.add_all([sms_to_send, sms_too_old, sms_future, sms_sent])
  56. session.commit()
  57. session.refresh(sms_to_send)
  58. session.refresh(sms_too_old)
  59. session.refresh(sms_future)
  60. session.refresh(sms_sent)
  61. return {
  62. "to_send": sms_to_send,
  63. "too_old": sms_too_old,
  64. "future": sms_future,
  65. "sent": sms_sent,
  66. }
  67. class TestSmsSenderSecurity:
  68. """Ensure all endpoints are strictly protected by require_super_admin"""
  69. @pytest.fixture(autouse=True)
  70. def setup_routes(self, sms_records):
  71. sms_id = sms_records["to_send"].id
  72. self.routes = [
  73. ("GET", "list_sms_to_send", {}, None),
  74. ("POST", "send_sms_now", {"sms_id": sms_id}, None),
  75. ("GET", "list_not_sent", {}, None),
  76. ("GET", "list_future_sms", {}, None),
  77. ("POST", "update_status", {}, None),
  78. ("GET", "get_status", {}, None),
  79. ]
  80. async def test_rejects_unauthenticated(self, client: AsyncClient):
  81. for method, endpoint, kwargs, payload in self.routes:
  82. url = app.url_path_for(endpoint, **kwargs)
  83. req = getattr(client, method.lower())
  84. response = await req(url, json=payload) if payload else await req(url)
  85. assert response.status_code == 401
  86. async def test_rejects_standard_user(self, client: AsyncClient, standard_user_headers):
  87. for method, endpoint, kwargs, payload in self.routes:
  88. url = app.url_path_for(endpoint, **kwargs)
  89. req = getattr(client, method.lower())
  90. response = (
  91. await req(url, json=payload, headers=standard_user_headers)
  92. if payload
  93. else await req(url, headers=standard_user_headers)
  94. )
  95. # require_super_admin usually throws 403
  96. assert response.status_code == 403
  97. class TestListSmsToSend:
  98. async def test_default_delay_window(
  99. self, client: AsyncClient, super_admin_headers, sms_records
  100. ):
  101. """Should only return SMS scheduled in the past 10 minutes (default)."""
  102. response = await client.get(
  103. app.url_path_for("list_sms_to_send"), headers=super_admin_headers
  104. )
  105. assert response.status_code == 200
  106. data = response.json()
  107. assert len(data) == 1
  108. assert data[0]["id"] == str(sms_records["to_send"].id)
  109. async def test_custom_max_delay(self, client: AsyncClient, super_admin_headers, sms_records):
  110. """Increasing max_delay to 20 mins should include the 'too_old' SMS."""
  111. response = await client.get(
  112. f"{app.url_path_for('list_sms_to_send')}?max_delay=20", headers=super_admin_headers
  113. )
  114. assert response.status_code == 200
  115. data = response.json()
  116. assert len(data) == 2
  117. ids = [sms["id"] for sms in data]
  118. assert str(sms_records["to_send"].id) in ids
  119. assert str(sms_records["too_old"].id) in ids
  120. class TestSendSmsNow:
  121. async def test_send_success_and_updates_status(
  122. self, client: AsyncClient, super_admin_headers, sms_records, session: Session
  123. ):
  124. sms_id = sms_records["to_send"].id
  125. # Ensure status is empty initially
  126. assert session.query(ServerStatus).first() is None
  127. response = await client.post(
  128. app.url_path_for("send_sms_now", sms_id=sms_id),
  129. headers=super_admin_headers,
  130. )
  131. assert response.status_code == 200
  132. assert response.json()["send_time"] is not None
  133. # Verify DB is updated
  134. session.refresh(sms_records["to_send"])
  135. assert sms_records["to_send"].send_time is not None
  136. # Verify ServerStatus was updated implicitly by the router
  137. status = session.query(ServerStatus).first()
  138. assert status is not None
  139. assert status.host == "127.0.0.1" # HTTPX default test client host
  140. async def test_already_sent_returns_400(
  141. self, client: AsyncClient, super_admin_headers, sms_records
  142. ):
  143. response = await client.post(
  144. app.url_path_for("send_sms_now", sms_id=sms_records["sent"].id),
  145. headers=super_admin_headers,
  146. )
  147. assert response.status_code == 400
  148. assert response.json()["detail"] == "SMS has already been sent"
  149. async def test_not_found_returns_404(self, client: AsyncClient, super_admin_headers):
  150. response = await client.post(
  151. app.url_path_for("send_sms_now", sms_id=uuid.uuid4()),
  152. headers=super_admin_headers,
  153. )
  154. assert response.status_code == 404
  155. class TestListNotSent:
  156. async def test_returns_all_unsent(self, client: AsyncClient, super_admin_headers, sms_records):
  157. response = await client.get(app.url_path_for("list_not_sent"), headers=super_admin_headers)
  158. assert response.status_code == 200
  159. data = response.json()
  160. # Should include to_send, too_old, and future (everything where send_time is None)
  161. assert len(data) == 3
  162. ids = [sms["id"] for sms in data]
  163. assert str(sms_records["sent"].id) not in ids
  164. class TestListFutureSms:
  165. async def test_returns_only_future(self, client: AsyncClient, super_admin_headers, sms_records):
  166. response = await client.get(
  167. app.url_path_for("list_future_sms"), headers=super_admin_headers
  168. )
  169. assert response.status_code == 200
  170. data = response.json()
  171. # Should only include the future SMS
  172. assert len(data) == 1
  173. assert data[0]["id"] == str(sms_records["future"].id)
  174. class TestServerStatus:
  175. async def test_post_status_creates_new(
  176. self, client: AsyncClient, super_admin_headers, session: Session
  177. ):
  178. response = await client.post(
  179. app.url_path_for("update_status"),
  180. headers={**super_admin_headers, "user-agent": "test-agent"},
  181. )
  182. assert response.status_code == 200
  183. status = session.query(ServerStatus).first()
  184. assert status.id == 1
  185. assert status.user_agent == "test-agent"
  186. async def test_get_status_invalid_if_empty(self, client: AsyncClient, super_admin_headers):
  187. """If the server has never pinged, it should return INVALID."""
  188. response = await client.get(app.url_path_for("get_status"), headers=super_admin_headers)
  189. assert response.status_code == 200
  190. assert response.json()["message"] == EnumServerStatus.INVALID.value
  191. async def test_get_status_active(
  192. self, client: AsyncClient, super_admin_headers, session: Session
  193. ):
  194. """Should be ACTIVE if pinged recently."""
  195. status = ServerStatus(
  196. id=1,
  197. user_agent="python",
  198. host="127.0.0.1",
  199. updated_at=datetime.datetime.now(datetime.UTC),
  200. )
  201. session.add(status)
  202. session.commit()
  203. response = await client.get(app.url_path_for("get_status"), headers=super_admin_headers)
  204. assert response.status_code == 200
  205. assert response.json()["message"] == EnumServerStatus.ACTIVE.value
  206. async def test_get_status_inactive(
  207. self, client: AsyncClient, super_admin_headers, session: Session
  208. ):
  209. """Should be INACTIVE if time since last ping exceeds settings threshold."""
  210. old_time = datetime.datetime.now(datetime.UTC) - timedelta(
  211. seconds=settings.INACTIVITY_SMS_SENDER_THRESHOLD_SECONDS + 10
  212. )
  213. status = ServerStatus(id=1, user_agent="python", host="127.0.0.1", updated_at=old_time)
  214. session.add(status)
  215. session.commit()
  216. response = await client.get(app.url_path_for("get_status"), headers=super_admin_headers)
  217. assert response.status_code == 200
  218. assert response.json()["message"] == EnumServerStatus.INACTIVE.value