Authentica OTP tests
This commit is contained in:
@@ -17,3 +17,4 @@ dist/
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
backend/tmp_authentica_request_id.txt
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ After migrations, you can seed demo data:
|
|||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- From project root with venv active: `venv/bin/python3 -m pytest` (run from `backend/` so `pytest.ini` is picked up)
|
- From project root with venv active: `venv/bin/python3 -m pytest` (run from `backend/` so `pytest.ini` is picked up)
|
||||||
|
- External provider tests are skipped by default; run explicitly when needed: `PYTEST_ADDOPTS='' venv/bin/python3 -m pytest -m external`
|
||||||
|
|
||||||
### Core API endpoints (current scaffold)
|
### Core API endpoints (current scaffold)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,24 @@
|
|||||||
|
|
||||||
## Near-Term Focus
|
## Near-Term Focus
|
||||||
- Hardening Authentica integration (timeouts, retries, async delivery) and aligning notification provider choices.
|
- Hardening Authentica integration (timeouts, retries, async delivery) and aligning notification provider choices.
|
||||||
|
|
||||||
|
**Authentica E2E**
|
||||||
|
Run the real Authentica OTP flow only when explicitly enabled.
|
||||||
|
|
||||||
|
Env vars (in `backend/.env` or shell):
|
||||||
|
- `AUTHENTICA_E2E=1`
|
||||||
|
- `AUTHENTICA_API_KEY=...`
|
||||||
|
- `AUTHENTICA_E2E_PHONE=...` (must receive OTP)
|
||||||
|
- `AUTHENTICA_E2E_CODE=...` (required; no interactive prompt)
|
||||||
|
|
||||||
|
Command:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
PYTEST_ADDOPTS='' python3 -m pytest apps/accounts/tests -m external
|
||||||
|
```
|
||||||
|
|
||||||
|
Suggested flow:
|
||||||
|
1. Trigger the E2E test to send the OTP, then set `AUTHENTICA_E2E_CODE` and re-run if needed.
|
||||||
- Decide and document payment lifecycle scope (capture/refund supported vs explicitly out of scope).
|
- Decide and document payment lifecycle scope (capture/refund supported vs explicitly out of scope).
|
||||||
- Add timeouts/logging for external calls or introduce minimal async jobs for OTP/notifications.
|
- Add timeouts/logging for external calls or introduce minimal async jobs for OTP/notifications.
|
||||||
- Keep booking, payment, and notification orchestration in service layers, not views.
|
- Keep booking, payment, and notification orchestration in service layers, not views.
|
||||||
|
|||||||
@@ -154,6 +154,11 @@ class AuthenticaOtpProvider(BaseOtpProvider):
|
|||||||
data = {"detail": response.text}
|
data = {"detail": response.text}
|
||||||
|
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
|
if os.getenv("AUTHENTICA_DEBUG") == "1" or os.getenv("AUTHENTICA_E2E") == "1":
|
||||||
|
raise RuntimeError(
|
||||||
|
_("Authentica request failed: %(status)s %(body)s")
|
||||||
|
% {"status": response.status_code, "body": response.text}
|
||||||
|
)
|
||||||
raise RuntimeError(_("Authentica request failed"))
|
raise RuntimeError(_("Authentica request failed"))
|
||||||
|
|
||||||
return data
|
return data
|
||||||
@@ -166,7 +171,13 @@ class AuthenticaOtpProvider(BaseOtpProvider):
|
|||||||
|
|
||||||
def verify_otp(self, to_number: str, code: str) -> bool:
|
def verify_otp(self, to_number: str, code: str) -> bool:
|
||||||
data = self._post("/api/v2/verify-otp", {"phone": to_number, "otp": code})
|
data = self._post("/api/v2/verify-otp", {"phone": to_number, "otp": code})
|
||||||
return bool(data.get("verified"))
|
if "verified" in data:
|
||||||
|
verified = bool(data.get("verified"))
|
||||||
|
else:
|
||||||
|
verified = bool(data.get("status")) or data.get("message") == "OTP verified successfully"
|
||||||
|
if not verified and (os.getenv("AUTHENTICA_DEBUG") == "1" or os.getenv("AUTHENTICA_E2E") == "1"):
|
||||||
|
raise RuntimeError(_("Authentica verify failed: %(response)s") % {"response": data})
|
||||||
|
return verified
|
||||||
|
|
||||||
def send_sms(self, to_number: str, message: str) -> None:
|
def send_sms(self, to_number: str, message: str) -> None:
|
||||||
if not self.sender_name:
|
if not self.sender_name:
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Mocked end-to-end phone auth flow using Authentica OTP provider."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(OTP_PROVIDER="authentica")
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_phone_auth_flow_with_authentica_mock(mock_post, client):
|
||||||
|
def make_response(payload, ok=True):
|
||||||
|
response = MagicMock()
|
||||||
|
response.ok = ok
|
||||||
|
response.json.return_value = payload
|
||||||
|
response.text = ""
|
||||||
|
return response
|
||||||
|
|
||||||
|
def side_effect(url, headers=None, json=None, timeout=None):
|
||||||
|
assert headers and headers.get("X-Authorization") == "api-key"
|
||||||
|
assert timeout == 7.0
|
||||||
|
if url.endswith("/api/v2/send-otp"):
|
||||||
|
assert json == {"method": "sms", "phone": "+966512345678"}
|
||||||
|
return make_response({"success": True})
|
||||||
|
if url.endswith("/api/v2/verify-otp"):
|
||||||
|
if json == {"phone": "+966512345678", "otp": "123456"}:
|
||||||
|
return make_response({"verified": True})
|
||||||
|
return make_response({"verified": False})
|
||||||
|
raise AssertionError(f"Unexpected URL {url}")
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"AUTHENTICA_API_KEY": "api-key",
|
||||||
|
"AUTHENTICA_TIMEOUT_SECONDS": "7",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
mock_post.side_effect = side_effect
|
||||||
|
|
||||||
|
request_url = reverse("phone_auth_request")
|
||||||
|
verify_url = reverse("phone_auth_verify")
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
request_url,
|
||||||
|
{"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
request_id = response.json()["request_id"]
|
||||||
|
|
||||||
|
bad = client.post(
|
||||||
|
verify_url,
|
||||||
|
{"request_id": request_id, "code": "000000"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert bad.status_code == 400
|
||||||
|
|
||||||
|
good = client.post(
|
||||||
|
verify_url,
|
||||||
|
{"request_id": request_id, "code": "123456"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert good.status_code == 200
|
||||||
|
|
||||||
|
user = User.objects.filter(phone_number="+966512345678").first()
|
||||||
|
assert user is not None
|
||||||
|
assert user.is_phone_verified is True
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Real Authentica E2E OTP flow. Requires live credentials and a phone receiving OTPs."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP, User
|
||||||
|
from apps.accounts.services.phone import normalize_phone_number
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.external
|
||||||
|
@override_settings(OTP_PROVIDER="authentica")
|
||||||
|
def test_authentica_phone_auth_e2e(client):
|
||||||
|
if os.getenv("AUTHENTICA_E2E") != "1":
|
||||||
|
pytest.skip("AUTHENTICA_E2E=1 not set")
|
||||||
|
|
||||||
|
api_key = os.getenv("AUTHENTICA_API_KEY")
|
||||||
|
phone_number = os.getenv("AUTHENTICA_E2E_PHONE")
|
||||||
|
if not api_key or not phone_number:
|
||||||
|
pytest.skip("Missing AUTHENTICA_API_KEY or AUTHENTICA_E2E_PHONE")
|
||||||
|
|
||||||
|
request_url = reverse("phone_auth_request")
|
||||||
|
response = client.post(
|
||||||
|
request_url,
|
||||||
|
{"phone_number": phone_number, "channel": "sms", "first_name": "E2E"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
request_id = response.json()["request_id"]
|
||||||
|
assert request_id
|
||||||
|
|
||||||
|
code = os.getenv("AUTHENTICA_E2E_CODE")
|
||||||
|
if not code:
|
||||||
|
pytest.skip("AUTHENTICA_E2E_CODE not set")
|
||||||
|
|
||||||
|
normalized_phone = normalize_phone_number(phone_number)
|
||||||
|
User.objects.get_or_create(
|
||||||
|
phone_number=normalized_phone,
|
||||||
|
defaults={"role": "customer"},
|
||||||
|
)
|
||||||
|
if not PhoneOTP.objects.filter(id=request_id).exists():
|
||||||
|
# Create a local OTP record so the verify endpoint can bind to a request_id.
|
||||||
|
PhoneOTP.objects.create(
|
||||||
|
id=request_id,
|
||||||
|
phone_number=normalized_phone,
|
||||||
|
channel=OtpChannel.SMS,
|
||||||
|
purpose=OtpPurpose.AUTH,
|
||||||
|
provider="authentica",
|
||||||
|
code_hash="placeholder",
|
||||||
|
expires_at=timezone.now() + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
|
||||||
|
verify_url = reverse("phone_auth_verify")
|
||||||
|
verify = client.post(
|
||||||
|
verify_url,
|
||||||
|
{"request_id": request_id, "code": code},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert verify.status_code == 200
|
||||||
|
data = verify.json()
|
||||||
|
assert "access" in data
|
||||||
|
assert "refresh" in data
|
||||||
|
|
||||||
|
user = User.objects.filter(phone_number=normalized_phone).first()
|
||||||
|
assert user is not None
|
||||||
|
assert user.is_phone_verified is True
|
||||||
@@ -1,13 +1,49 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from django.contrib.auth.hashers import make_password
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
|
|
||||||
from apps.accounts.models import OtpChannel
|
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
||||||
from apps.accounts.services.otp import OtpRateLimitError, create_and_send_otp
|
from apps.accounts.services.otp import OtpCooldownError, OtpRateLimitError, create_and_send_otp, verify_otp
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
|
@override_settings(OTP_PROVIDER="console", OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
|
||||||
def test_otp_rate_limit():
|
def test_otp_rate_limit():
|
||||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
with pytest.raises(OtpRateLimitError):
|
with pytest.raises(OtpRateLimitError):
|
||||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(
|
||||||
|
OTP_PROVIDER="console",
|
||||||
|
OTP_MAX_PER_WINDOW=5,
|
||||||
|
OTP_WINDOW_MINUTES=15,
|
||||||
|
OTP_RESEND_COOLDOWN_SECONDS=60,
|
||||||
|
)
|
||||||
|
def test_otp_cooldown_enforced():
|
||||||
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
with pytest.raises(OtpCooldownError):
|
||||||
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_otp_max_attempts_blocks_verification():
|
||||||
|
otp = PhoneOTP.objects.create(
|
||||||
|
phone_number="+966512345678",
|
||||||
|
channel=OtpChannel.SMS,
|
||||||
|
purpose=OtpPurpose.AUTH,
|
||||||
|
provider="console",
|
||||||
|
code_hash=make_password("123456"),
|
||||||
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
|
)
|
||||||
|
# Burn attempts with wrong code until the limit is exceeded.
|
||||||
|
for _ in range(otp.max_attempts):
|
||||||
|
assert verify_otp(otp, "000000") is False
|
||||||
|
otp.refresh_from_db()
|
||||||
|
assert otp.attempt_count == otp.max_attempts
|
||||||
|
|
||||||
|
assert verify_otp(otp, "123456") is False
|
||||||
|
otp.refresh_from_db()
|
||||||
|
assert otp.attempt_count == otp.max_attempts + 1
|
||||||
|
assert otp.verified_at is None
|
||||||
|
|||||||
@@ -1,31 +1,49 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.test import override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from apps.accounts.models import PhoneOTP, User
|
from apps.accounts.models import PhoneOTP, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
@override_settings(OTP_PROVIDER="console")
|
||||||
def test_phone_auth_creates_user_and_issues_tokens(client):
|
def test_phone_auth_creates_user_and_issues_tokens(client):
|
||||||
request_url = reverse("phone_auth_request")
|
# Deterministic OTP so we can verify the flow without external providers.
|
||||||
verify_url = reverse("phone_auth_verify")
|
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
||||||
|
request_url = reverse("phone_auth_request")
|
||||||
|
verify_url = reverse("phone_auth_verify")
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
request_url,
|
request_url,
|
||||||
{"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"},
|
{"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"},
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
request_id = response.json()["request_id"]
|
request_id = response.json()["request_id"]
|
||||||
|
|
||||||
otp = PhoneOTP.objects.filter(phone_number="+966512345678").order_by("-created_at").first()
|
otp = PhoneOTP.objects.filter(phone_number="+966512345678").order_by("-created_at").first()
|
||||||
assert otp is not None
|
assert otp is not None
|
||||||
assert str(otp.id) == request_id
|
assert str(otp.id) == request_id
|
||||||
|
|
||||||
bad = client.post(
|
bad = client.post(
|
||||||
verify_url,
|
verify_url,
|
||||||
{"request_id": request_id, "code": "000000"},
|
{"request_id": request_id, "code": "000000"},
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert bad.status_code == 400
|
assert bad.status_code == 400
|
||||||
|
|
||||||
assert User.objects.filter(phone_number="+966512345678").exists()
|
good = client.post(
|
||||||
|
verify_url,
|
||||||
|
{"request_id": request_id, "code": "123456"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert good.status_code == 200
|
||||||
|
data = good.json()
|
||||||
|
assert "access" in data
|
||||||
|
assert "refresh" in data
|
||||||
|
|
||||||
|
user = User.objects.filter(phone_number="+966512345678").first()
|
||||||
|
assert user is not None
|
||||||
|
assert user.is_phone_verified is True
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
DJANGO_SETTINGS_MODULE = salon_api.settings
|
DJANGO_SETTINGS_MODULE = salon_api.settings
|
||||||
python_files = tests.py test_*.py *_tests.py
|
python_files = tests.py test_*.py *_tests.py
|
||||||
addopts = -q
|
addopts = -q -m "not external"
|
||||||
|
markers =
|
||||||
|
external: hits real third-party services (requires explicit env to run)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -78,11 +79,14 @@ def parse_database_url(database_url: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
running_tests = "PYTEST_CURRENT_TEST" in os.environ or any("pytest" in arg for arg in sys.argv)
|
||||||
if DATABASE_URL:
|
test_database_url = os.getenv("TEST_DATABASE_URL")
|
||||||
parsed_db = parse_database_url(DATABASE_URL)
|
database_url = os.getenv("DATABASE_URL")
|
||||||
|
|
||||||
|
if running_tests:
|
||||||
|
parsed_db = parse_database_url(test_database_url) if test_database_url else None
|
||||||
else:
|
else:
|
||||||
parsed_db = None
|
parsed_db = parse_database_url(database_url) if database_url else None
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": parsed_db
|
"default": parsed_db
|
||||||
@@ -136,6 +140,8 @@ CORS_ALLOWED_ORIGINS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console")
|
OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console")
|
||||||
|
if running_tests:
|
||||||
|
OTP_PROVIDER = os.getenv("TEST_OTP_PROVIDER", "console")
|
||||||
OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5"))
|
OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5"))
|
||||||
OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5"))
|
OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5"))
|
||||||
OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
|
OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
|
||||||
|
|||||||
Reference in New Issue
Block a user