Spaces:
Running on Zero
Running on Zero
feat(backend): centralized environment configuration, Razorpay standard web checkout, Google Play keyless auth & security hardening
bbd07c7 Download tests/test_subscription_security.py from sahihislam/superadmin: direct link, hf CLI and curl.
- Browser
- Download file 18.8 kB
-
https://huggingface.co/spaces/sahihislam/superadmin/resolve/main/tests/test_subscription_security.py
- Command line
-
hf download hf://spaces/sahihislam/superadmin/tests/test_subscription_security.py
-
curl -L -o test_subscription_security.py https://huggingface.co/spaces/sahihislam/superadmin/resolve/main/tests/test_subscription_security.py
18.8 kB
| """ | |
| Sahih Islam - Subscription Security & Anti-Modding Test Suite | |
| ============================================================ | |
| 14 Comprehensive Security Scenarios verifying: | |
| 1. Server-authoritative Pro entitlement gating | |
| 2. Tampered/spoofed client payload resistance | |
| 3. Time-based subscription expiry enforcement | |
| 4. Instant revocation on Google Play refunds | |
| 5. Active subscriber access authorization | |
| 6. Google Play In-App Purchase token verification | |
| 7. Rejection of malformed/invalid Google Play purchase tokens | |
| 8. Anti-Replay protection across multiple user accounts | |
| 9. Idempotent purchase verification for the same user | |
| 10. RTDN Webhook renewal lifecycle updates in MongoDB | |
| 11. RTDN Webhook revocation/refund updates in MongoDB | |
| 12. IDOR access control protection (enforce_self_or_admin) | |
| 13. Prevention of unverified direct subscription activation | |
| 14. Production prohibition of mock/sandbox tokens | |
| """ | |
| import os | |
| import sys | |
| import asyncio | |
| from datetime import datetime, timedelta | |
| from unittest.mock import AsyncMock, patch, MagicMock | |
| import pytest | |
| from fastapi import HTTPException, status, Request | |
| # Ensure backend root on sys.path | |
| sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) | |
| from app.auth_deps import is_user_pro, require_pro, require_entitlement, enforce_self_or_admin | |
| from app.rag_config import resolve_user_tier | |
| from app.services.google_play_service import GooglePlayService, google_play_service | |
| from app.core.config import settings | |
| from app.models import SubscriptionCreate | |
| def run_async(coro): | |
| """Executes asynchronous coroutine synchronously for pytest.""" | |
| return asyncio.run(coro) | |
| # ============================================================================ | |
| # 1. Server Authority & Tier Resolution Tests (Scenarios 1 - 5) | |
| # ============================================================================ | |
| def test_free_user_pro_endpoint_denial(): | |
| """Scenario 1: Free user attempting to access a Pro-protected endpoint receives 403 Forbidden.""" | |
| async def _test(): | |
| free_user = {"uid": "user_free_1", "role": "USER", "is_pro": False} | |
| assert await is_user_pro(free_user) is False | |
| with pytest.raises(HTTPException) as exc_info: | |
| await require_pro(current_user=free_user) | |
| assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN | |
| assert "Pro subscription required" in exc_info.value.detail | |
| checker = require_entitlement("ai_deepseek_v3") | |
| with pytest.raises(HTTPException) as exc_info2: | |
| await checker(current_user=free_user) | |
| assert exc_info2.value.status_code == status.HTTP_403_FORBIDDEN | |
| assert "ai_deepseek_v3" in exc_info2.value.detail | |
| run_async(_test()) | |
| def test_client_spoofed_is_pro_ignored(): | |
| """Scenario 2: Client passes spoofed 'is_pro': True in request, but DB record has is_pro: False.""" | |
| async def _test(): | |
| db_user_record = { | |
| "uid": "user_spoof_attacker", | |
| "role": "USER", | |
| "is_pro": False, | |
| "subscription": {"is_pro": False, "tier": "FREE", "status": "FREE"} | |
| } | |
| # Attacker's request body may claim whatever, but the authoritative user dict is evaluated | |
| assert await is_user_pro(db_user_record) is False | |
| assert resolve_user_tier(db_user_record) == "free" | |
| run_async(_test()) | |
| def test_expired_subscription_downgrade(): | |
| """Scenario 3: User with is_pro=True whose expires_at is in the past is downgraded to free.""" | |
| async def _test(): | |
| past_expiry = datetime.utcnow() - timedelta(days=3) | |
| expired_user = { | |
| "uid": "user_expired_sub", | |
| "role": "USER", | |
| "is_pro": True, # Stale flag left in DB | |
| "subscription": { | |
| "is_pro": True, | |
| "tier": "PRO", | |
| "status": "ACTIVE", | |
| "expires_at": past_expiry | |
| } | |
| } | |
| assert await is_user_pro(expired_user) is False | |
| assert resolve_user_tier(expired_user) == "free" | |
| with pytest.raises(HTTPException) as exc_info: | |
| await require_pro(current_user=expired_user) | |
| assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN | |
| run_async(_test()) | |
| def test_revoked_or_refunded_subscription(): | |
| """Scenario 4: User with status=REFUNDED or REVOKED has Pro access revoked immediately.""" | |
| async def _test(): | |
| future_date = datetime.utcnow() + timedelta(days=20) | |
| refunded_user = { | |
| "uid": "user_refunded_1", | |
| "role": "USER", | |
| "is_pro": True, | |
| "subscription": { | |
| "is_pro": True, | |
| "tier": "PRO", | |
| "status": "REFUNDED", | |
| "expires_at": future_date | |
| } | |
| } | |
| assert await is_user_pro(refunded_user) is False | |
| assert resolve_user_tier(refunded_user) == "free" | |
| revoked_user = { | |
| "uid": "user_revoked_1", | |
| "role": "USER", | |
| "subscription": { | |
| "is_pro": True, | |
| "tier": "PRO", | |
| "status": "REVOKED", | |
| "expires_at": future_date | |
| } | |
| } | |
| assert await is_user_pro(revoked_user) is False | |
| assert resolve_user_tier(revoked_user) == "free" | |
| run_async(_test()) | |
| def test_valid_subscription_allows_access(): | |
| """Scenario 5: Active subscriber with future expires_at passes require_pro and receives 'pro' tier.""" | |
| async def _test(): | |
| future_date = datetime.utcnow() + timedelta(days=25) | |
| valid_pro_user = { | |
| "uid": "user_valid_pro", | |
| "role": "USER", | |
| "is_pro": True, | |
| "subscription": { | |
| "is_pro": True, | |
| "tier": "PRO", | |
| "status": "ACTIVE", | |
| "expires_at": future_date | |
| } | |
| } | |
| assert await is_user_pro(valid_pro_user) is True | |
| assert resolve_user_tier(valid_pro_user) == "pro" | |
| # require_pro returns the user without raising | |
| result = await require_pro(current_user=valid_pro_user) | |
| assert result["uid"] == "user_valid_pro" | |
| run_async(_test()) | |
| # ============================================================================ | |
| # 2. Google Play Developer API Verification & Anti-Replay (Scenarios 6 - 9) | |
| # ============================================================================ | |
| def test_google_play_purchase_verification_success(): | |
| """Scenario 6: Valid Google Play purchase token verifies and activates subscription.""" | |
| async def _test(): | |
| from app.payment_routes import verify_google_play_purchase | |
| mock_user = {"uid": "user_gp_buyer_1"} | |
| token = "test_token_valid_purchase_1001" | |
| payload = { | |
| "purchase_token": token, | |
| "product_id": "sahih_pro_monthly", | |
| "package_name": "com.sahihislam.app", | |
| "purpose": "subscription", | |
| "amount": 199.0, | |
| } | |
| with patch("app.payment_routes.subscriptions_collection.find_one", new_callable=AsyncMock) as mock_find_sub, \ | |
| patch("app.payment_routes.execute_payment_fulfillment", new_callable=AsyncMock) as mock_fulfill, \ | |
| patch("app.payment_routes.invalidate_user_cache", new_callable=AsyncMock): | |
| mock_find_sub.return_value = None # Token not yet claimed | |
| mock_fulfill.return_value = {"is_pro": True, "tier": "PRO"} | |
| response = await verify_google_play_purchase(payload=payload, current_user=mock_user) | |
| assert response["success"] is True | |
| assert response["product_id"] == "sahih_pro_monthly" | |
| assert mock_fulfill.called | |
| call_kwargs = mock_fulfill.call_args[1] | |
| assert call_kwargs["metadata"]["purchase_token"] == token | |
| run_async(_test()) | |
| def test_google_play_purchase_verification_invalid_token(): | |
| """Scenario 7: Malformed or fraudulent Google Play purchase token is rejected with 400.""" | |
| async def _test(): | |
| from app.payment_routes import verify_google_play_purchase | |
| mock_user = {"uid": "user_gp_fraud_1"} | |
| payload = { | |
| "purchase_token": "mock_invalid_fraud_token_xyz", | |
| "product_id": "sahih_pro_monthly", | |
| "package_name": "com.sahihislam.app", | |
| "purpose": "subscription", | |
| } | |
| with patch("app.payment_routes.subscriptions_collection.find_one", new_callable=AsyncMock) as mock_find_sub: | |
| mock_find_sub.return_value = None | |
| with pytest.raises(HTTPException) as exc_info: | |
| await verify_google_play_purchase(payload=payload, current_user=mock_user) | |
| assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST | |
| assert "Invalid" in exc_info.value.detail or "inactive" in exc_info.value.detail | |
| run_async(_test()) | |
| def test_anti_replay_token_reuse_different_user(): | |
| """Scenario 8: Token already claimed by User A is submitted by User B -> 409 Conflict.""" | |
| async def _test(): | |
| from app.payment_routes import verify_google_play_purchase | |
| token = "test_token_already_claimed_by_alice" | |
| existing_claim = { | |
| "purchase_token": token, | |
| "user_id": "user_alice", | |
| "plan_id": "sahih_pro_monthly", | |
| "renews_at": datetime.utcnow() + timedelta(days=20), | |
| } | |
| attacker_user = {"uid": "user_bob_attacker"} | |
| payload = { | |
| "purchase_token": token, | |
| "product_id": "sahih_pro_monthly", | |
| "package_name": "com.sahihislam.app", | |
| "purpose": "subscription", | |
| } | |
| with patch("app.payment_routes.subscriptions_collection.find_one", new_callable=AsyncMock) as mock_find_sub: | |
| mock_find_sub.return_value = existing_claim | |
| with pytest.raises(HTTPException) as exc_info: | |
| await verify_google_play_purchase(payload=payload, current_user=attacker_user) | |
| assert exc_info.value.status_code == status.HTTP_409_CONFLICT | |
| assert "already been claimed by another account" in exc_info.value.detail | |
| run_async(_test()) | |
| def test_idempotent_token_submission_same_user(): | |
| """Scenario 9: Same user re-submitting an already verified token receives idempotent 200 OK without duplicate billing.""" | |
| async def _test(): | |
| from app.payment_routes import verify_google_play_purchase | |
| token = "test_token_same_user_repeat" | |
| user_alice = {"uid": "user_alice"} | |
| expiry = datetime.utcnow() + timedelta(days=20) | |
| existing_claim = { | |
| "purchase_token": token, | |
| "user_id": "user_alice", | |
| "transaction_id": "GPA.PREVIOUS_TXN_001", | |
| "plan_id": "sahih_pro_monthly", | |
| "renews_at": expiry, | |
| } | |
| payload = { | |
| "purchase_token": token, | |
| "product_id": "sahih_pro_monthly", | |
| "package_name": "com.sahihislam.app", | |
| "purpose": "subscription", | |
| } | |
| with patch("app.payment_routes.subscriptions_collection.find_one", new_callable=AsyncMock) as mock_find_sub, \ | |
| patch("app.payment_routes.execute_payment_fulfillment", new_callable=AsyncMock) as mock_fulfill: | |
| mock_find_sub.return_value = existing_claim | |
| res = await verify_google_play_purchase(payload=payload, current_user=user_alice) | |
| assert res["success"] is True | |
| assert res["status"] == "already_verified" | |
| assert res["order_id"] == "GPA.PREVIOUS_TXN_001" | |
| assert not mock_fulfill.called | |
| run_async(_test()) | |
| # ============================================================================ | |
| # 3. Google RTDN Webhook Tests (Scenarios 10 - 11) | |
| # ============================================================================ | |
| def test_rtdn_renewal_webhook_updates_user(): | |
| """Scenario 10: RTDN notificationType=2 (SUBSCRIPTION_RENEWED) updates subscription and user expiry in MongoDB.""" | |
| async def _test(): | |
| from app.payment_routes import google_play_rtdn_webhook | |
| token = "test_rtdn_renew_token_55" | |
| sub_record = { | |
| "purchase_token": token, | |
| "user_id": "user_subscriber_99", | |
| "plan_id": "sahih_pro_monthly", | |
| "status": "active" | |
| } | |
| mock_request = AsyncMock(spec=Request) | |
| mock_request.json = AsyncMock(return_value={ | |
| "subscriptionNotification": { | |
| "notificationType": 2, # RENEWED | |
| "purchaseToken": token, | |
| "subscriptionId": "sahih_pro_monthly", | |
| } | |
| }) | |
| with patch("app.payment_routes.subscriptions_collection.find_one", new_callable=AsyncMock) as mock_find_sub, \ | |
| patch("app.payment_routes.subscriptions_collection.update_one", new_callable=AsyncMock) as mock_sub_update, \ | |
| patch("app.payment_routes.users_collection.update_one", new_callable=AsyncMock) as mock_user_update, \ | |
| patch("app.payment_routes.transactions_collection.insert_one", new_callable=AsyncMock), \ | |
| patch("app.payment_routes.invalidate_user_cache", new_callable=AsyncMock) as mock_invalidate: | |
| mock_find_sub.return_value = sub_record | |
| res = await google_play_rtdn_webhook(request=mock_request) | |
| assert res["status"] == "acknowledged" | |
| assert mock_sub_update.called | |
| sub_update_call = mock_sub_update.call_args[0] | |
| assert sub_update_call[0] == {"purchase_token": token} | |
| assert sub_update_call[1]["$set"]["status"] == "active" | |
| assert mock_user_update.called | |
| user_update_call = mock_user_update.call_args[0] | |
| assert user_update_call[0] == {"uid": "user_subscriber_99"} | |
| assert user_update_call[1]["$set"]["is_pro"] is True | |
| assert user_update_call[1]["$set"]["subscription.status"] == "ACTIVE" | |
| mock_invalidate.assert_called_with("user_subscriber_99") | |
| run_async(_test()) | |
| def test_rtdn_refund_webhook_revokes_pro(): | |
| """Scenario 11: RTDN notificationType=12 (SUBSCRIPTION_REVOKED) immediately strips Pro in user record.""" | |
| async def _test(): | |
| from app.payment_routes import google_play_rtdn_webhook | |
| token = "test_rtdn_refund_token_77" | |
| sub_record = { | |
| "purchase_token": token, | |
| "user_id": "user_refund_target", | |
| "plan_id": "sahih_pro_monthly", | |
| "status": "active" | |
| } | |
| mock_request = AsyncMock(spec=Request) | |
| mock_request.json = AsyncMock(return_value={ | |
| "subscriptionNotification": { | |
| "notificationType": 12, # REVOKED / REFUND | |
| "purchaseToken": token, | |
| "subscriptionId": "sahih_pro_monthly", | |
| } | |
| }) | |
| with patch("app.payment_routes.subscriptions_collection.find_one", new_callable=AsyncMock) as mock_find_sub, \ | |
| patch("app.payment_routes.subscriptions_collection.update_one", new_callable=AsyncMock) as mock_sub_update, \ | |
| patch("app.payment_routes.users_collection.update_one", new_callable=AsyncMock) as mock_user_update, \ | |
| patch("app.payment_routes.transactions_collection.insert_one", new_callable=AsyncMock), \ | |
| patch("app.payment_routes.invalidate_user_cache", new_callable=AsyncMock) as mock_invalidate: | |
| mock_find_sub.return_value = sub_record | |
| res = await google_play_rtdn_webhook(request=mock_request) | |
| assert res["status"] == "acknowledged" | |
| assert mock_sub_update.called | |
| assert mock_sub_update.call_args[0][1]["$set"]["status"] == "refunded" | |
| assert mock_user_update.called | |
| user_update_doc = mock_user_update.call_args[0][1]["$set"] | |
| assert user_update_doc["is_pro"] is False | |
| assert user_update_doc["subscription.is_pro"] is False | |
| assert user_update_doc["subscription.status"] == "REVOKED" | |
| mock_invalidate.assert_called_with("user_refund_target") | |
| run_async(_test()) | |
| # ============================================================================ | |
| # 4. Access Control, IDOR & Production Isolation (Scenarios 12 - 14) | |
| # ============================================================================ | |
| def test_idor_protection_enforce_self_or_admin(): | |
| """Scenario 12: enforce_self_or_admin prevents User A from acting on User B's resource.""" | |
| user_a = {"uid": "user_alpha", "role": "USER"} | |
| user_b_uid = "user_beta" | |
| admin_user = {"uid": "superadmin_1", "role": "ADMIN"} | |
| # User A acting on own resource -> OK | |
| enforce_self_or_admin(user_a, target_uid="user_alpha") | |
| # User A acting on User B -> 403 Forbidden | |
| with pytest.raises(HTTPException) as exc_info: | |
| enforce_self_or_admin(user_a, target_uid=user_b_uid) | |
| assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN | |
| assert "Cannot act on another user's resource" in exc_info.value.detail | |
| # Admin acting on User B -> OK | |
| enforce_self_or_admin(admin_user, target_uid=user_b_uid) | |
| def test_direct_subscribe_endpoint_locked(): | |
| """Scenario 13: Direct unverified subscription payload is rejected with 400 Bad Request.""" | |
| async def _test(): | |
| from app.payment_routes import subscribe_user | |
| mock_user = {"uid": "user_direct_cheat"} | |
| payload = SubscriptionCreate( | |
| plan_id="sahih_pro_annual", | |
| billing_cycle="annual", | |
| payment_method="direct_cheat_bypass", | |
| amount_paid=0.0, | |
| ) | |
| with pytest.raises(HTTPException) as exc_info: | |
| await subscribe_user(payload=payload, current_user=mock_user) | |
| assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST | |
| assert "Direct unverified subscription activation is disabled" in exc_info.value.detail | |
| run_async(_test()) | |
| def test_production_mock_token_forbidden(): | |
| """Scenario 14: In production mode (settings.IS_PROD=True), mock purchase tokens are rejected with 403.""" | |
| async def _test(): | |
| gps = GooglePlayService() | |
| with patch.object(settings, "IS_PROD", True), \ | |
| patch.dict(os.environ, {"TEST_MODE": "0", "ALLOW_MOCK_GOOGLE_PLAY": "0"}): | |
| with pytest.raises(HTTPException) as exc_info: | |
| await gps.verify_purchase( | |
| package_name="com.sahihislam.app", | |
| product_id="sahih_pro_monthly", | |
| purchase_token="mock_cheat_token_123", | |
| is_subscription=True | |
| ) | |
| assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN | |
| assert "Mock purchase tokens are strictly forbidden in production" in exc_info.value.detail | |
| run_async(_test()) | |