Files
orion/tests/unit/middleware/test_storefront_access.py
Samir Boulahtit 2c710ad416 feat: storefront subscription access guard + module-driven nav + URL rename
Add StorefrontAccessMiddleware that blocks storefront access for stores
without an active subscription, returning a multilingual unavailable page
(en/fr/de/lb) for page requests and JSON 403 for API requests. Multi-platform
aware: resolves subscription for detected platform with fallback to primary.

Also includes yesterday's session work:
- Module-driven storefront navigation via FrontendType.STOREFRONT menu declarations
- shop/ → storefront/ URL rename across 30+ templates
- Subscription context (tier_code) passed to storefront templates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:27:31 +01:00

788 lines
28 KiB
Python

# tests/unit/middleware/test_storefront_access.py
"""
Unit tests for StorefrontAccessMiddleware.
Tests cover:
- Passthrough for non-storefront frontend types (ADMIN, STORE, PLATFORM, MERCHANT)
- Passthrough for static file requests
- Blocking when no store is detected (not_found)
- Blocking when store has no subscription (not_activated)
- Blocking when subscription is inactive (not_activated)
- Passthrough when subscription is active (TRIAL, ACTIVE, PAST_DUE, CANCELLED)
- Multi-platform subscription resolution (platform-specific, fallback)
- API requests return JSON 403
- Page requests return HTML 403
- Language detection for unavailable page
- request.state.subscription and subscription_tier are set on passthrough
"""
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from fastapi import Request
from starlette.responses import JSONResponse
from app.modules.enums import FrontendType
from middleware.storefront_access import (
MESSAGES,
StorefrontAccessMiddleware,
_is_static_request,
)
# =============================================================================
# Helper: build a mock Request with the right state attributes
# =============================================================================
def _make_request(
path="/storefront/products",
frontend_type=FrontendType.STOREFRONT,
store=None,
platform=None,
language="en",
theme=None,
):
"""Create a mock Request with pre-set state attributes."""
request = Mock(spec=Request)
request.url = Mock(path=path)
request.state = Mock()
request.state.frontend_type = frontend_type
request.state.store = store
request.state.platform = platform
request.state.language = language
request.state.theme = theme
return request
def _make_store(store_id=1, subdomain="testshop", merchant_id=10):
"""Create a mock Store object."""
store = Mock()
store.id = store_id
store.subdomain = subdomain
store.merchant_id = merchant_id
store.name = "Test Shop"
return store
def _make_platform(platform_id=1):
"""Create a mock Platform object."""
platform = Mock()
platform.id = platform_id
platform.code = "oms"
return platform
def _make_subscription(is_active=True, tier_code="essential"):
"""Create a mock MerchantSubscription."""
tier = Mock()
tier.code = tier_code
tier.id = 1
sub = Mock()
sub.is_active = is_active
sub.tier = tier
sub.status = "active" if is_active else "expired"
return sub
# =============================================================================
# Static request detection
# =============================================================================
@pytest.mark.unit
class TestIsStaticRequest:
"""Test suite for _is_static_request helper."""
@pytest.mark.parametrize(
"path",
[
"/static/css/style.css",
"/static/js/app.js",
"/uploads/images/photo.jpg",
"/health",
"/docs",
"/redoc",
"/openapi.json",
"/storefront/favicon.ico",
"/some/path/favicon.ico",
"/static/storefront/css/tailwind.output.css",
],
)
def test_static_paths_detected(self, path):
"""Test that static/system paths are correctly detected."""
assert _is_static_request(path) is True
@pytest.mark.parametrize(
"path",
[
"/storefront/products",
"/storefront/",
"/api/v1/storefront/cart",
"/storefront/category/shoes",
],
)
def test_non_static_paths_not_detected(self, path):
"""Test that real storefront paths are not flagged as static."""
assert _is_static_request(path) is False
@pytest.mark.parametrize(
"path",
[
"/storefront/logo.png",
"/some/path/font.woff2",
"/assets/icon.svg",
"/file.map",
],
)
def test_static_extensions_detected(self, path):
"""Test that paths ending with static extensions are detected."""
assert _is_static_request(path) is True
def test_case_insensitive(self):
"""Test detection is case-insensitive."""
assert _is_static_request("/STATIC/CSS/STYLE.CSS") is True
assert _is_static_request("/Uploads/Image.PNG") is True
# =============================================================================
# Middleware passthrough tests (non-storefront)
# =============================================================================
@pytest.mark.unit
class TestStorefrontAccessMiddlewarePassthrough:
"""Test that non-storefront requests pass through without checks."""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"frontend_type",
[
FrontendType.ADMIN,
FrontendType.STORE,
FrontendType.PLATFORM,
FrontendType.MERCHANT,
],
)
async def test_non_storefront_passes_through(self, frontend_type):
"""Test non-storefront frontend types are not gated."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(frontend_type=frontend_type)
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_no_frontend_type_passes_through(self):
"""Test request with no frontend_type set passes through."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request()
request.state.frontend_type = None
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_static_file_passes_through(self):
"""Test storefront static file requests pass through."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(path="/static/css/style.css")
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_favicon_passes_through(self):
"""Test favicon requests pass through."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(path="/storefront/favicon.ico")
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
# =============================================================================
# Blocking tests (no store / no subscription)
# =============================================================================
@pytest.mark.unit
class TestStorefrontAccessMiddlewareBlocking:
"""Test that requests are blocked when store/subscription is missing."""
@pytest.mark.asyncio
async def test_no_store_returns_not_found(self):
"""Test 'not_found' when no store is detected."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(store=None)
call_next = AsyncMock()
with patch.object(middleware, "_render_unavailable") as mock_render:
mock_render.return_value = Mock()
await middleware.dispatch(request, call_next)
mock_render.assert_called_once_with(request, "not_found")
call_next.assert_not_called()
@pytest.mark.asyncio
async def test_no_subscription_returns_not_activated(self):
"""Test 'not_activated' when store exists but no subscription."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
platform = _make_platform()
request = _make_request(store=store, platform=platform)
call_next = AsyncMock()
mock_db = MagicMock()
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch.object(
middleware,
"_get_subscription",
return_value=None,
),
):
with patch.object(middleware, "_render_unavailable") as mock_render:
mock_render.return_value = Mock()
await middleware.dispatch(request, call_next)
mock_render.assert_called_once_with(
request, "not_activated", store
)
call_next.assert_not_called()
@pytest.mark.asyncio
async def test_inactive_subscription_returns_not_activated(self):
"""Test 'not_activated' when subscription exists but is inactive."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
platform = _make_platform()
request = _make_request(store=store, platform=platform)
call_next = AsyncMock()
mock_db = MagicMock()
inactive_sub = _make_subscription(is_active=False)
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch.object(
middleware,
"_get_subscription",
return_value=inactive_sub,
),
):
with patch.object(middleware, "_render_unavailable") as mock_render:
mock_render.return_value = Mock()
await middleware.dispatch(request, call_next)
mock_render.assert_called_once_with(
request, "not_activated", store
)
call_next.assert_not_called()
@pytest.mark.asyncio
async def test_db_session_closed_on_block(self):
"""Test database session is closed even when request is blocked."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
request = _make_request(store=store)
call_next = AsyncMock()
mock_db = MagicMock()
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch.object(middleware, "_get_subscription", return_value=None),
patch.object(
middleware, "_render_unavailable", return_value=Mock()
),
):
await middleware.dispatch(request, call_next)
mock_db.close.assert_called_once()
# =============================================================================
# Active subscription passthrough
# =============================================================================
@pytest.mark.unit
class TestStorefrontAccessMiddlewareActiveSubscription:
"""Test passthrough and state injection for active subscriptions."""
@pytest.mark.asyncio
async def test_active_subscription_passes_through(self):
"""Test active subscription lets request through."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
platform = _make_platform()
request = _make_request(store=store, platform=platform)
call_next = AsyncMock(return_value=Mock())
mock_db = MagicMock()
active_sub = _make_subscription(is_active=True, tier_code="professional")
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch.object(
middleware, "_get_subscription", return_value=active_sub
),
):
await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_sets_subscription_on_request_state(self):
"""Test subscription and tier are stored on request.state."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
platform = _make_platform()
request = _make_request(store=store, platform=platform)
call_next = AsyncMock(return_value=Mock())
mock_db = MagicMock()
active_sub = _make_subscription(is_active=True, tier_code="essential")
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch.object(
middleware, "_get_subscription", return_value=active_sub
),
):
await middleware.dispatch(request, call_next)
assert request.state.subscription is active_sub
assert request.state.subscription_tier is active_sub.tier
@pytest.mark.asyncio
async def test_db_session_closed_on_success(self):
"""Test database session is closed after successful passthrough."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
request = _make_request(store=store)
call_next = AsyncMock(return_value=Mock())
mock_db = MagicMock()
active_sub = _make_subscription(is_active=True)
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch.object(
middleware, "_get_subscription", return_value=active_sub
),
):
await middleware.dispatch(request, call_next)
mock_db.close.assert_called_once()
# =============================================================================
# Multi-platform subscription resolution
# =============================================================================
@pytest.mark.unit
class TestGetSubscription:
"""Test _get_subscription multi-platform resolution logic."""
def test_uses_detected_platform(self):
"""Test subscription is fetched for the detected platform."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store(merchant_id=10)
platform = _make_platform(platform_id=2)
request = _make_request(store=store, platform=platform)
mock_db = MagicMock()
expected_sub = _make_subscription()
with patch(
"app.modules.billing.services.subscription_service.subscription_service"
) as mock_svc:
mock_svc.get_merchant_subscription.return_value = expected_sub
result = middleware._get_subscription(mock_db, store, request)
mock_svc.get_merchant_subscription.assert_called_once_with(
mock_db, 10, 2
)
assert result is expected_sub
def test_falls_back_to_store_primary_platform(self):
"""Test fallback to get_subscription_for_store when platform sub is None."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store(store_id=5, merchant_id=10)
platform = _make_platform(platform_id=2)
request = _make_request(store=store, platform=platform)
mock_db = MagicMock()
fallback_sub = _make_subscription(tier_code="starter")
with patch(
"app.modules.billing.services.subscription_service.subscription_service"
) as mock_svc:
mock_svc.get_merchant_subscription.return_value = None
mock_svc.get_subscription_for_store.return_value = fallback_sub
result = middleware._get_subscription(mock_db, store, request)
mock_svc.get_merchant_subscription.assert_called_once_with(
mock_db, 10, 2
)
mock_svc.get_subscription_for_store.assert_called_once_with(
mock_db, 5
)
assert result is fallback_sub
def test_no_platform_uses_store_fallback(self):
"""Test when no platform is detected, falls back to store-based lookup."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store(store_id=7)
request = _make_request(store=store, platform=None)
mock_db = MagicMock()
fallback_sub = _make_subscription()
with patch(
"app.modules.billing.services.subscription_service.subscription_service"
) as mock_svc:
mock_svc.get_subscription_for_store.return_value = fallback_sub
result = middleware._get_subscription(mock_db, store, request)
mock_svc.get_merchant_subscription.assert_not_called()
mock_svc.get_subscription_for_store.assert_called_once_with(
mock_db, 7
)
assert result is fallback_sub
# =============================================================================
# Response rendering tests
# =============================================================================
@pytest.mark.unit
class TestRenderUnavailable:
"""Test _render_unavailable response generation."""
def test_api_request_returns_json_403(self):
"""Test API requests get JSON 403 response."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(path="/api/v1/storefront/cart")
response = middleware._render_unavailable(request, "not_activated")
assert isinstance(response, JSONResponse)
assert response.status_code == 403
assert response.body is not None
# Decode JSON body
import json
body = json.loads(response.body)
assert body["error"] == "storefront_not_available"
assert body["reason"] == "not_activated"
def test_api_not_found_returns_json_403(self):
"""Test API not_found also gets JSON 403."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(path="/api/v1/storefront/products")
response = middleware._render_unavailable(request, "not_found")
assert isinstance(response, JSONResponse)
assert response.status_code == 403
import json
body = json.loads(response.body)
assert body["reason"] == "not_found"
def test_page_request_renders_template(self):
"""Test page requests render the unavailable template."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(path="/storefront/products", language="en")
mock_template_response = Mock(status_code=403)
with patch("app.templates_config.templates") as mock_templates:
mock_templates.TemplateResponse.return_value = mock_template_response
middleware._render_unavailable(
request, "not_activated", store=_make_store()
)
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
assert call_args[0][0] == "storefront/unavailable.html"
context = call_args[0][1]
assert context["request"] is request
assert context["reason"] == "not_activated"
assert context["title"] == MESSAGES["not_activated"]["en"]["title"]
assert context["message"] == MESSAGES["not_activated"]["en"]["message"]
assert context["language"] == "en"
assert call_args[1]["status_code"] == 403
@pytest.mark.parametrize("language", ["en", "fr", "de", "lb"])
def test_page_request_uses_correct_language(self, language):
"""Test unavailable page renders in the detected language."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(
path="/storefront/", language=language
)
with patch("app.templates_config.templates") as mock_templates:
mock_templates.TemplateResponse.return_value = Mock(status_code=403)
middleware._render_unavailable(request, "not_found")
context = mock_templates.TemplateResponse.call_args[0][1]
assert context["language"] == language
assert context["title"] == MESSAGES["not_found"][language]["title"]
def test_unsupported_language_falls_back_to_english(self):
"""Test unsupported language falls back to English."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(
path="/storefront/", language="pt"
)
with patch("app.templates_config.templates") as mock_templates:
mock_templates.TemplateResponse.return_value = Mock(status_code=403)
middleware._render_unavailable(request, "not_activated")
context = mock_templates.TemplateResponse.call_args[0][1]
assert context["language"] == "en"
assert context["title"] == MESSAGES["not_activated"]["en"]["title"]
def test_page_request_includes_store_when_provided(self):
"""Test store object is passed to template when available."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
request = _make_request(path="/storefront/")
with patch("app.templates_config.templates") as mock_templates:
mock_templates.TemplateResponse.return_value = Mock(status_code=403)
middleware._render_unavailable(request, "not_activated", store=store)
context = mock_templates.TemplateResponse.call_args[0][1]
assert context["store"] is store
def test_page_request_store_none_for_not_found(self):
"""Test store is None for not_found case."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(path="/storefront/")
with patch("app.templates_config.templates") as mock_templates:
mock_templates.TemplateResponse.return_value = Mock(status_code=403)
middleware._render_unavailable(request, "not_found")
context = mock_templates.TemplateResponse.call_args[0][1]
assert context["store"] is None
# =============================================================================
# Messages dict validation
# =============================================================================
@pytest.mark.unit
class TestMessages:
"""Validate the MESSAGES dict structure."""
def test_all_reasons_have_all_languages(self):
"""Test every reason has en, fr, de, lb translations."""
for reason in ("not_found", "not_activated"):
assert reason in MESSAGES
for lang in ("en", "fr", "de", "lb"):
assert lang in MESSAGES[reason], f"Missing {lang} for {reason}"
assert "title" in MESSAGES[reason][lang]
assert "message" in MESSAGES[reason][lang]
def test_messages_are_non_empty_strings(self):
"""Test all message values are non-empty strings."""
for reason in MESSAGES:
for lang in MESSAGES[reason]:
for field in ("title", "message"):
value = MESSAGES[reason][lang][field]
assert isinstance(value, str)
assert len(value) > 0
# =============================================================================
# Full integration-style dispatch tests
# =============================================================================
@pytest.mark.unit
class TestStorefrontAccessMiddlewareDispatchIntegration:
"""End-to-end dispatch tests exercising the full middleware flow."""
@pytest.mark.asyncio
async def test_full_flow_active_subscription(self):
"""Test full dispatch: store + active subscription → passthrough."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store(merchant_id=10)
platform = _make_platform(platform_id=2)
request = _make_request(
path="/storefront/products",
store=store,
platform=platform,
)
expected_response = Mock()
call_next = AsyncMock(return_value=expected_response)
mock_db = MagicMock()
active_sub = _make_subscription(is_active=True, tier_code="professional")
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch(
"app.modules.billing.services.subscription_service.subscription_service"
) as mock_svc,
):
mock_svc.get_merchant_subscription.return_value = active_sub
result = await middleware.dispatch(request, call_next)
assert result is expected_response
assert request.state.subscription is active_sub
assert request.state.subscription_tier is active_sub.tier
call_next.assert_called_once_with(request)
mock_db.close.assert_called_once()
@pytest.mark.asyncio
async def test_full_flow_no_subscription_page_request(self):
"""Test full dispatch: store + no subscription + page → HTML 403."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
platform = _make_platform()
request = _make_request(
path="/storefront/",
store=store,
platform=platform,
language="fr",
)
call_next = AsyncMock()
mock_db = MagicMock()
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch(
"app.modules.billing.services.subscription_service.subscription_service"
) as mock_svc,
patch("app.templates_config.templates") as mock_templates,
):
mock_svc.get_merchant_subscription.return_value = None
mock_svc.get_subscription_for_store.return_value = None
mock_templates.TemplateResponse.return_value = Mock(status_code=403)
await middleware.dispatch(request, call_next)
call_next.assert_not_called()
mock_templates.TemplateResponse.assert_called_once()
context = mock_templates.TemplateResponse.call_args[0][1]
assert context["language"] == "fr"
assert context["reason"] == "not_activated"
mock_db.close.assert_called_once()
@pytest.mark.asyncio
async def test_full_flow_no_subscription_api_request(self):
"""Test full dispatch: store + no subscription + API → JSON 403."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
platform = _make_platform()
request = _make_request(
path="/api/v1/storefront/cart",
store=store,
platform=platform,
)
call_next = AsyncMock()
mock_db = MagicMock()
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch(
"app.modules.billing.services.subscription_service.subscription_service"
) as mock_svc,
):
mock_svc.get_merchant_subscription.return_value = None
mock_svc.get_subscription_for_store.return_value = None
result = await middleware.dispatch(request, call_next)
call_next.assert_not_called()
assert isinstance(result, JSONResponse)
assert result.status_code == 403
mock_db.close.assert_called_once()
@pytest.mark.asyncio
async def test_full_flow_no_store_detected(self):
"""Test full dispatch: no store → not_found response."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(path="/storefront/", store=None)
call_next = AsyncMock()
with patch("app.templates_config.templates") as mock_templates:
mock_templates.TemplateResponse.return_value = Mock(status_code=403)
await middleware.dispatch(request, call_next)
call_next.assert_not_called()
context = mock_templates.TemplateResponse.call_args[0][1]
assert context["reason"] == "not_found"
assert context["store"] is None
@pytest.mark.asyncio
async def test_db_closed_on_exception(self):
"""Test database session is closed even when _get_subscription raises."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
request = _make_request(store=store)
call_next = AsyncMock()
mock_db = MagicMock()
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch.object(
middleware,
"_get_subscription",
side_effect=Exception("db error"),
),
pytest.raises(Exception, match="db error"),
):
await middleware.dispatch(request, call_next)
mock_db.close.assert_called_once()