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