first commit
This commit is contained in:
commit
34ea15a54d
246 changed files with 18660 additions and 0 deletions
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
4
backend/tests/conftest.py
Normal file
4
backend/tests/conftest.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import os
|
||||
|
||||
# Tests share one TestClient IP; disable in-process rate limits during pytest.
|
||||
os.environ.setdefault("DESI_CLANS_DISABLE_RATE_LIMIT", "1")
|
||||
56
backend/tests/helpers.py
Normal file
56
backend/tests/helpers.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import uuid
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def register_user() -> tuple[str, str]:
|
||||
email = f"buyer-{uuid.uuid4().hex}@example.com"
|
||||
password = "securepass1"
|
||||
response = client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": email, "password": password, "full_name": "Buyer"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
token = response.json()["access_token"]
|
||||
return email, token
|
||||
|
||||
|
||||
def auth_headers(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def first_published_event() -> dict:
|
||||
response = client.get("/api/events", params={"upcoming": "false"})
|
||||
assert response.status_code == 200
|
||||
events = response.json()
|
||||
assert events, "Seed catalog required for order tests"
|
||||
return events[0]
|
||||
|
||||
|
||||
def pick_tier(tiers: list[dict], quantity: int = 1) -> dict | None:
|
||||
return next(
|
||||
(t for t in tiers if t["status"] == "active" and t["remaining_quantity"] >= quantity),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def create_pending_order():
|
||||
import pytest
|
||||
|
||||
_, token = register_user()
|
||||
event = first_published_event()
|
||||
tiers = client.get(f"/api/events/{event['id']}/tickets").json()
|
||||
tier = pick_tier(tiers, 1)
|
||||
if not tier:
|
||||
pytest.skip("No ticket inventory available for test")
|
||||
response = client.post(
|
||||
"/api/orders",
|
||||
headers={**auth_headers(token), "Idempotency-Key": uuid.uuid4().hex},
|
||||
json={"event_id": event["id"], "items": [{"ticket_type_id": tier["id"], "quantity": 1}]},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return token, response.json()
|
||||
50
backend/tests/test_admin_api.py
Normal file
50
backend/tests/test_admin_api.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import uuid
|
||||
|
||||
from tests.helpers import auth_headers, client, register_user
|
||||
|
||||
|
||||
def _admin_token() -> str:
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
response = client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"email": settings.admin_email, "password": settings.admin_password},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(f"Admin login failed: {response.text} — run scripts/seed_admin.py")
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
def test_attendee_cannot_access_admin_dashboard() -> None:
|
||||
_, token = register_user()
|
||||
response = client.get("/api/admin/dashboard/metrics", headers=auth_headers(token))
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_admin_dashboard_metrics() -> None:
|
||||
token = _admin_token()
|
||||
response = client.get("/api/admin/dashboard/metrics", headers={"Authorization": f"Bearer {token}"})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert "total_revenue_paise" in body
|
||||
assert "tickets_sold" in body
|
||||
assert "upcoming_events" in body
|
||||
|
||||
|
||||
def test_admin_lists_catalog() -> None:
|
||||
token = _admin_token()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
assert client.get("/api/admin/events", headers=headers).status_code == 200
|
||||
assert client.get("/api/admin/venues", headers=headers).status_code == 200
|
||||
assert client.get("/api/admin/orders", headers=headers).status_code == 200
|
||||
assert client.get("/api/admin/payments", headers=headers).status_code == 200
|
||||
assert client.get("/api/admin/stats/events", headers=headers).status_code == 200
|
||||
|
||||
|
||||
def test_admin_invalid_login() -> None:
|
||||
response = client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"email": f"nope-{uuid.uuid4().hex}@example.com", "password": "wrongpass"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
95
backend/tests/test_auth.py
Normal file
95
backend/tests/test_auth.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import uuid
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def _unique_email() -> str:
|
||||
return f"test-{uuid.uuid4().hex}@example.com"
|
||||
|
||||
|
||||
def _register(email: str | None = None, password: str = "securepass1") -> tuple[str, dict]:
|
||||
address = email or _unique_email()
|
||||
response = client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": address, "password": password, "full_name": "Test User"},
|
||||
)
|
||||
return address, response
|
||||
|
||||
|
||||
def test_register_login_and_me() -> None:
|
||||
email, register_response = _register()
|
||||
assert register_response.status_code == 201
|
||||
token = register_response.json()["access_token"]
|
||||
assert token
|
||||
|
||||
login_response = client.post("/api/auth/login", json={"email": email, "password": "securepass1"})
|
||||
assert login_response.status_code == 200
|
||||
assert login_response.json()["token_type"] == "bearer"
|
||||
|
||||
me_response = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
assert me_response.status_code == 200
|
||||
body = me_response.json()
|
||||
assert body["email"] == email
|
||||
assert body["full_name"] == "Test User"
|
||||
assert body["is_active"] is True
|
||||
assert "hashed_password" not in body
|
||||
|
||||
|
||||
def test_register_duplicate_email() -> None:
|
||||
email, first = _register()
|
||||
assert first.status_code == 201
|
||||
second = client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": email, "password": "securepass1"},
|
||||
)
|
||||
assert second.status_code == 409
|
||||
|
||||
|
||||
def test_register_password_too_short() -> None:
|
||||
response = client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _unique_email(), "password": "short"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_login_invalid_credentials() -> None:
|
||||
email, register_response = _register()
|
||||
assert register_response.status_code == 201
|
||||
|
||||
wrong_password = client.post("/api/auth/login", json={"email": email, "password": "wrongpassword"})
|
||||
assert wrong_password.status_code == 401
|
||||
|
||||
unknown = client.post("/api/auth/login", json={"email": "nobody@example.com", "password": "securepass1"})
|
||||
assert unknown.status_code == 401
|
||||
|
||||
|
||||
def test_me_requires_auth() -> None:
|
||||
response = client.get("/api/auth/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_me_rejects_invalid_token() -> None:
|
||||
response = client.get("/api/auth/me", headers={"Authorization": "Bearer not-a-valid-jwt"})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_passwords_are_hashed_in_database() -> None:
|
||||
from app.db.session import SessionLocal
|
||||
from app.services.users import get_user_by_email
|
||||
|
||||
email, register_response = _register(password="anotherpass9")
|
||||
assert register_response.status_code == 201
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = get_user_by_email(db, email)
|
||||
assert user is not None
|
||||
assert user.hashed_password != "anotherpass9"
|
||||
assert user.hashed_password.startswith("$2")
|
||||
finally:
|
||||
db.close()
|
||||
64
backend/tests/test_catalog_api.py
Normal file
64
backend/tests/test_catalog_api.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_list_events_published() -> None:
|
||||
response = client.get("/api/events")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert isinstance(body, list)
|
||||
if body:
|
||||
assert "slug" in body[0]
|
||||
assert "venue" in body[0]
|
||||
assert body[0]["status"] == "published"
|
||||
|
||||
|
||||
def test_list_events_filter_category() -> None:
|
||||
response = client.get("/api/events", params={"category": "garba", "upcoming": "false"})
|
||||
assert response.status_code == 200
|
||||
for item in response.json():
|
||||
assert item["category"] == "garba"
|
||||
|
||||
|
||||
def test_get_event_by_slug() -> None:
|
||||
listing = client.get("/api/events", params={"upcoming": "false"})
|
||||
assert listing.status_code == 200
|
||||
if not listing.json():
|
||||
return
|
||||
slug = listing.json()[0]["slug"]
|
||||
detail = client.get(f"/api/events/{slug}")
|
||||
assert detail.status_code == 200
|
||||
data = detail.json()
|
||||
assert data["slug"] == slug
|
||||
assert "ticket_types" in data
|
||||
assert "media" in data
|
||||
|
||||
|
||||
def test_get_event_not_found() -> None:
|
||||
response = client.get("/api/events/does-not-exist-slug")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_list_venues() -> None:
|
||||
response = client.get("/api/venues")
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
|
||||
def test_get_venue_by_id() -> None:
|
||||
listing = client.get("/api/venues")
|
||||
assert listing.status_code == 200
|
||||
if not listing.json():
|
||||
return
|
||||
venue_id = listing.json()[0]["id"]
|
||||
response = client.get(f"/api/venues/{venue_id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == venue_id
|
||||
|
||||
|
||||
def test_get_venue_not_found() -> None:
|
||||
response = client.get("/api/venues/999999")
|
||||
assert response.status_code == 404
|
||||
61
backend/tests/test_digital_tickets.py
Normal file
61
backend/tests/test_digital_tickets.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import json
|
||||
|
||||
from tests.helpers import auth_headers, client, create_pending_order
|
||||
from app.core.config import get_settings
|
||||
from app.services.payments.gateway import MockPaymentGateway, get_payment_gateway
|
||||
|
||||
|
||||
def _pay_and_get_ticket():
|
||||
token, checkout = create_pending_order()
|
||||
gateway = get_payment_gateway()
|
||||
assert isinstance(gateway, MockPaymentGateway)
|
||||
payment = checkout["payment"]
|
||||
payment_id = f"mock_pay_{checkout['id']}"
|
||||
signature = gateway.build_mock_checkout_signature(payment["provider_order_id"], payment_id)
|
||||
client.post(
|
||||
f"/api/orders/{checkout['id']}/payment/verify",
|
||||
headers=auth_headers(token),
|
||||
json={
|
||||
"provider_order_id": payment["provider_order_id"],
|
||||
"provider_payment_id": payment_id,
|
||||
"signature": signature,
|
||||
},
|
||||
)
|
||||
tickets = client.get("/api/tickets/my", headers=auth_headers(token)).json()
|
||||
ticket_id = tickets[0]["id"]
|
||||
return token, ticket_id
|
||||
|
||||
|
||||
def test_ticket_detail_includes_qr() -> None:
|
||||
token, ticket_id = _pay_and_get_ticket()
|
||||
response = client.get(f"/api/tickets/{ticket_id}", headers=auth_headers(token))
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["qr_image_base64"]
|
||||
assert body["event_title"]
|
||||
assert body["venue_name"]
|
||||
assert body["ticket_type_name"]
|
||||
assert body["code"].startswith("DC-")
|
||||
payload = json.loads(body["qr_payload"])
|
||||
assert payload["token"] == body["qr_token"]
|
||||
|
||||
|
||||
def test_verify_marks_ticket_used_and_prevents_reuse() -> None:
|
||||
token, ticket_id = _pay_and_get_ticket()
|
||||
detail = client.get(f"/api/tickets/{ticket_id}", headers=auth_headers(token)).json()
|
||||
qr_token = detail["qr_token"]
|
||||
|
||||
settings = get_settings()
|
||||
headers = {}
|
||||
if settings.ticket_verify_api_key:
|
||||
headers["X-Check-In-Key"] = settings.ticket_verify_api_key
|
||||
|
||||
first = client.post("/api/tickets/verify", json={"qr_token": qr_token}, headers=headers)
|
||||
assert first.status_code == 200
|
||||
assert first.json()["valid"] is True
|
||||
assert first.json()["status"] == "used"
|
||||
|
||||
second = client.post("/api/tickets/verify", json={"qr_token": qr_token}, headers=headers)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["valid"] is False
|
||||
assert "already used" in second.json()["message"].lower()
|
||||
19
backend/tests/test_health.py
Normal file
19
backend/tests/test_health.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_health_endpoint() -> None:
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code in (200, 503)
|
||||
body = response.json()
|
||||
assert body["service"] == "desi-clans-api"
|
||||
assert "database" in body
|
||||
assert body["status"] in ("ok", "degraded")
|
||||
|
||||
if response.status_code == 200:
|
||||
assert body["database"] == "connected"
|
||||
else:
|
||||
assert body["database"] == "unavailable"
|
||||
174
backend/tests/test_orders.py
Normal file
174
backend/tests/test_orders.py
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.main import app
|
||||
from app.models.catalog import TicketType
|
||||
from app.models.enums import OrderStatus, TicketTypeStatus
|
||||
from app.models.user import User
|
||||
from app.schemas.orders import OrderCreate, OrderLineCreate
|
||||
from app.services.orders import InsufficientInventoryError, create_order
|
||||
from app.services.payments.gateway import MockPaymentGateway, get_payment_gateway
|
||||
from tests.helpers import (
|
||||
auth_headers,
|
||||
client,
|
||||
create_pending_order,
|
||||
first_published_event,
|
||||
pick_tier,
|
||||
register_user,
|
||||
)
|
||||
|
||||
|
||||
def _complete_mock_payment(token: str, checkout: dict) -> None:
|
||||
gateway = get_payment_gateway()
|
||||
assert isinstance(gateway, MockPaymentGateway)
|
||||
payment = checkout["payment"]
|
||||
payment_id = f"mock_pay_{checkout['id']}"
|
||||
signature = gateway.build_mock_checkout_signature(payment["provider_order_id"], payment_id)
|
||||
response = client.post(
|
||||
f"/api/orders/{checkout['id']}/payment/verify",
|
||||
headers=auth_headers(token),
|
||||
json={
|
||||
"provider_order_id": payment["provider_order_id"],
|
||||
"provider_payment_id": payment_id,
|
||||
"signature": signature,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_list_event_tickets() -> None:
|
||||
event = first_published_event()
|
||||
response = client.get(f"/api/events/{event['id']}/tickets")
|
||||
assert response.status_code == 200
|
||||
tiers = response.json()
|
||||
assert tiers
|
||||
assert tiers[0]["event_id"] == event["id"]
|
||||
assert "remaining_quantity" in tiers[0]
|
||||
assert "price_paise" in tiers[0]
|
||||
|
||||
|
||||
def test_create_order_pending_until_verified() -> None:
|
||||
token, checkout = create_pending_order()
|
||||
assert checkout["status"] == OrderStatus.pending.value
|
||||
assert checkout["payment"]["amount_paise"] == checkout["total_paise"]
|
||||
assert checkout["payment"]["provider_order_id"]
|
||||
|
||||
tickets_before = client.get("/api/tickets/my", headers=auth_headers(token)).json()
|
||||
assert not any(t["order_id"] == checkout["id"] for t in tickets_before)
|
||||
|
||||
_complete_mock_payment(token, checkout)
|
||||
|
||||
my_tickets = client.get("/api/tickets/my", headers=auth_headers(token))
|
||||
assert my_tickets.status_code == 200
|
||||
tickets = my_tickets.json()
|
||||
assert any(t["order_id"] == checkout["id"] for t in tickets)
|
||||
assert all(t["code"].startswith("DC-") for t in tickets)
|
||||
|
||||
|
||||
def test_order_requires_authentication() -> None:
|
||||
event = first_published_event()
|
||||
response = client.post(
|
||||
"/api/orders",
|
||||
json={"event_id": event["id"], "items": [{"ticket_type_id": 1, "quantity": 1}]},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_cannot_oversell_inventory() -> None:
|
||||
_, token = register_user()
|
||||
event = first_published_event()
|
||||
tiers = client.get(f"/api/events/{event['id']}/tickets").json()
|
||||
tier = pick_tier(tiers, 1)
|
||||
if not tier:
|
||||
pytest.skip("No ticket inventory available for test")
|
||||
if tier["remaining_quantity"] >= 20:
|
||||
pytest.skip("Quantity cap prevents oversell test for this tier")
|
||||
|
||||
response = client.post(
|
||||
"/api/orders",
|
||||
headers=auth_headers(token),
|
||||
json={
|
||||
"event_id": event["id"],
|
||||
"items": [{"ticket_type_id": tier["id"], "quantity": tier["remaining_quantity"] + 1}],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_server_calculates_total_not_client() -> None:
|
||||
token, checkout = create_pending_order()
|
||||
tiers = client.get(f"/api/events/{checkout['event_id']}/tickets").json()
|
||||
tier = next(t for t in tiers if t["id"] == checkout["items"][0]["ticket_type_id"])
|
||||
qty = checkout["items"][0]["quantity"]
|
||||
assert checkout["total_paise"] == tier["price_paise"] * qty
|
||||
|
||||
|
||||
def test_concurrent_purchases_do_not_oversell() -> None:
|
||||
event = first_published_event()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
tier = db.scalar(
|
||||
select(TicketType).where(TicketType.event_id == event["id"]).order_by(TicketType.id.asc())
|
||||
)
|
||||
assert tier is not None
|
||||
tier.quantity_sold = max(tier.quantity_total - 1, 0)
|
||||
tier.quantity_total = tier.quantity_sold + 1
|
||||
tier.status = TicketTypeStatus.active
|
||||
db.commit()
|
||||
ticket_type_id = tier.id
|
||||
event_id = event["id"]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
user_ids: list[int] = []
|
||||
for _ in range(2):
|
||||
email = f"race-{uuid.uuid4().hex}@example.com"
|
||||
reg = client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": email, "password": "securepass1"},
|
||||
)
|
||||
assert reg.status_code == 201
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.scalar(select(User).where(User.email == email))
|
||||
assert user is not None
|
||||
user_ids.append(user.id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _buy(user_id: int) -> str:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
user = session.get(User, user_id)
|
||||
create_order(
|
||||
session,
|
||||
user,
|
||||
OrderCreate(
|
||||
event_id=event_id,
|
||||
items=[OrderLineCreate(ticket_type_id=ticket_type_id, quantity=1)],
|
||||
),
|
||||
)
|
||||
return "success"
|
||||
except InsufficientInventoryError:
|
||||
session.rollback()
|
||||
return "conflict"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(_buy, user_ids))
|
||||
|
||||
assert results.count("success") == 1
|
||||
assert results.count("conflict") == 1
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
refreshed = db.get(TicketType, ticket_type_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.quantity_sold == refreshed.quantity_total
|
||||
finally:
|
||||
db.close()
|
||||
97
backend/tests/test_payments.py
Normal file
97
backend/tests/test_payments.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from app.core.config import get_settings
|
||||
from app.services.payments.gateway import MockPaymentGateway, get_payment_gateway
|
||||
from tests.helpers import auth_headers, client, create_pending_order
|
||||
|
||||
|
||||
def _mock_signature(body: bytes) -> str:
|
||||
secret = get_settings().payment_mock_secret
|
||||
return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def test_verify_payment_issues_tickets() -> None:
|
||||
token, checkout = create_pending_order()
|
||||
assert checkout["status"] == "pending"
|
||||
payment = checkout["payment"]
|
||||
assert payment["provider"] == "mock"
|
||||
|
||||
gateway = get_payment_gateway()
|
||||
assert isinstance(gateway, MockPaymentGateway)
|
||||
payment_id = f"mock_pay_{checkout['id']}"
|
||||
signature = gateway.build_mock_checkout_signature(payment["provider_order_id"], payment_id)
|
||||
|
||||
verify = client.post(
|
||||
f"/api/orders/{checkout['id']}/payment/verify",
|
||||
headers=auth_headers(token),
|
||||
json={
|
||||
"provider_order_id": payment["provider_order_id"],
|
||||
"provider_payment_id": payment_id,
|
||||
"signature": signature,
|
||||
},
|
||||
)
|
||||
assert verify.status_code == 200
|
||||
assert verify.json()["status"] == "paid"
|
||||
|
||||
tickets = client.get("/api/tickets/my", headers=auth_headers(token)).json()
|
||||
assert any(t["order_id"] == checkout["id"] for t in tickets)
|
||||
|
||||
|
||||
def test_rejects_invalid_signature() -> None:
|
||||
token, checkout = create_pending_order()
|
||||
payment = checkout["payment"]
|
||||
verify = client.post(
|
||||
f"/api/orders/{checkout['id']}/payment/verify",
|
||||
headers=auth_headers(token),
|
||||
json={
|
||||
"provider_order_id": payment["provider_order_id"],
|
||||
"provider_payment_id": "mock_pay_bad",
|
||||
"signature": "not-valid",
|
||||
},
|
||||
)
|
||||
assert verify.status_code == 400
|
||||
|
||||
|
||||
def test_webhook_idempotent() -> None:
|
||||
token, checkout = create_pending_order()
|
||||
payment = checkout["payment"]
|
||||
payment_id = f"mock_pay_wh_{checkout['id']}"
|
||||
import uuid
|
||||
|
||||
event_id = f"evt_duplicate_{uuid.uuid4().hex}"
|
||||
payload = {
|
||||
"id": event_id,
|
||||
"event": "payment.captured",
|
||||
"payload": {
|
||||
"payment": {
|
||||
"entity": {
|
||||
"id": payment_id,
|
||||
"order_id": payment["provider_order_id"],
|
||||
"amount": checkout["total_paise"],
|
||||
"currency": "INR",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
body = json.dumps(payload).encode()
|
||||
signature = _mock_signature(body)
|
||||
headers = {"X-Mock-Signature": signature}
|
||||
|
||||
first = client.post("/api/webhooks/mock", content=body, headers=headers)
|
||||
assert first.status_code == 200
|
||||
assert first.json()["status"] == "paid"
|
||||
|
||||
second = client.post("/api/webhooks/mock", content=body, headers=headers)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["status"] == "duplicate_ignored"
|
||||
|
||||
tickets = client.get("/api/tickets/my", headers=auth_headers(token)).json()
|
||||
assert len([t for t in tickets if t["order_id"] == checkout["id"]]) == 1
|
||||
|
||||
|
||||
def test_cancel_pending_order_releases_inventory() -> None:
|
||||
token, checkout = create_pending_order()
|
||||
cancel = client.post(f"/api/orders/{checkout['id']}/cancel", headers=auth_headers(token))
|
||||
assert cancel.status_code == 200
|
||||
assert cancel.json()["status"] == "cancelled"
|
||||
55
backend/tests/test_security.py
Normal file
55
backend/tests/test_security.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from jose import jwt
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.security import (
|
||||
ALGORITHM,
|
||||
TOKEN_TYPE_USER,
|
||||
create_user_access_token,
|
||||
decode_user_access_token,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
|
||||
def test_password_hashing_roundtrip() -> None:
|
||||
hashed = hash_password("my-secret-password")
|
||||
assert hashed != "my-secret-password"
|
||||
assert verify_password("my-secret-password", hashed)
|
||||
assert not verify_password("wrong", hashed)
|
||||
|
||||
|
||||
def test_jwt_expiration_claim() -> None:
|
||||
settings = Settings(
|
||||
environment="development",
|
||||
database_url="postgresql://localhost/test",
|
||||
secret_key="unit-test-secret-key-at-least-32-chars",
|
||||
access_token_expire_minutes=30,
|
||||
)
|
||||
token = create_user_access_token(42, settings)
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
|
||||
assert payload["sub"] == "42"
|
||||
assert payload["type"] == TOKEN_TYPE_USER
|
||||
assert "exp" in payload
|
||||
|
||||
user_id = decode_user_access_token(token, settings)
|
||||
assert user_id == 42
|
||||
|
||||
|
||||
def test_jwt_rejects_wrong_type() -> None:
|
||||
settings = Settings(
|
||||
environment="development",
|
||||
database_url="postgresql://localhost/test",
|
||||
secret_key="unit-test-secret-key-at-least-32-chars",
|
||||
access_token_expire_minutes=30,
|
||||
)
|
||||
from datetime import datetime, timezone
|
||||
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||
admin_style = jwt.encode(
|
||||
{"sub": "admin@example.com", "exp": expire, "type": "admin"},
|
||||
settings.secret_key,
|
||||
algorithm=ALGORITHM,
|
||||
)
|
||||
assert decode_user_access_token(admin_style, settings) is None
|
||||
50
backend/tests/test_security_hardening.py
Normal file
50
backend/tests/test_security_hardening.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.config import Settings
|
||||
from tests.helpers import client
|
||||
|
||||
|
||||
def _base_settings_kwargs(**overrides):
|
||||
return {
|
||||
"environment": "development",
|
||||
"database_url": "postgresql://localhost/test",
|
||||
"secret_key": "unit-test-secret-key-at-least-32-chars",
|
||||
**overrides,
|
||||
}
|
||||
|
||||
|
||||
def test_production_requires_gate_key_and_razorpay() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(
|
||||
**_base_settings_kwargs(
|
||||
environment="production",
|
||||
payment_provider="mock",
|
||||
ticket_verify_api_key="short",
|
||||
)
|
||||
)
|
||||
|
||||
settings = Settings(
|
||||
**_base_settings_kwargs(
|
||||
environment="production",
|
||||
payment_provider="razorpay",
|
||||
ticket_verify_api_key="gate-key-min-16-chars",
|
||||
razorpay_key_id="rzp_test",
|
||||
razorpay_key_secret="secret",
|
||||
razorpay_webhook_secret="whsec",
|
||||
admin_password="not-the-default",
|
||||
cors_origins="https://desiclans.in",
|
||||
)
|
||||
)
|
||||
assert settings.environment == "production"
|
||||
|
||||
|
||||
@patch("app.api.routes.webhooks.get_settings")
|
||||
def test_mock_webhook_disabled_outside_development(mock_get_settings) -> None:
|
||||
mock_get_settings.return_value = Settings(
|
||||
**_base_settings_kwargs(environment="staging"),
|
||||
)
|
||||
response = client.post("/api/webhooks/mock", content=b"{}", headers={"X-Mock-Signature": "x"})
|
||||
assert response.status_code == 404
|
||||
Loading…
Add table
Add a link
Reference in a new issue