Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions docs/api/gsm-sms.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The GSM endpoints proxy SMS operations from the SAPOT server to the GSM module (
| GET | `/gsm/health` | JWT Bearer | Check GSM module availability. |
| GET | `/gsm/health/detailed` | JWT Bearer | Detailed GSM module health/diagnostics. |
| GET | `/gsm/sms/messages` | JWT Bearer | List recent SMS messages seen by the module. |
| POST | `/gsm/sms/send` | JWT Bearer | Send an SMS to a phone number. |
| POST | `/gsm/sms/send` | JWT Bearer + verified phone | Send an SMS to a phone number. |
| POST | `/gsm/request` | None | Initiate an SMS OTP for a phone number (registration/verification). |
| POST | `/gsm/verify` | None | Verify an SMS OTP code. |
| POST | `/gsm/resend` | None | Resend an SMS OTP. |
Expand All @@ -25,7 +25,7 @@ The GSM endpoints proxy SMS operations from the SAPOT server to the GSM module (
| GET | `/gsm/mock/health` | JWT Bearer | Mock variant of `/gsm/health`. |
| GET | `/gsm/mock/health/detailed` | JWT Bearer | Mock variant of `/gsm/health/detailed`. |
| GET | `/gsm/mock/sms/messages` | JWT Bearer | Mock variant of `/gsm/sms/messages`. |
| POST | `/gsm/mock/sms/send` | JWT Bearer | Mock variant of `/gsm/sms/send`. |
| POST | `/gsm/mock/sms/send` | JWT Bearer + verified phone | Mock variant of `/gsm/sms/send`. |
| POST | `/gsm/mock/request` | None | Mock variant of `/gsm/request`. |
| POST | `/gsm/mock/verify` | None | Mock variant of `/gsm/verify`. |
| POST | `/gsm/mock/resend` | None | Mock variant of `/gsm/resend`. |
Expand All @@ -43,6 +43,19 @@ Webhook endpoint the GSM hardware gateway calls when it receives an SMS. Protect

The GSM module's own standalone hardware-facing API is documented in [`docs/deployment/gsm-module.md`](../deployment/gsm-module.md). Its `POST /sms/send` route requires the same `X-GSM-Secret` shared secret that protects the main server's inbound webhook.

## Outbound sender eligibility

`POST /gsm/sms/send` and its mock variant require a `PhoneVerified` record for the authenticated account. The server checks this before looking up the recipient or contacting the GSM gateway. An unverified account receives HTTP 403:

```json
{
"detail": {
"reason": "PHONE_VERIFICATION_REQUIRED",
"message": "Verify your phone number before sending SMS."
}
}
```

## Gateway failure contract

The main server preserves synchronous gateway failures for `/gsm/sms/send`, `/gsm/request`, `/gsm/resend`, and `/gsm/contact-unknown-user`. Queue saturation returns HTTP 503:
Expand Down
12 changes: 12 additions & 0 deletions docs/api/openapi/gsm-sms.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,12 @@ paths:
schema: {}
'404':
description: Not Found
'403':
description: The sending account does not have a verified phone number.
content:
application/json:
schema:
$ref: '#/components/schemas/GsmFailureResponse'
'422':
description: Validation Error
content:
Expand Down Expand Up @@ -592,6 +598,12 @@ paths:
- $ref: '#/components/schemas/GsmFailureResponse'
- $ref: '#/components/schemas/GsmHealthUnavailableResponse'
title: Response 503 Send Sms Gsm Sms Send Post
'403':
description: The sending account does not have a verified phone number.
content:
application/json:
schema:
$ref: '#/components/schemas/GsmFailureResponse'
'422':
description: Validation Error
content:
Expand Down
2 changes: 1 addition & 1 deletion docs/features/sms-gateway/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ sequenceDiagram
GSM->>Main: POST /gsm/inbound with X-GSM-Secret
```

The main server authenticates the user-facing `/gsm/sms/send` route with a JSON Web Token (JWT). It authenticates its direct gateway call with the same shared `GSM_SECRET` used for callbacks. The GSM service validates `X-GSM-Secret` before logging or queueing a send, which prevents another container on the internal network from occupying the serial modem.
The main server authenticates the user-facing `/gsm/sms/send` route with a JSON Web Token (JWT) and requires the sender to have a verified phone number. It rejects an unverified sender before contacting the gateway. The main server authenticates its direct gateway call with the same shared `GSM_SECRET` used for callbacks. The GSM service validates `X-GSM-Secret` before logging or queueing a send, which prevents another container on the internal network from occupying the serial modem.

## How does outbound admission work?

Expand Down
3 changes: 2 additions & 1 deletion docs/features/sms-gateway/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ Only one request may await a modem confirmation. A confirmation received before

### FR-SG-08: Main server integration

- The user-facing `/gsm/sms/send` route remains on the main server and requires its normal JWT authentication.
- The user-facing `/gsm/sms/send` route remains on the main server and requires its normal JWT authentication and a verified phone number for the sending account.
- An authenticated account without a verified phone number receives HTTP 403 with `reason: "PHONE_VERIFICATION_REQUIRED"` before the main server calls the GSM service.
- The main server calls the direct gateway at `http://localhost:8001/sms/send` with `X-GSM-Secret`.
- The direct gateway is a trusted local service and must not be exposed to untrusted networks.
- The main server must preserve gateway HTTP 502 and 503 failures for user-facing send, verification, resend, and first-contact requests.
Expand Down
13 changes: 12 additions & 1 deletion mobile-app/sapot-mobile-app/docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,8 @@ Failure is non-fatal — the client logs and retries on next login.
---

### `POST /user-utils/search-user` — Search Users
**Auth:** Required
**Auth:** Required; the authenticated account must have a verified phone number

**Query params:** `identifier_string=<string>&limit=<number=20>&offset=<number=0>`

**Response `200`:**
Expand Down Expand Up @@ -864,6 +865,16 @@ independently — surface them separately rather than collapsing to one "offline
{ "msg_id": "string", "ok": "boolean", "to": "string" }
```

**Response `403` when the sender's phone number is not verified:**
```json
{
"detail": {
"reason": "PHONE_VERIFICATION_REQUIRED",
"message": "Verify your phone number before sending SMS."
}
}
```

**Response `503` when the outbound queue is full:**
```json
{
Expand Down
32 changes: 30 additions & 2 deletions server/app/api/gsm.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ class GsmHealthUnavailableResponse(BaseModel):
},
}

GSM_PHONE_VERIFICATION_ERROR_RESPONSES = {
403: {
"model": GsmFailureResponse,
"description": "The sending account does not have a verified phone number.",
},
}

GSM_SMS_SEND_ERROR_RESPONSES = {
**GSM_SEND_ERROR_RESPONSES,
**GSM_PHONE_VERIFICATION_ERROR_RESPONSES,
}

GSM_HEALTH_ERROR_RESPONSES = {
503: {
"model": GsmHealthResponse | GsmHealthUnavailableResponse,
Expand Down Expand Up @@ -295,7 +307,19 @@ async def gsm_messages(
response = await client.get("/sms/messages", params=params)
return response.json()

@router.post("/sms/send", responses=GSM_SEND_ERROR_RESPONSES)

def _require_verified_phone(user: User) -> None:
if not user.phone_is_verified:
raise HTTPException(
status_code=403,
detail={
"reason": "PHONE_VERIFICATION_REQUIRED",
"message": "Verify your phone number before sending SMS.",
},
)


@router.post("/sms/send", responses=GSM_SMS_SEND_ERROR_RESPONSES)
async def send_sms(
current_user : Annotated[User, Depends(get_current_user)],
user_id: UUID,
Expand All @@ -306,6 +330,8 @@ async def send_sms(
if current_user.banned:
raise HTTPException(403)

_require_verified_phone(current_user)

target = session.get(User, user_id)

if not target:
Expand Down Expand Up @@ -769,7 +795,7 @@ async def MOCK_gsm_messages(
"""Admin only"""
return "Admin only!"

@router.post("/mock/sms/send")
@router.post("/mock/sms/send", responses=GSM_PHONE_VERIFICATION_ERROR_RESPONSES)
async def MOCK_send_sms(
current_user : Annotated[User, Depends(get_current_user)],
user_id: UUID,
Expand All @@ -780,6 +806,8 @@ async def MOCK_send_sms(
if current_user.banned:
raise HTTPException(403)

_require_verified_phone(current_user)

target = session.get(User, user_id)

if not target:
Expand Down
61 changes: 53 additions & 8 deletions server/app/tests/test_gsm_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from app.api import gsm
from app.db_operations.token import get_current_user
from app.main import app
from app.models.phone_verification import PhoneVerification, now_ms
from app.models.phone_verification import PhoneVerification, PhoneVerified, now_ms
from app.models.users import User
from sqlmodel import select

Expand Down Expand Up @@ -65,8 +65,13 @@ async def post(self, path: str, json: dict, **kwargs):
raise httpx.PoolTimeout("GSM proxy connection pool is full")


def _authenticated_user(session):
return session.exec(select(User)).first()
def _authenticated_user(session, *, phone_verified=False):
user = session.exec(select(User)).first()
if phone_verified:
session.add(PhoneVerified(user_id=user.id))
session.commit()
session.refresh(user)
return user


def test_proxy_capacity_and_timeouts_cover_gateway_contract(monkeypatch):
Expand Down Expand Up @@ -111,7 +116,7 @@ async def post(self, path: str, **kwargs):


def test_send_sms_preserves_queue_full_status(client, session, monkeypatch):
current_user = _authenticated_user(session)
current_user = _authenticated_user(session, phone_verified=True)
target = session.exec(select(User).where(User.id != current_user.id)).first()
monkeypatch.setattr(gsm, "_get_gsm_client", lambda: SaturatedGsmClient())
monkeypatch.setitem(app.dependency_overrides, get_current_user, lambda: current_user)
Expand All @@ -126,7 +131,7 @@ def test_send_sms_preserves_queue_full_status(client, session, monkeypatch):


def test_send_sms_preserves_service_stopping_status(client, session, monkeypatch):
current_user = _authenticated_user(session)
current_user = _authenticated_user(session, phone_verified=True)
target = session.exec(select(User).where(User.id != current_user.id)).first()
monkeypatch.setattr(gsm, "_get_gsm_client", lambda: StoppingGsmClient())
monkeypatch.setitem(app.dependency_overrides, get_current_user, lambda: current_user)
Expand Down Expand Up @@ -191,7 +196,7 @@ def test_contact_unknown_user_preserves_queue_full_status(client, session, monke


def test_send_sms_reports_unavailable_gateway(client, session, monkeypatch):
current_user = _authenticated_user(session)
current_user = _authenticated_user(session, phone_verified=True)
target = session.exec(select(User).where(User.id != current_user.id)).first()
monkeypatch.setattr(gsm, "_get_gsm_client", lambda: UnavailableGsmClient())
monkeypatch.setitem(app.dependency_overrides, get_current_user, lambda: current_user)
Expand All @@ -211,7 +216,7 @@ def test_send_sms_reports_unavailable_gateway(client, session, monkeypatch):


def test_send_sms_preserves_modem_not_ready_status(client, session, monkeypatch):
current_user = _authenticated_user(session)
current_user = _authenticated_user(session, phone_verified=True)
target = session.exec(select(User).where(User.id != current_user.id)).first()
monkeypatch.setattr(gsm, "_get_gsm_client", lambda: ModemNotReadyGsmClient())
monkeypatch.setitem(app.dependency_overrides, get_current_user, lambda: current_user)
Expand All @@ -228,7 +233,7 @@ def test_send_sms_preserves_modem_not_ready_status(client, session, monkeypatch)
def test_send_sms_rejects_proxy_pool_exhaustion_without_gateway_send(
client, session, monkeypatch
):
current_user = _authenticated_user(session)
current_user = _authenticated_user(session, phone_verified=True)
target = session.exec(select(User).where(User.id != current_user.id)).first()
monkeypatch.setattr(gsm, "_get_gsm_client", lambda: PoolExhaustedGsmClient())
monkeypatch.setitem(app.dependency_overrides, get_current_user, lambda: current_user)
Expand All @@ -245,3 +250,43 @@ def test_send_sms_rejects_proxy_pool_exhaustion_without_gateway_send(
"reason": "GATEWAY_UNAVAILABLE",
}
}


def test_send_sms_rejects_unverified_sender_without_gateway_send(
client, session, monkeypatch
):
current_user = _authenticated_user(session)
target = session.exec(select(User).where(User.id != current_user.id)).first()

def fail_if_gateway_client_is_requested():
raise AssertionError("Unverified sender reached the GSM gateway")

monkeypatch.setattr(gsm, "_get_gsm_client", fail_if_gateway_client_is_requested)
monkeypatch.setitem(app.dependency_overrides, get_current_user, lambda: current_user)

response = client.post(
"/gsm/sms/send",
params={"user_id": str(target.id), "message": "Help is on the way"},
)

assert response.status_code == 403
assert response.json() == {
"detail": {
"reason": "PHONE_VERIFICATION_REQUIRED",
"message": "Verify your phone number before sending SMS.",
}
}


def test_mock_send_sms_rejects_unverified_sender(client, session, monkeypatch):
current_user = _authenticated_user(session)
target = session.exec(select(User).where(User.id != current_user.id)).first()
monkeypatch.setitem(app.dependency_overrides, get_current_user, lambda: current_user)

response = client.post(
"/gsm/mock/sms/send",
params={"user_id": str(target.id), "message": "Help is on the way"},
)

assert response.status_code == 403
assert response.json()["detail"]["reason"] == "PHONE_VERIFICATION_REQUIRED"
Loading