|
|
@@ -0,0 +1,266 @@
|
|
|
+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
|