| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266 |
- import datetime
- import uuid
- from datetime import timedelta
- import pytest
- from httpx import AsyncClient
- from sqlalchemy.orm import Session
- from app.core.config import settings
- from app.main import app
- from app.models import GlobalRole, OrgRole, Project, ServerStatus, Sms
- from app.schemas.responses import EnumServerStatus
- pytestmark = pytest.mark.asyncio
- @pytest.fixture
- def super_admin_headers(make_org_user) -> dict:
- """Fixture to get headers for a user with SUPER_ADMIN global role."""
- _, headers = make_org_user(role=OrgRole.ORG_ADMIN, global_role=GlobalRole.SUPER_ADMIN)
- return headers
- @pytest.fixture
- def standard_user_headers(make_org_user) -> dict:
- """Fixture to get headers for a standard user."""
- _, headers = make_org_user(role=OrgRole.ORG_ADMIN, global_role=GlobalRole.USER)
- return headers
- @pytest.fixture
- def sms_records(session: Session, default_project: Project) -> dict[str, Sms]:
- """Creates a matrix of SMS records to test various time and state conditions."""
- now = datetime.datetime.now()
- # Ready to send (within the default 10 min window)
- sms_to_send = Sms(
- project_id=default_project.id,
- content="to send",
- phone_number="0600000001",
- sending_time=now - timedelta(minutes=5),
- )
- # Too old (missed the default 10 min window)
- sms_too_old = Sms(
- project_id=default_project.id,
- content="too old",
- phone_number="0600000002",
- sending_time=now - timedelta(minutes=15),
- )
- # Scheduled for the future
- sms_future = Sms(
- project_id=default_project.id,
- content="future",
- phone_number="0600000003",
- sending_time=now + timedelta(minutes=10),
- )
- # 4. Already sent
- sms_sent = Sms(
- project_id=default_project.id,
- content="sent",
- phone_number="0600000004",
- sending_time=now - timedelta(minutes=5),
- send_time=now - timedelta(minutes=1),
- )
- session.add_all([sms_to_send, sms_too_old, sms_future, sms_sent])
- session.commit()
- session.refresh(sms_to_send)
- session.refresh(sms_too_old)
- session.refresh(sms_future)
- session.refresh(sms_sent)
- return {
- "to_send": sms_to_send,
- "too_old": sms_too_old,
- "future": sms_future,
- "sent": sms_sent,
- }
- class TestSmsSenderSecurity:
- """Ensure all endpoints are strictly protected by require_super_admin"""
- @pytest.fixture(autouse=True)
- def setup_routes(self, sms_records):
- sms_id = sms_records["to_send"].id
- self.routes = [
- ("GET", "list_sms_to_send", {}, None),
- ("POST", "send_sms_now", {"sms_id": sms_id}, None),
- ("GET", "list_not_sent", {}, None),
- ("GET", "list_future_sms", {}, None),
- ("POST", "update_status", {}, None),
- ("GET", "get_status", {}, None),
- ]
- async def test_rejects_unauthenticated(self, client: AsyncClient):
- for method, endpoint, kwargs, payload in self.routes:
- url = app.url_path_for(endpoint, **kwargs)
- req = getattr(client, method.lower())
- response = await req(url, json=payload) if payload else await req(url)
- assert response.status_code == 401
- async def test_rejects_standard_user(self, client: AsyncClient, standard_user_headers):
- for method, endpoint, kwargs, payload in self.routes:
- url = app.url_path_for(endpoint, **kwargs)
- req = getattr(client, method.lower())
- response = (
- await req(url, json=payload, headers=standard_user_headers)
- if payload
- else await req(url, headers=standard_user_headers)
- )
- # require_super_admin usually throws 403
- assert response.status_code == 403
- class TestListSmsToSend:
- async def test_default_delay_window(
- self, client: AsyncClient, super_admin_headers, sms_records
- ):
- """Should only return SMS scheduled in the past 10 minutes (default)."""
- response = await client.get(
- app.url_path_for("list_sms_to_send"), headers=super_admin_headers
- )
- assert response.status_code == 200
- data = response.json()
- assert len(data) == 1
- assert data[0]["id"] == str(sms_records["to_send"].id)
- async def test_custom_max_delay(self, client: AsyncClient, super_admin_headers, sms_records):
- """Increasing max_delay to 20 mins should include the 'too_old' SMS."""
- response = await client.get(
- f"{app.url_path_for('list_sms_to_send')}?max_delay=20", headers=super_admin_headers
- )
- assert response.status_code == 200
- data = response.json()
- assert len(data) == 2
- ids = [sms["id"] for sms in data]
- assert str(sms_records["to_send"].id) in ids
- assert str(sms_records["too_old"].id) in ids
- class TestSendSmsNow:
- async def test_send_success_and_updates_status(
- self, client: AsyncClient, super_admin_headers, sms_records, session: Session
- ):
- sms_id = sms_records["to_send"].id
- # Ensure status is empty initially
- assert session.query(ServerStatus).first() is None
- response = await client.post(
- app.url_path_for("send_sms_now", sms_id=sms_id),
- headers=super_admin_headers,
- )
- assert response.status_code == 200
- assert response.json()["send_time"] is not None
- # Verify DB is updated
- session.refresh(sms_records["to_send"])
- assert sms_records["to_send"].send_time is not None
- # Verify ServerStatus was updated implicitly by the router
- status = session.query(ServerStatus).first()
- assert status is not None
- assert status.host == "127.0.0.1" # HTTPX default test client host
- async def test_already_sent_returns_400(
- self, client: AsyncClient, super_admin_headers, sms_records
- ):
- response = await client.post(
- app.url_path_for("send_sms_now", sms_id=sms_records["sent"].id),
- headers=super_admin_headers,
- )
- assert response.status_code == 400
- assert response.json()["detail"] == "SMS has already been sent"
- async def test_not_found_returns_404(self, client: AsyncClient, super_admin_headers):
- response = await client.post(
- app.url_path_for("send_sms_now", sms_id=uuid.uuid4()),
- headers=super_admin_headers,
- )
- assert response.status_code == 404
- class TestListNotSent:
- async def test_returns_all_unsent(self, client: AsyncClient, super_admin_headers, sms_records):
- response = await client.get(app.url_path_for("list_not_sent"), headers=super_admin_headers)
- assert response.status_code == 200
- data = response.json()
- # Should include to_send, too_old, and future (everything where send_time is None)
- assert len(data) == 3
- ids = [sms["id"] for sms in data]
- assert str(sms_records["sent"].id) not in ids
- class TestListFutureSms:
- async def test_returns_only_future(self, client: AsyncClient, super_admin_headers, sms_records):
- response = await client.get(
- app.url_path_for("list_future_sms"), headers=super_admin_headers
- )
- assert response.status_code == 200
- data = response.json()
- # Should only include the future SMS
- assert len(data) == 1
- assert data[0]["id"] == str(sms_records["future"].id)
- class TestServerStatus:
- async def test_post_status_creates_new(
- self, client: AsyncClient, super_admin_headers, session: Session
- ):
- response = await client.post(
- app.url_path_for("update_status"),
- headers={**super_admin_headers, "user-agent": "test-agent"},
- )
- assert response.status_code == 200
- status = session.query(ServerStatus).first()
- assert status.id == 1
- assert status.user_agent == "test-agent"
- async def test_get_status_invalid_if_empty(self, client: AsyncClient, super_admin_headers):
- """If the server has never pinged, it should return INVALID."""
- response = await client.get(app.url_path_for("get_status"), headers=super_admin_headers)
- assert response.status_code == 200
- assert response.json()["message"] == EnumServerStatus.INVALID.value
- async def test_get_status_active(
- self, client: AsyncClient, super_admin_headers, session: Session
- ):
- """Should be ACTIVE if pinged recently."""
- status = ServerStatus(
- id=1,
- user_agent="python",
- host="127.0.0.1",
- updated_at=datetime.datetime.now(datetime.UTC),
- )
- session.add(status)
- session.commit()
- response = await client.get(app.url_path_for("get_status"), headers=super_admin_headers)
- assert response.status_code == 200
- assert response.json()["message"] == EnumServerStatus.ACTIVE.value
- async def test_get_status_inactive(
- self, client: AsyncClient, super_admin_headers, session: Session
- ):
- """Should be INACTIVE if time since last ping exceeds settings threshold."""
- old_time = datetime.datetime.now(datetime.UTC) - timedelta(
- seconds=settings.INACTIVITY_SMS_SENDER_THRESHOLD_SECONDS + 10
- )
- status = ServerStatus(id=1, user_agent="python", host="127.0.0.1", updated_at=old_time)
- session.add(status)
- session.commit()
- response = await client.get(app.url_path_for("get_status"), headers=super_admin_headers)
- assert response.status_code == 200
- assert response.json()["message"] == EnumServerStatus.INACTIVE.value
|