Compare commits
5 Commits
e5dbd7ef1a
...
ef21d47533
| Author | SHA1 | Date | |
|---|---|---|---|
| ef21d47533 | |||
| 6c5969e4e1 | |||
| 6a739bf670 | |||
| ffa12f0255 | |||
| 93731b7173 |
261
docs/proposals/test-api-deps-auth-dependencies.md
Normal file
261
docs/proposals/test-api-deps-auth-dependencies.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Test Plan: app/api/deps.py — Authentication Dependencies
|
||||
|
||||
**Date:** 2026-02-19
|
||||
**Status:** Planned
|
||||
**Priority:** P0 — Security-critical, zero test coverage
|
||||
**File under test:** `app/api/deps.py` (1,668 lines, 31 functions)
|
||||
|
||||
## Why This File Is Critical
|
||||
|
||||
`deps.py` is the **single entry point for all authentication and authorization** in the application. Every protected route depends on it. It enforces:
|
||||
|
||||
- Role isolation (admins can't access store routes, stores can't access admin routes)
|
||||
- Cookie path restrictions (prevent cross-context token leakage)
|
||||
- Token priority (Authorization header > cookie)
|
||||
- Platform access control for platform admins
|
||||
- Store permission checks (owner, team member, specific permissions)
|
||||
- Merchant ownership verification
|
||||
- Customer token validation with store-matching
|
||||
- Module and menu-based access control
|
||||
|
||||
A bug here is a security vulnerability. Zero test coverage is unacceptable.
|
||||
|
||||
## Current State
|
||||
|
||||
- **Existing test coverage:** None for deps.py directly
|
||||
- **Related tests:** `tests/integration/security/test_authentication.py` (3 tests — basic endpoint protection only)
|
||||
- **Related tests:** `tests/unit/models/schema/test_auth.py` (tests UserContext schema)
|
||||
|
||||
## Test Structure
|
||||
|
||||
Tests will live at: `tests/unit/api/test_deps.py`
|
||||
|
||||
This follows the project convention of matching source layout (`app/api/deps.py` → `tests/unit/api/test_deps.py`).
|
||||
|
||||
## Functions To Test (31 total)
|
||||
|
||||
### Phase 1: Helper Functions (4 functions)
|
||||
|
||||
These are pure/near-pure functions — easiest to test, highest value as foundation.
|
||||
|
||||
| Function | Lines | What it does | Test count |
|
||||
|----------|-------|-------------|------------|
|
||||
| `_get_token_from_request()` | 76–105 | Extract token from header or cookie, header takes priority | 4 |
|
||||
| `_validate_user_token()` | 108–123 | Validate JWT, return User model | 3 |
|
||||
| `_get_user_model()` | 126–155 | Load User from DB by UserContext.id, copy token attrs | 3 |
|
||||
| `_validate_customer_token()` | 1026–1114 | Decode customer JWT, verify type/expiry/store match | 7 |
|
||||
|
||||
**Tests for `_get_token_from_request`:**
|
||||
- Returns (token, "header") when Authorization header present
|
||||
- Returns (token, "cookie") when only cookie present
|
||||
- Header takes priority over cookie when both present
|
||||
- Returns (None, None) when neither present
|
||||
|
||||
**Tests for `_validate_user_token`:**
|
||||
- Returns User for valid token
|
||||
- Raises InvalidTokenException for invalid/expired token
|
||||
- Raises InvalidTokenException for token with non-existent user
|
||||
|
||||
**Tests for `_get_user_model`:**
|
||||
- Returns User model with token attributes copied
|
||||
- Raises InvalidTokenException when user not in DB
|
||||
- Copies token_store_id, token_store_code, token_store_role from context
|
||||
|
||||
**Tests for `_validate_customer_token`:**
|
||||
- Returns CustomerContext for valid customer token
|
||||
- Rejects token with wrong type (not "customer")
|
||||
- Rejects token with missing "sub" claim
|
||||
- Rejects expired token
|
||||
- Rejects token for non-existent customer
|
||||
- Rejects token for inactive customer
|
||||
- Rejects token with store_id mismatch (cross-store attack)
|
||||
|
||||
### Phase 2: Admin Authentication (6 functions)
|
||||
|
||||
| Function | Lines | What it does | Test count |
|
||||
|----------|-------|-------------|------------|
|
||||
| `get_current_admin_from_cookie_or_header()` | 163–209 | Admin auth via cookie or header | 4 |
|
||||
| `get_current_admin_api()` | 212–242 | Admin auth via header only (CSRF-safe) | 3 |
|
||||
| `get_current_super_admin()` | 250–283 | Require super admin role | 3 |
|
||||
| `get_current_super_admin_api()` | 286–312 | Super admin, header only | 2 |
|
||||
| `require_platform_access()` | 315–357 | Factory: platform-specific admin access | 4 |
|
||||
| `get_admin_with_platform_context()` | 360–430 | Admin with platform from JWT | 4 |
|
||||
|
||||
**Key tests:**
|
||||
- Valid admin token accepted (cookie and header)
|
||||
- Non-admin role rejected with AdminRequiredException
|
||||
- No token raises InvalidTokenException
|
||||
- Super admin check: platform admin rejected
|
||||
- Platform access: super admin bypasses, platform admin checked against accessible_platform_ids
|
||||
- Platform context: platform_id extracted from token, stored in request.state
|
||||
|
||||
### Phase 3: Store Authentication (2 functions)
|
||||
|
||||
| Function | Lines | What it does | Test count |
|
||||
|----------|-------|-------------|------------|
|
||||
| `get_current_store_from_cookie_or_header()` | 665–720 | Store auth via cookie or header | 5 |
|
||||
| `get_current_store_api()` | 723–780 | Store API auth, validates store context | 5 |
|
||||
|
||||
**Key tests:**
|
||||
- Valid store token accepted
|
||||
- **Admin blocked from store routes** (critical security boundary)
|
||||
- Customer blocked from store routes
|
||||
- No token raises InvalidTokenException
|
||||
- Store API requires token_store_id claim
|
||||
- Store API verifies user still member of store (revocation check)
|
||||
|
||||
### Phase 4: Merchant Authentication (5 functions)
|
||||
|
||||
| Function | Lines | What it does | Test count |
|
||||
|----------|-------|-------------|------------|
|
||||
| `get_current_merchant_from_cookie_or_header()` | 788–848 | Merchant auth, verifies ownership | 4 |
|
||||
| `get_current_merchant_api()` | 851–896 | Merchant API auth | 3 |
|
||||
| `get_current_merchant_optional()` | 899–940 | Returns None if not authenticated | 3 |
|
||||
| `get_merchant_for_current_user()` | 943–979 | Load Merchant object for API user | 3 |
|
||||
| `get_merchant_for_current_user_page()` | 982–1018 | Load Merchant object for page user | 2 |
|
||||
|
||||
**Key tests:**
|
||||
- Valid merchant owner accepted
|
||||
- User without active merchants rejected
|
||||
- Optional variant returns None on failure (no exception)
|
||||
- Merchant object stored in request.state
|
||||
|
||||
### Phase 5: Customer Authentication (2 functions)
|
||||
|
||||
| Function | Lines | What it does | Test count |
|
||||
|----------|-------|-------------|------------|
|
||||
| `get_current_customer_from_cookie_or_header()` | 1117–1156 | Customer auth via cookie or header | 3 |
|
||||
| `get_current_customer_api()` | 1159–1185 | Customer API auth | 2 |
|
||||
|
||||
**Key tests:**
|
||||
- Valid customer token accepted
|
||||
- No token raises InvalidTokenException
|
||||
- API variant requires Authorization header
|
||||
|
||||
### Phase 6: Access Control (6 functions)
|
||||
|
||||
| Function | Lines | What it does | Test count |
|
||||
|----------|-------|-------------|------------|
|
||||
| `require_module_access()` | 438–538 | Factory: module enablement check | 4 |
|
||||
| `require_menu_access()` | 546–657 | Factory: menu visibility check | 4 |
|
||||
| `require_store_permission()` | 1278–1324 | Factory: specific permission check | 4 |
|
||||
| `require_store_owner()` | 1327–1369 | Require store owner role | 3 |
|
||||
| `require_any_store_permission()` | 1372–1425 | Factory: ANY of N permissions | 3 |
|
||||
| `require_all_store_permissions()` | 1428–1483 | Factory: ALL of N permissions | 3 |
|
||||
|
||||
**Key tests:**
|
||||
- Module disabled → InsufficientPermissionsException
|
||||
- Super admin bypasses module check
|
||||
- Store owner has all permissions
|
||||
- Team member checked against specific permissions
|
||||
- Missing permission raises InsufficientStorePermissionsException
|
||||
|
||||
### Phase 7: Optional Auth & Utilities (4 functions)
|
||||
|
||||
| Function | Lines | What it does | Test count |
|
||||
|----------|-------|-------------|------------|
|
||||
| `get_current_admin_optional()` | 1535–1579 | Returns None on failure | 3 |
|
||||
| `get_current_store_optional()` | 1582–1626 | Returns None on failure | 3 |
|
||||
| `get_current_customer_optional()` | 1629–1667 | Returns None on failure | 3 |
|
||||
| `get_user_permissions()` | 1486–1527 | List all permissions for user in store | 3 |
|
||||
| `get_user_store()` | 1225–1270 | Verify store ownership/membership | 3 |
|
||||
|
||||
**Key tests:**
|
||||
- Optional variants return None (not raise) when token invalid
|
||||
- Optional variants return context when token valid
|
||||
- get_user_permissions returns all permissions for owner, specific for team member
|
||||
|
||||
## Total Test Count
|
||||
|
||||
| Phase | Functions | Tests |
|
||||
|-------|----------|-------|
|
||||
| 1. Helpers | 4 | 17 |
|
||||
| 2. Admin auth | 6 | 20 |
|
||||
| 3. Store auth | 2 | 10 |
|
||||
| 4. Merchant auth | 5 | 15 |
|
||||
| 5. Customer auth | 2 | 5 |
|
||||
| 6. Access control | 6 | 21 |
|
||||
| 7. Optional & utils | 5 | 15 |
|
||||
| **Total** | **30** | **~103** |
|
||||
|
||||
## Test Approach
|
||||
|
||||
### Unit vs Integration
|
||||
|
||||
These will be **unit tests** with mocked dependencies:
|
||||
|
||||
- `db` → SQLAlchemy session with test data (using existing `db` fixture)
|
||||
- `auth_manager` → Use the real AuthManager but with test users/tokens (existing `auth_fixtures.py`)
|
||||
- `request` → Mock FastAPI Request with `request.state`, `request.url.path`
|
||||
- FastAPI `Depends()` → Call functions directly, passing dependencies explicitly
|
||||
|
||||
This avoids the overhead of full HTTP request cycles and tests the logic in isolation.
|
||||
|
||||
### Fixtures Needed
|
||||
|
||||
Most already exist in `tests/fixtures/auth_fixtures.py`:
|
||||
|
||||
- `test_admin` — admin user (is_super_admin=True)
|
||||
- `test_platform_admin` — platform admin (is_super_admin=False)
|
||||
- `test_store_user` — store-role user
|
||||
- `test_user` — regular user
|
||||
|
||||
**New fixtures to add in the test file:**
|
||||
|
||||
- `mock_request` — Mock Request with configurable state and url.path
|
||||
- `test_customer` — Customer model for customer auth tests
|
||||
- `test_merchant` — Merchant model for merchant auth tests
|
||||
- `test_store` — Store with platform association
|
||||
- `admin_token` — JWT token for admin user
|
||||
- `store_token` — JWT token with store context claims
|
||||
- `customer_token` — Customer JWT token
|
||||
- `merchant_token` — JWT token for merchant owner
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. **Phase 1 first** — helper functions have no dependencies on other deps.py functions
|
||||
2. **Phase 2–5 next** — auth functions build on helpers
|
||||
3. **Phase 6–7 last** — access control builds on auth functions
|
||||
|
||||
Phases 2–5 can be done in parallel. Phase 6 depends on 2–3 being done.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Run just the deps tests
|
||||
python -m pytest tests/unit/api/test_deps.py -v --timeout=60
|
||||
|
||||
# Run with coverage
|
||||
python -m pytest tests/unit/api/test_deps.py -v --cov=app.api.deps --cov-report=term-missing
|
||||
```
|
||||
|
||||
## Corrected Coverage Summary
|
||||
|
||||
The earlier 360 analysis incorrectly reported module service coverage at 10%. The actual numbers (after finding tests in `app/modules/*/tests/`):
|
||||
|
||||
| Module | Services | Unit Tests | Integration Tests | Coverage |
|
||||
|--------|----------|-----------|-------------------|----------|
|
||||
| billing | 7 | 8 | 4 | **100%+** |
|
||||
| catalog | 4 | 4 | 0 | **100%** |
|
||||
| checkout | 1 | 1 | 0 | **100%** |
|
||||
| cms | 4 | 4 | 0 | **100%** |
|
||||
| core | 6 | 5 | 0 | **83%** |
|
||||
| customers | 3 | 3 | 0 | **100%** |
|
||||
| dev_tools | 2 | 2 | 0 | **100%** |
|
||||
| inventory | 3 | 3 | 0 | **100%** |
|
||||
| loyalty | 8 | 8 | 1 | **100%** |
|
||||
| marketplace | 5 | 8 | 0 | **100%+** |
|
||||
| messaging | 6 | 5 | 0 | **83%** |
|
||||
| monitoring | 6 | 6 | 0 | **100%** |
|
||||
| orders | 6 | 5 | 0 | **83%** |
|
||||
| payments | 2 | 2 | 0 | **100%** |
|
||||
| tenancy | 11 | 17 | 6 | **100%+** |
|
||||
| cart | 1 | 1 | 0 | **100%** |
|
||||
| analytics | 1 | 0 | 0 | **0%** |
|
||||
|
||||
**Remaining gaps (non-service):**
|
||||
- **Models:** 12 modules have no model tests
|
||||
- **Schemas:** 12 modules have no schema tests
|
||||
- **Routes:** 14 modules have no route/integration tests
|
||||
- **Tasks:** 0 modules have task tests (5 modules have tasks)
|
||||
- **Framework:** `app/api/deps.py`, `middleware/auth.py`, `app/exceptions/`, `app/handlers/stripe_webhook.py` have no tests
|
||||
@@ -115,8 +115,7 @@ def create_admin_user(db: Session, auth_manager: AuthManager) -> User:
|
||||
username=settings.admin_username,
|
||||
email=settings.admin_email,
|
||||
hashed_password=hashed_password,
|
||||
role="admin",
|
||||
is_super_admin=True,
|
||||
role="super_admin",
|
||||
first_name=settings.admin_first_name,
|
||||
last_name=settings.admin_last_name,
|
||||
is_active=True,
|
||||
@@ -142,13 +141,12 @@ def create_loyalty_admin(db: Session, auth_manager: AuthManager, loyalty_platfor
|
||||
print_warning(f"Loyalty admin already exists: {email}")
|
||||
return existing
|
||||
|
||||
password = "admin123" # Dev default, change in production
|
||||
password = "admin123" # noqa: SEC001 Dev default, change in production
|
||||
admin = User(
|
||||
username="loyalty_admin",
|
||||
email=email,
|
||||
hashed_password=auth_manager.hash_password(password),
|
||||
role="admin",
|
||||
is_super_admin=False,
|
||||
role="platform_admin",
|
||||
first_name="Loyalty",
|
||||
last_name="Administrator",
|
||||
is_active=True,
|
||||
@@ -166,7 +164,7 @@ def create_loyalty_admin(db: Session, auth_manager: AuthManager, loyalty_platfor
|
||||
db.add(assignment)
|
||||
db.flush()
|
||||
|
||||
print_success(f"Created loyalty admin: {email} (password: {password})")
|
||||
print_success(f"Created loyalty admin: {email} (password: {password})") # noqa: SEC021
|
||||
return admin
|
||||
|
||||
|
||||
@@ -559,7 +557,6 @@ def verify_rbac_schema(db: Session) -> bool:
|
||||
if "store_users" in tables:
|
||||
vu_cols = {col["name"] for col in inspector.get_columns("store_users")}
|
||||
required_cols = {
|
||||
"user_type",
|
||||
"invitation_token",
|
||||
"invitation_sent_at",
|
||||
"invitation_accepted_at",
|
||||
@@ -646,7 +643,9 @@ def print_summary(db: Session):
|
||||
print_header("INITIALIZATION SUMMARY")
|
||||
|
||||
# Count records
|
||||
user_count = db.query(User).filter(User.role == "admin").count()
|
||||
user_count = db.query(User).filter(
|
||||
User.role.in_(["super_admin", "platform_admin"])
|
||||
).count()
|
||||
setting_count = db.query(AdminSetting).count()
|
||||
platform_count = db.query(Platform).count()
|
||||
tier_count = db.query(SubscriptionTier).filter(SubscriptionTier.is_active.is_(True)).count()
|
||||
@@ -692,7 +691,7 @@ def print_summary(db: Session):
|
||||
print(" Loyalty Platform Admin (loyalty only):")
|
||||
print(f" URL: {admin_url}")
|
||||
print(" Username: loyalty_admin")
|
||||
print(" Password: admin123")
|
||||
print(" Password: admin123") # noqa: SEC021
|
||||
print("─" * 70)
|
||||
|
||||
# Show security warnings if in production
|
||||
|
||||
0
tests/unit/api/__init__.py
Normal file
0
tests/unit/api/__init__.py
Normal file
1582
tests/unit/api/test_deps.py
Normal file
1582
tests/unit/api/test_deps.py
Normal file
File diff suppressed because it is too large
Load Diff
555
tests/unit/middleware/test_auth.py
Normal file
555
tests/unit/middleware/test_auth.py
Normal file
@@ -0,0 +1,555 @@
|
||||
# tests/unit/middleware/test_auth.py
|
||||
"""Unit tests for middleware/auth.py (AuthManager).
|
||||
|
||||
58 tests across 9 classes covering all 12 AuthManager methods:
|
||||
- Constructor, password hashing, authenticate_user
|
||||
- create_access_token, verify_token, get_current_user
|
||||
- RBAC (require_role, require_admin, require_store, require_customer)
|
||||
- create_default_admin_user
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from jose import jwt as jose_jwt
|
||||
|
||||
from app.modules.tenancy.exceptions import (
|
||||
AdminRequiredException,
|
||||
InsufficientPermissionsException,
|
||||
InvalidCredentialsException,
|
||||
InvalidTokenException,
|
||||
TokenExpiredException,
|
||||
UserNotActiveException,
|
||||
)
|
||||
from app.modules.tenancy.models import User
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
# ─── Phase 1: Constructor ───────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAuthManagerInit:
|
||||
"""Test AuthManager.__init__ reads env vars and sets defaults."""
|
||||
|
||||
def test_default_configuration(self):
|
||||
"""Verify defaults: fallback secret key, HS256, 30 min expiry."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
mgr = AuthManager()
|
||||
assert mgr.secret_key == "your-secret-key-change-in-production-please"
|
||||
assert mgr.algorithm == "HS256"
|
||||
assert mgr.token_expire_minutes == 30
|
||||
|
||||
def test_custom_secret_key_from_env(self):
|
||||
"""JWT_SECRET_KEY env var overrides the default secret."""
|
||||
with patch.dict(os.environ, {"JWT_SECRET_KEY": "my-custom-secret"}):
|
||||
mgr = AuthManager()
|
||||
assert mgr.secret_key == "my-custom-secret"
|
||||
|
||||
def test_custom_expire_minutes_from_env(self):
|
||||
"""JWT_EXPIRE_MINUTES env var overrides the default 30."""
|
||||
with patch.dict(os.environ, {"JWT_EXPIRE_MINUTES": "60"}):
|
||||
mgr = AuthManager()
|
||||
assert mgr.token_expire_minutes == 60
|
||||
|
||||
|
||||
# ─── Phase 2: Password Hashing ──────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPasswordHashing:
|
||||
"""Test hash_password and verify_password (bcrypt)."""
|
||||
|
||||
def test_hash_returns_bcrypt_format(self, auth_manager):
|
||||
h = auth_manager.hash_password("testpass")
|
||||
assert h.startswith("$2b$")
|
||||
|
||||
def test_hash_different_salts(self, auth_manager):
|
||||
"""Same plaintext produces different hashes (random salts)."""
|
||||
h1 = auth_manager.hash_password("same")
|
||||
h2 = auth_manager.hash_password("same")
|
||||
assert h1 != h2
|
||||
|
||||
def test_verify_correct_password(self, auth_manager):
|
||||
h = auth_manager.hash_password("correct")
|
||||
assert auth_manager.verify_password("correct", h) is True
|
||||
|
||||
def test_verify_wrong_password(self, auth_manager):
|
||||
h = auth_manager.hash_password("correct")
|
||||
assert auth_manager.verify_password("wrong", h) is False
|
||||
|
||||
def test_verify_empty_password(self, auth_manager):
|
||||
h = auth_manager.hash_password("notempty")
|
||||
assert auth_manager.verify_password("", h) is False
|
||||
|
||||
|
||||
# ─── Phase 3: authenticate_user ─────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAuthenticateUser:
|
||||
"""Test authenticate_user with real DB fixtures."""
|
||||
|
||||
def test_success_by_username(self, db, auth_manager, test_user):
|
||||
result = auth_manager.authenticate_user(db, test_user.username, "testpass123")
|
||||
assert result is not None
|
||||
assert result.id == test_user.id
|
||||
|
||||
def test_success_by_email(self, db, auth_manager, test_user):
|
||||
result = auth_manager.authenticate_user(db, test_user.email, "testpass123")
|
||||
assert result is not None
|
||||
assert result.id == test_user.id
|
||||
|
||||
def test_user_not_found(self, db, auth_manager):
|
||||
result = auth_manager.authenticate_user(db, "nonexistent_user", "pass")
|
||||
assert result is None
|
||||
|
||||
def test_wrong_password(self, db, auth_manager, test_user):
|
||||
result = auth_manager.authenticate_user(db, test_user.username, "wrongpass")
|
||||
assert result is None
|
||||
|
||||
def test_empty_credentials(self, db, auth_manager):
|
||||
result = auth_manager.authenticate_user(db, "", "")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ─── Phase 4: create_access_token ────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCreateAccessToken:
|
||||
"""Test create_access_token with real User fixtures."""
|
||||
|
||||
def test_basic_user_claims(self, auth_manager, test_user):
|
||||
result = auth_manager.create_access_token(test_user)
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
assert payload["sub"] == str(test_user.id)
|
||||
assert payload["username"] == test_user.username
|
||||
assert payload["email"] == test_user.email
|
||||
assert payload["role"] == test_user.role
|
||||
|
||||
def test_return_structure(self, auth_manager, test_user):
|
||||
result = auth_manager.create_access_token(test_user)
|
||||
assert "access_token" in result
|
||||
assert result["token_type"] == "bearer"
|
||||
assert result["expires_in"] == auth_manager.token_expire_minutes * 60
|
||||
|
||||
def test_super_admin_claims(self, auth_manager, test_super_admin):
|
||||
result = auth_manager.create_access_token(test_super_admin)
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
assert payload["is_super_admin"] is True
|
||||
assert "accessible_platforms" not in payload
|
||||
|
||||
def test_platform_admin_with_platforms(self, db, auth_manager, test_platform_admin):
|
||||
with patch.object(
|
||||
test_platform_admin, "get_accessible_platform_ids", return_value=[1, 2, 3]
|
||||
):
|
||||
result = auth_manager.create_access_token(test_platform_admin)
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
assert payload["is_super_admin"] is False
|
||||
assert payload["accessible_platforms"] == [1, 2, 3]
|
||||
|
||||
def test_platform_admin_without_platforms(self, db, auth_manager, test_platform_admin):
|
||||
with patch.object(
|
||||
test_platform_admin, "get_accessible_platform_ids", return_value=None
|
||||
):
|
||||
result = auth_manager.create_access_token(test_platform_admin)
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
assert payload["is_super_admin"] is False
|
||||
assert "accessible_platforms" not in payload
|
||||
|
||||
def test_store_context(self, auth_manager, test_user):
|
||||
result = auth_manager.create_access_token(
|
||||
test_user, store_id=5, store_code="mystore", store_role="owner"
|
||||
)
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
assert payload["store_id"] == 5
|
||||
assert payload["store_code"] == "mystore"
|
||||
assert payload["store_role"] == "owner"
|
||||
|
||||
def test_platform_context(self, auth_manager, test_admin):
|
||||
result = auth_manager.create_access_token(
|
||||
test_admin, platform_id=10, platform_code="platX"
|
||||
)
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
assert payload["platform_id"] == 10
|
||||
assert payload["platform_code"] == "platX"
|
||||
|
||||
def test_all_contexts_combined(self, auth_manager, test_admin):
|
||||
result = auth_manager.create_access_token(
|
||||
test_admin,
|
||||
store_id=5,
|
||||
store_code="store1",
|
||||
store_role="manager",
|
||||
platform_id=10,
|
||||
platform_code="plat1",
|
||||
)
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
assert payload["store_id"] == 5
|
||||
assert payload["platform_id"] == 10
|
||||
|
||||
def test_expiration_matches_config(self, auth_manager, test_user):
|
||||
before = datetime.now(UTC)
|
||||
result = auth_manager.create_access_token(test_user)
|
||||
after = datetime.now(UTC)
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
exp = datetime.fromtimestamp(payload["exp"], tz=UTC)
|
||||
# JWT exp is stored as integer seconds, so allow 1s tolerance
|
||||
expected_min = before + timedelta(minutes=auth_manager.token_expire_minutes) - timedelta(seconds=1)
|
||||
expected_max = after + timedelta(minutes=auth_manager.token_expire_minutes) + timedelta(seconds=1)
|
||||
assert expected_min <= exp <= expected_max
|
||||
|
||||
def test_non_admin_has_no_admin_claims(self, auth_manager, test_user):
|
||||
result = auth_manager.create_access_token(test_user)
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
assert "is_super_admin" not in payload
|
||||
assert "accessible_platforms" not in payload
|
||||
|
||||
|
||||
# ─── Phase 5: verify_token ───────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestVerifyToken:
|
||||
"""Tests that craft tokens directly with jose_jwt.encode()."""
|
||||
|
||||
def _encode(self, payload, secret):
|
||||
"""Encode a JWT with the given payload and secret."""
|
||||
return jose_jwt.encode(payload, secret, algorithm="HS256")
|
||||
|
||||
def _base_payload(self, **overrides):
|
||||
"""Produce a minimal valid payload."""
|
||||
base = {
|
||||
"sub": "42",
|
||||
"username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"role": "user",
|
||||
"exp": datetime.now(UTC) + timedelta(hours=1),
|
||||
"iat": datetime.now(UTC),
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
def test_valid_basic_token(self, auth_manager):
|
||||
token = self._encode(self._base_payload(), auth_manager.secret_key)
|
||||
data = auth_manager.verify_token(token)
|
||||
assert data["user_id"] == 42
|
||||
assert data["username"] == "alice"
|
||||
assert data["email"] == "alice@example.com"
|
||||
assert data["role"] == "user"
|
||||
|
||||
def test_valid_store_token(self, auth_manager):
|
||||
token = self._encode(
|
||||
self._base_payload(store_id=7, store_code="shop1", store_role="owner"),
|
||||
auth_manager.secret_key,
|
||||
)
|
||||
data = auth_manager.verify_token(token)
|
||||
assert data["store_id"] == 7
|
||||
assert data["store_code"] == "shop1"
|
||||
assert data["store_role"] == "owner"
|
||||
|
||||
def test_valid_admin_token(self, auth_manager):
|
||||
token = self._encode(
|
||||
self._base_payload(is_super_admin=True), auth_manager.secret_key
|
||||
)
|
||||
data = auth_manager.verify_token(token)
|
||||
assert data["is_super_admin"] is True
|
||||
|
||||
def test_valid_platform_token(self, auth_manager):
|
||||
token = self._encode(
|
||||
self._base_payload(platform_id=3, platform_code="eu"),
|
||||
auth_manager.secret_key,
|
||||
)
|
||||
data = auth_manager.verify_token(token)
|
||||
assert data["platform_id"] == 3
|
||||
assert data["platform_code"] == "eu"
|
||||
|
||||
def test_valid_all_claims(self, auth_manager):
|
||||
token = self._encode(
|
||||
self._base_payload(
|
||||
is_super_admin=False,
|
||||
accessible_platforms=[1, 2],
|
||||
platform_id=1,
|
||||
platform_code="us",
|
||||
store_id=5,
|
||||
store_code="s1",
|
||||
store_role="manager",
|
||||
),
|
||||
auth_manager.secret_key,
|
||||
)
|
||||
data = auth_manager.verify_token(token)
|
||||
assert data["accessible_platforms"] == [1, 2]
|
||||
assert data["platform_id"] == 1
|
||||
assert data["store_id"] == 5
|
||||
|
||||
def test_default_role_when_missing(self, auth_manager):
|
||||
payload = self._base_payload()
|
||||
del payload["role"]
|
||||
token = self._encode(payload, auth_manager.secret_key)
|
||||
data = auth_manager.verify_token(token)
|
||||
assert data["role"] == "user"
|
||||
|
||||
def test_expired_token(self, auth_manager):
|
||||
token = self._encode(
|
||||
self._base_payload(exp=datetime.now(UTC) - timedelta(hours=1)),
|
||||
auth_manager.secret_key,
|
||||
)
|
||||
with pytest.raises(TokenExpiredException):
|
||||
auth_manager.verify_token(token)
|
||||
|
||||
def test_missing_sub(self, auth_manager):
|
||||
payload = self._base_payload()
|
||||
del payload["sub"]
|
||||
token = self._encode(payload, auth_manager.secret_key)
|
||||
with pytest.raises(InvalidTokenException):
|
||||
auth_manager.verify_token(token)
|
||||
|
||||
def test_missing_exp(self, auth_manager):
|
||||
payload = self._base_payload()
|
||||
del payload["exp"]
|
||||
token = jose_jwt.encode(payload, auth_manager.secret_key, algorithm="HS256")
|
||||
with pytest.raises(InvalidTokenException):
|
||||
auth_manager.verify_token(token)
|
||||
|
||||
def test_wrong_secret_key(self, auth_manager):
|
||||
token = self._encode(self._base_payload(), "wrong-secret-key")
|
||||
with pytest.raises(InvalidTokenException):
|
||||
auth_manager.verify_token(token)
|
||||
|
||||
def test_malformed_token(self, auth_manager):
|
||||
with pytest.raises(InvalidTokenException):
|
||||
auth_manager.verify_token("not.a.valid.jwt")
|
||||
|
||||
|
||||
# ─── Phase 6: get_current_user ───────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetCurrentUser:
|
||||
"""Round-trip tests using create_access_token → get_current_user."""
|
||||
|
||||
def _make_credentials(self, token: str) -> HTTPAuthorizationCredentials:
|
||||
return HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
|
||||
|
||||
def test_success(self, db, auth_manager, test_user):
|
||||
token_data = auth_manager.create_access_token(test_user)
|
||||
creds = self._make_credentials(token_data["access_token"])
|
||||
user = auth_manager.get_current_user(db, creds)
|
||||
assert user.id == test_user.id
|
||||
|
||||
def test_inactive_user(self, db, auth_manager, test_user):
|
||||
token_data = auth_manager.create_access_token(test_user)
|
||||
test_user.is_active = False
|
||||
db.commit()
|
||||
creds = self._make_credentials(token_data["access_token"])
|
||||
with pytest.raises(UserNotActiveException):
|
||||
auth_manager.get_current_user(db, creds)
|
||||
|
||||
def test_nonexistent_user(self, db, auth_manager, test_user):
|
||||
token_data = auth_manager.create_access_token(test_user)
|
||||
db.delete(test_user)
|
||||
db.commit()
|
||||
creds = self._make_credentials(token_data["access_token"])
|
||||
with pytest.raises(InvalidCredentialsException):
|
||||
auth_manager.get_current_user(db, creds)
|
||||
|
||||
def test_invalid_token(self, db, auth_manager):
|
||||
creds = self._make_credentials("invalid.token.here")
|
||||
with pytest.raises(InvalidTokenException):
|
||||
auth_manager.get_current_user(db, creds)
|
||||
|
||||
def test_attaches_admin_attrs(self, db, auth_manager, test_super_admin):
|
||||
token_data = auth_manager.create_access_token(test_super_admin)
|
||||
creds = self._make_credentials(token_data["access_token"])
|
||||
user = auth_manager.get_current_user(db, creds)
|
||||
assert user.token_is_super_admin is True
|
||||
|
||||
def test_attaches_platform_attrs(self, db, auth_manager, test_admin):
|
||||
token_data = auth_manager.create_access_token(
|
||||
test_admin, platform_id=10, platform_code="eu"
|
||||
)
|
||||
creds = self._make_credentials(token_data["access_token"])
|
||||
user = auth_manager.get_current_user(db, creds)
|
||||
assert user.token_platform_id == 10
|
||||
assert user.token_platform_code == "eu"
|
||||
|
||||
def test_attaches_store_attrs(self, db, auth_manager, test_store_user):
|
||||
token_data = auth_manager.create_access_token(
|
||||
test_store_user, store_id=5, store_code="shop1", store_role="owner"
|
||||
)
|
||||
creds = self._make_credentials(token_data["access_token"])
|
||||
user = auth_manager.get_current_user(db, creds)
|
||||
assert user.token_store_id == 5
|
||||
assert user.token_store_code == "shop1"
|
||||
assert user.token_store_role == "owner"
|
||||
|
||||
def test_no_optional_attrs_for_basic_user(self, db, auth_manager, test_user):
|
||||
token_data = auth_manager.create_access_token(test_user)
|
||||
creds = self._make_credentials(token_data["access_token"])
|
||||
user = auth_manager.get_current_user(db, creds)
|
||||
assert not hasattr(user, "token_is_super_admin")
|
||||
assert not hasattr(user, "token_platform_id")
|
||||
assert not hasattr(user, "token_store_id")
|
||||
|
||||
|
||||
# ─── Phase 7: RBAC ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRequireRole:
|
||||
"""Test the require_role decorator factory."""
|
||||
|
||||
def test_matching_role(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "admin"
|
||||
|
||||
@auth_manager.require_role("admin")
|
||||
def protected(current_user):
|
||||
return "ok"
|
||||
|
||||
assert protected(user) == "ok"
|
||||
|
||||
def test_non_matching_role(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "user"
|
||||
|
||||
@auth_manager.require_role("admin")
|
||||
def protected(current_user):
|
||||
return "ok"
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
protected(user)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
def test_args_pass_through(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "admin"
|
||||
|
||||
@auth_manager.require_role("admin")
|
||||
def protected(current_user, x, y=10):
|
||||
return x + y
|
||||
|
||||
assert protected(user, 5, y=20) == 25
|
||||
|
||||
def test_error_message(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "customer"
|
||||
|
||||
@auth_manager.require_role("store")
|
||||
def protected(current_user):
|
||||
pass
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
protected(user)
|
||||
assert "store" in exc_info.value.detail
|
||||
assert "customer" in exc_info.value.detail
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRequireAdmin:
|
||||
"""Test require_admin method."""
|
||||
|
||||
def test_admin_accepted(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "admin"
|
||||
result = auth_manager.require_admin(user)
|
||||
assert result is user
|
||||
|
||||
def test_non_admin_rejected(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "user"
|
||||
with pytest.raises(AdminRequiredException):
|
||||
auth_manager.require_admin(user)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRequireStore:
|
||||
"""Test require_store method (accepts store and admin roles)."""
|
||||
|
||||
def test_store_accepted(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "store"
|
||||
assert auth_manager.require_store(user) is user
|
||||
|
||||
def test_admin_accepted(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "admin"
|
||||
assert auth_manager.require_store(user) is user
|
||||
|
||||
def test_customer_rejected(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "customer"
|
||||
with pytest.raises(InsufficientPermissionsException):
|
||||
auth_manager.require_store(user)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRequireCustomer:
|
||||
"""Test require_customer method (accepts customer and admin roles)."""
|
||||
|
||||
def test_customer_accepted(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "customer"
|
||||
assert auth_manager.require_customer(user) is user
|
||||
|
||||
def test_admin_accepted(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "admin"
|
||||
assert auth_manager.require_customer(user) is user
|
||||
|
||||
def test_store_rejected(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "store"
|
||||
with pytest.raises(InsufficientPermissionsException):
|
||||
auth_manager.require_customer(user)
|
||||
|
||||
|
||||
# ─── Phase 8: create_default_admin_user ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCreateDefaultAdminUser:
|
||||
"""Test create_default_admin_user with real DB."""
|
||||
|
||||
def test_creates_admin_when_none_exists(self, db, auth_manager):
|
||||
user = auth_manager.create_default_admin_user(db)
|
||||
assert user.username == "admin"
|
||||
assert user.role == "admin"
|
||||
assert user.is_super_admin is True
|
||||
assert user.is_active is True
|
||||
|
||||
def test_skips_when_admin_exists(self, db, auth_manager):
|
||||
first = auth_manager.create_default_admin_user(db)
|
||||
second = auth_manager.create_default_admin_user(db)
|
||||
assert first.id == second.id
|
||||
|
||||
def test_password_is_verifiable(self, db, auth_manager):
|
||||
user = auth_manager.create_default_admin_user(db)
|
||||
assert auth_manager.verify_password("admin123", user.hashed_password) is True
|
||||
|
||||
def test_user_persisted_to_db(self, db, auth_manager):
|
||||
auth_manager.create_default_admin_user(db)
|
||||
found = db.query(User).filter(User.username == "admin").first()
|
||||
assert found is not None
|
||||
assert found.email == "admin@example.com"
|
||||
297
tests/unit/middleware/test_cloudflare.py
Normal file
297
tests/unit/middleware/test_cloudflare.py
Normal file
@@ -0,0 +1,297 @@
|
||||
# tests/unit/middleware/test_cloudflare.py
|
||||
"""
|
||||
Unit tests for CloudFlareMiddleware, get_real_client_ip, and get_client_country.
|
||||
|
||||
Tests cover:
|
||||
- CloudFlare dispatch: CF disabled bypass, header extraction, missing header
|
||||
fallbacks, CF-Visitor JSON parsing (valid/malformed), X-CF-Ray response
|
||||
header, no-client fallback
|
||||
- get_real_client_ip: state.real_ip, CF-Connecting-IP, X-Forwarded-For,
|
||||
request.client fallback, no client → "unknown"
|
||||
- get_client_country: state.client_country, CF-IPCountry header, neither
|
||||
available, state takes priority over header
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
from middleware.cloudflare import (
|
||||
CloudFlareMiddleware,
|
||||
get_client_country,
|
||||
get_real_client_ip,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCloudFlareDispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestCloudFlareDispatch:
|
||||
"""Tests for CloudFlareMiddleware.dispatch()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cf_disabled_bypasses_middleware(self):
|
||||
"""When cloudflare_enabled is False, call_next is invoked directly."""
|
||||
middleware = CloudFlareMiddleware(app=None)
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock(spec=[])
|
||||
response = Mock(status_code=200, headers={})
|
||||
call_next = AsyncMock(return_value=response)
|
||||
|
||||
with patch("middleware.cloudflare.settings") as mock_settings:
|
||||
mock_settings.cloudflare_enabled = False
|
||||
result = await middleware.dispatch(request, call_next)
|
||||
|
||||
call_next.assert_awaited_once_with(request)
|
||||
assert result is response
|
||||
# No state attributes should have been set
|
||||
assert not hasattr(request.state, "real_ip")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_cf_headers_extracted(self):
|
||||
"""All CloudFlare headers are stored on request.state."""
|
||||
middleware = CloudFlareMiddleware(app=None)
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {
|
||||
"CF-Connecting-IP": "1.2.3.4",
|
||||
"CF-IPCountry": "LU",
|
||||
"CF-Ray": "abc123",
|
||||
"CF-Visitor": '{"scheme":"https"}',
|
||||
}
|
||||
request.state = Mock(spec=[])
|
||||
request.client = Mock(host="127.0.0.1")
|
||||
|
||||
response = Mock(status_code=200, headers={})
|
||||
call_next = AsyncMock(return_value=response)
|
||||
|
||||
with patch("middleware.cloudflare.settings") as mock_settings:
|
||||
mock_settings.cloudflare_enabled = True
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.real_ip == "1.2.3.4"
|
||||
assert request.state.client_country == "LU"
|
||||
assert request.state.cf_ray == "abc123"
|
||||
assert request.state.original_scheme == "https"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_cf_connecting_ip_falls_back_to_client(self):
|
||||
"""Without CF-Connecting-IP, real_ip falls back to request.client.host."""
|
||||
middleware = CloudFlareMiddleware(app=None)
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {}
|
||||
request.state = Mock(spec=[])
|
||||
request.client = Mock(host="10.0.0.1")
|
||||
|
||||
response = Mock(status_code=200, headers={})
|
||||
call_next = AsyncMock(return_value=response)
|
||||
|
||||
with patch("middleware.cloudflare.settings") as mock_settings:
|
||||
mock_settings.cloudflare_enabled = True
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.real_ip == "10.0.0.1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_cf_headers_no_state_attributes(self):
|
||||
"""Missing optional headers do not set corresponding state attributes."""
|
||||
middleware = CloudFlareMiddleware(app=None)
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {}
|
||||
request.state = Mock(spec=[])
|
||||
request.client = Mock(host="10.0.0.1")
|
||||
|
||||
response = Mock(status_code=200, headers={})
|
||||
call_next = AsyncMock(return_value=response)
|
||||
|
||||
with patch("middleware.cloudflare.settings") as mock_settings:
|
||||
mock_settings.cloudflare_enabled = True
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
# client_country and cf_ray should NOT be set
|
||||
assert not hasattr(request.state, "client_country")
|
||||
assert not hasattr(request.state, "cf_ray")
|
||||
assert not hasattr(request.state, "original_scheme")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cf_visitor_json_parsed(self):
|
||||
"""CF-Visitor JSON body is parsed and scheme extracted."""
|
||||
middleware = CloudFlareMiddleware(app=None)
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"CF-Visitor": '{"scheme":"http"}'}
|
||||
request.state = Mock(spec=[])
|
||||
request.client = Mock(host="127.0.0.1")
|
||||
|
||||
response = Mock(status_code=200, headers={})
|
||||
call_next = AsyncMock(return_value=response)
|
||||
|
||||
with patch("middleware.cloudflare.settings") as mock_settings:
|
||||
mock_settings.cloudflare_enabled = True
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.original_scheme == "http"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cf_visitor_malformed_json_defaults_to_https(self):
|
||||
"""Malformed CF-Visitor JSON defaults original_scheme to 'https'."""
|
||||
middleware = CloudFlareMiddleware(app=None)
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"CF-Visitor": "not-json"}
|
||||
request.state = Mock(spec=[])
|
||||
request.client = Mock(host="127.0.0.1")
|
||||
|
||||
response = Mock(status_code=200, headers={})
|
||||
call_next = AsyncMock(return_value=response)
|
||||
|
||||
with patch("middleware.cloudflare.settings") as mock_settings:
|
||||
mock_settings.cloudflare_enabled = True
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.original_scheme == "https"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cf_ray_added_to_response_header(self):
|
||||
"""X-CF-Ray response header is set when CF-Ray is present."""
|
||||
middleware = CloudFlareMiddleware(app=None)
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"CF-Ray": "ray-999"}
|
||||
request.state = Mock(spec=[])
|
||||
request.client = Mock(host="127.0.0.1")
|
||||
|
||||
response = Mock(status_code=200, headers={})
|
||||
call_next = AsyncMock(return_value=response)
|
||||
|
||||
with patch("middleware.cloudflare.settings") as mock_settings:
|
||||
mock_settings.cloudflare_enabled = True
|
||||
result = await middleware.dispatch(request, call_next)
|
||||
|
||||
assert result.headers["X-CF-Ray"] == "ray-999"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_client_fallback_returns_unknown(self):
|
||||
"""When request.client is None, real_ip is set to 'unknown'."""
|
||||
middleware = CloudFlareMiddleware(app=None)
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {}
|
||||
request.state = Mock(spec=[])
|
||||
request.client = None
|
||||
|
||||
response = Mock(status_code=200, headers={})
|
||||
call_next = AsyncMock(return_value=response)
|
||||
|
||||
with patch("middleware.cloudflare.settings") as mock_settings:
|
||||
mock_settings.cloudflare_enabled = True
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.real_ip == "unknown"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestGetRealClientIp
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestGetRealClientIp:
|
||||
"""Tests for get_real_client_ip() standalone helper."""
|
||||
|
||||
def test_returns_state_real_ip_when_present(self):
|
||||
"""state.real_ip takes priority over everything else."""
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock()
|
||||
request.state.real_ip = "5.6.7.8"
|
||||
request.headers = {"CF-Connecting-IP": "1.2.3.4"}
|
||||
|
||||
assert get_real_client_ip(request) == "5.6.7.8"
|
||||
|
||||
def test_returns_cf_connecting_ip_header(self):
|
||||
"""Falls back to CF-Connecting-IP when state.real_ip absent."""
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock(spec=[]) # no real_ip attribute
|
||||
request.headers = {"CF-Connecting-IP": "1.2.3.4"}
|
||||
|
||||
assert get_real_client_ip(request) == "1.2.3.4"
|
||||
|
||||
def test_returns_first_x_forwarded_for_ip(self):
|
||||
"""Falls back to first IP from X-Forwarded-For header."""
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock(spec=[])
|
||||
request.headers = {"X-Forwarded-For": "9.8.7.6, 10.0.0.1, 10.0.0.2"}
|
||||
|
||||
assert get_real_client_ip(request) == "9.8.7.6"
|
||||
|
||||
def test_returns_single_x_forwarded_for_ip(self):
|
||||
"""X-Forwarded-For with single IP still works."""
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock(spec=[])
|
||||
request.headers = {"X-Forwarded-For": "9.8.7.6"}
|
||||
|
||||
assert get_real_client_ip(request) == "9.8.7.6"
|
||||
|
||||
def test_returns_client_host_as_fallback(self):
|
||||
"""Falls back to request.client.host when no proxy headers."""
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock(spec=[])
|
||||
request.headers = {}
|
||||
request.client = Mock(host="192.168.1.1")
|
||||
|
||||
assert get_real_client_ip(request) == "192.168.1.1"
|
||||
|
||||
def test_returns_unknown_when_no_client(self):
|
||||
"""Returns 'unknown' when client is None and no proxy headers."""
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock(spec=[])
|
||||
request.headers = {}
|
||||
request.client = None
|
||||
|
||||
assert get_real_client_ip(request) == "unknown"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestGetClientCountry
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestGetClientCountry:
|
||||
"""Tests for get_client_country() standalone helper."""
|
||||
|
||||
def test_returns_state_client_country_when_present(self):
|
||||
"""state.client_country takes priority."""
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock()
|
||||
request.state.client_country = "LU"
|
||||
request.headers = {"CF-IPCountry": "DE"}
|
||||
|
||||
assert get_client_country(request) == "LU"
|
||||
|
||||
def test_returns_cf_ipcountry_header_fallback(self):
|
||||
"""Falls back to CF-IPCountry header when state not set."""
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock(spec=[])
|
||||
request.headers = {"CF-IPCountry": "FR"}
|
||||
|
||||
assert get_client_country(request) == "FR"
|
||||
|
||||
def test_returns_none_when_neither_available(self):
|
||||
"""Returns None when neither state nor header is available."""
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock(spec=[])
|
||||
request.headers = {}
|
||||
|
||||
assert get_client_country(request) is None
|
||||
|
||||
def test_state_takes_priority_over_header(self):
|
||||
"""state.client_country takes priority even when header differs."""
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock()
|
||||
request.state.client_country = "BE"
|
||||
request.headers = {"CF-IPCountry": "NL"}
|
||||
|
||||
assert get_client_country(request) == "BE"
|
||||
|
||||
def test_header_with_no_state_attribute(self):
|
||||
"""CF-IPCountry header used when state has no client_country attr."""
|
||||
request = Mock(spec=Request)
|
||||
# Use spec=[] so hasattr returns False for all attributes
|
||||
request.state = Mock(spec=[])
|
||||
request.headers = {"CF-IPCountry": "DE"}
|
||||
|
||||
assert get_client_country(request) == "DE"
|
||||
491
tests/unit/middleware/test_language.py
Normal file
491
tests/unit/middleware/test_language.py
Normal file
@@ -0,0 +1,491 @@
|
||||
# tests/unit/middleware/test_language.py
|
||||
"""
|
||||
Unit tests for LanguageMiddleware, set_language_cookie, and delete_language_cookie.
|
||||
|
||||
Tests cover:
|
||||
- Admin frontend: user pref used, falls back to "en"
|
||||
- Store dashboard: calls resolve_store_dashboard_language, no-store fallback
|
||||
- Storefront: calls resolve_storefront_language, no-store fallback
|
||||
- General: platform frontend, fallback/unknown type, unsupported language
|
||||
validation, request.state.language set, request.state.language_info populated,
|
||||
cookie language read
|
||||
- Helpers: _get_user_language_from_token, _get_customer_language_from_token,
|
||||
set_language_cookie (valid/invalid), delete_language_cookie
|
||||
- Private methods: user with pref, user without pref, no user, customer with pref
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.modules.enums import FrontendType
|
||||
from middleware.language import (
|
||||
LanguageMiddleware,
|
||||
delete_language_cookie,
|
||||
set_language_cookie,
|
||||
)
|
||||
|
||||
|
||||
def _make_request(
|
||||
frontend_type=None,
|
||||
store=None,
|
||||
cookies=None,
|
||||
headers=None,
|
||||
current_user=None,
|
||||
current_customer=None,
|
||||
):
|
||||
"""Build a mock Request with the given state attributes."""
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock(spec=[])
|
||||
request.cookies = cookies or {}
|
||||
request.headers = headers or {}
|
||||
|
||||
if frontend_type is not None:
|
||||
request.state.frontend_type = frontend_type
|
||||
if store is not None:
|
||||
request.state.store = store
|
||||
if current_user is not None:
|
||||
request.state.current_user = current_user
|
||||
if current_customer is not None:
|
||||
request.state.current_customer = current_customer
|
||||
|
||||
return request
|
||||
|
||||
|
||||
def _make_call_next():
|
||||
"""Return (call_next, response) pair."""
|
||||
response = Mock(status_code=200, headers={})
|
||||
call_next = AsyncMock(return_value=response)
|
||||
return call_next, response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestLanguageDispatchAdmin
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestLanguageDispatchAdmin:
|
||||
"""Admin frontend language resolution."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_user_preferred_language_used(self):
|
||||
"""Admin: user's preferred_language is used when set."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
user = Mock()
|
||||
user.preferred_language = "de"
|
||||
request = _make_request(
|
||||
frontend_type=FrontendType.ADMIN,
|
||||
current_user=user,
|
||||
)
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with patch("middleware.language.parse_accept_language", return_value=None):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.language == "de"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_falls_back_to_en(self):
|
||||
"""Admin: falls back to 'en' when no user preference."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
request = _make_request(frontend_type=FrontendType.ADMIN)
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with patch("middleware.language.parse_accept_language", return_value=None):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.language == "en"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestLanguageDispatchStore
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestLanguageDispatchStore:
|
||||
"""Store dashboard language resolution."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_calls_resolve_store_dashboard_language(self):
|
||||
"""Store: resolve_store_dashboard_language is called with correct args."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
user = Mock()
|
||||
user.preferred_language = "de"
|
||||
store = Mock()
|
||||
store.dashboard_language = "fr"
|
||||
request = _make_request(
|
||||
frontend_type=FrontendType.STORE,
|
||||
store=store,
|
||||
current_user=user,
|
||||
)
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with (
|
||||
patch("middleware.language.parse_accept_language", return_value=None),
|
||||
patch(
|
||||
"middleware.language.resolve_store_dashboard_language",
|
||||
return_value="de",
|
||||
) as mock_resolve,
|
||||
):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
mock_resolve.assert_called_once_with(
|
||||
user_preferred="de",
|
||||
store_dashboard="fr",
|
||||
)
|
||||
assert request.state.language == "de"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_no_store_passes_none_dashboard(self):
|
||||
"""Store: store_dashboard is None when request.state has no store."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
request = _make_request(frontend_type=FrontendType.STORE)
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with (
|
||||
patch("middleware.language.parse_accept_language", return_value=None),
|
||||
patch(
|
||||
"middleware.language.resolve_store_dashboard_language",
|
||||
return_value="fr",
|
||||
) as mock_resolve,
|
||||
):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
mock_resolve.assert_called_once_with(
|
||||
user_preferred=None,
|
||||
store_dashboard=None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestLanguageDispatchStorefront
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestLanguageDispatchStorefront:
|
||||
"""Storefront language resolution."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_storefront_calls_resolve_storefront_language(self):
|
||||
"""Storefront: resolve_storefront_language called with correct args."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
customer = Mock()
|
||||
customer.preferred_language = "lb"
|
||||
store = Mock()
|
||||
store.storefront_language = "fr"
|
||||
store.storefront_languages = ["fr", "de", "lb"]
|
||||
request = _make_request(
|
||||
frontend_type=FrontendType.STOREFRONT,
|
||||
store=store,
|
||||
current_customer=customer,
|
||||
cookies={"lang": "de"},
|
||||
headers={"accept-language": "en-US,en;q=0.9"},
|
||||
)
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"middleware.language.parse_accept_language", return_value="en"
|
||||
),
|
||||
patch(
|
||||
"middleware.language.resolve_storefront_language",
|
||||
return_value="lb",
|
||||
) as mock_resolve,
|
||||
):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
mock_resolve.assert_called_once_with(
|
||||
customer_preferred="lb",
|
||||
session_language="de",
|
||||
store_storefront="fr",
|
||||
browser_language="en",
|
||||
enabled_languages=["fr", "de", "lb"],
|
||||
)
|
||||
assert request.state.language == "lb"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_storefront_no_store_passes_none(self):
|
||||
"""Storefront: store fields are None when no store in state."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
request = _make_request(frontend_type=FrontendType.STOREFRONT)
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with (
|
||||
patch("middleware.language.parse_accept_language", return_value=None),
|
||||
patch(
|
||||
"middleware.language.resolve_storefront_language",
|
||||
return_value="fr",
|
||||
) as mock_resolve,
|
||||
):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
mock_resolve.assert_called_once_with(
|
||||
customer_preferred=None,
|
||||
session_language=None,
|
||||
store_storefront=None,
|
||||
browser_language=None,
|
||||
enabled_languages=None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestLanguageDispatchGeneral
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestLanguageDispatchGeneral:
|
||||
"""Platform, fallback/unknown, validation, state, and cookie tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_uses_cookie_language(self):
|
||||
"""Platform: cookie language is used first."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
request = _make_request(
|
||||
frontend_type=FrontendType.PLATFORM,
|
||||
cookies={"lang": "de"},
|
||||
)
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with patch("middleware.language.parse_accept_language", return_value="en"):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.language == "de"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_falls_back_to_browser_language(self):
|
||||
"""Platform: browser language used when no cookie."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
request = _make_request(frontend_type=FrontendType.PLATFORM)
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with patch("middleware.language.parse_accept_language", return_value="de"):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.language == "de"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_unknown_frontend_type(self):
|
||||
"""Unknown/None frontend type uses cookie → browser → default."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
request = _make_request() # no frontend_type
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with patch("middleware.language.parse_accept_language", return_value=None):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
# DEFAULT_LANGUAGE is "fr"
|
||||
assert request.state.language == "fr"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_language_falls_back_to_default(self):
|
||||
"""Unsupported language code is replaced by DEFAULT_LANGUAGE."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
request = _make_request(
|
||||
frontend_type=FrontendType.PLATFORM,
|
||||
cookies={"lang": "xx"}, # unsupported
|
||||
)
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with patch("middleware.language.parse_accept_language", return_value=None):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.language == "fr"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_state_language_is_set(self):
|
||||
"""request.state.language is always set after dispatch."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
request = _make_request(frontend_type=FrontendType.PLATFORM)
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with patch("middleware.language.parse_accept_language", return_value="en"):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert hasattr(request.state, "language")
|
||||
assert request.state.language == "en"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_state_language_info_populated(self):
|
||||
"""request.state.language_info contains code, cookie, browser, frontend_type."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
request = _make_request(
|
||||
frontend_type=FrontendType.PLATFORM,
|
||||
cookies={"lang": "en"},
|
||||
headers={"accept-language": "de"},
|
||||
)
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with patch("middleware.language.parse_accept_language", return_value="de"):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
info = request.state.language_info
|
||||
assert info["code"] == "en"
|
||||
assert info["cookie"] == "en"
|
||||
assert info["browser"] == "de"
|
||||
assert info["frontend_type"] == "platform"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cookie_language_read_from_request(self):
|
||||
"""The 'lang' cookie is read and considered in resolution."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
request = _make_request(
|
||||
frontend_type=FrontendType.PLATFORM,
|
||||
cookies={"lang": "lb"},
|
||||
)
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with patch("middleware.language.parse_accept_language", return_value=None):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.language == "lb"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestLanguageHelpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestLanguageHelpers:
|
||||
"""Tests for set_language_cookie and delete_language_cookie."""
|
||||
|
||||
def test_set_language_cookie_valid(self):
|
||||
"""set_language_cookie sets cookie for supported language."""
|
||||
response = Mock(spec=Response)
|
||||
result = set_language_cookie(response, "de")
|
||||
|
||||
response.set_cookie.assert_called_once_with(
|
||||
key="lang",
|
||||
value="de",
|
||||
max_age=60 * 60 * 24 * 365,
|
||||
httponly=False,
|
||||
samesite="lax",
|
||||
)
|
||||
assert result is response
|
||||
|
||||
def test_set_language_cookie_invalid_language(self):
|
||||
"""set_language_cookie does NOT call set_cookie for unsupported lang."""
|
||||
response = Mock(spec=Response)
|
||||
result = set_language_cookie(response, "xx")
|
||||
|
||||
response.set_cookie.assert_not_called()
|
||||
assert result is response
|
||||
|
||||
def test_delete_language_cookie(self):
|
||||
"""delete_language_cookie calls delete_cookie with correct key."""
|
||||
response = Mock(spec=Response)
|
||||
result = delete_language_cookie(response)
|
||||
|
||||
response.delete_cookie.assert_called_once_with(key="lang")
|
||||
assert result is response
|
||||
|
||||
def test_get_user_language_from_token_with_pref(self):
|
||||
"""_get_user_language_from_token returns preferred_language."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock()
|
||||
request.state.current_user = Mock()
|
||||
request.state.current_user.preferred_language = "de"
|
||||
|
||||
assert middleware._get_user_language_from_token(request) == "de"
|
||||
|
||||
def test_get_user_language_from_token_no_user(self):
|
||||
"""_get_user_language_from_token returns None with no user."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock(spec=[]) # no current_user
|
||||
|
||||
assert middleware._get_user_language_from_token(request) is None
|
||||
|
||||
def test_get_customer_language_from_token_with_pref(self):
|
||||
"""_get_customer_language_from_token returns preferred_language."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock()
|
||||
request.state.current_customer = Mock()
|
||||
request.state.current_customer.preferred_language = "lb"
|
||||
|
||||
assert middleware._get_customer_language_from_token(request) == "lb"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestLanguageDispatchPrivateMethods
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestLanguageDispatchPrivateMethods:
|
||||
"""Integration-style tests exercising private helpers through dispatch."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_user_with_preferred_language(self):
|
||||
"""Admin + user with pref → uses that preference."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
user = Mock()
|
||||
user.preferred_language = "lb"
|
||||
request = _make_request(
|
||||
frontend_type=FrontendType.ADMIN,
|
||||
current_user=user,
|
||||
)
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with patch("middleware.language.parse_accept_language", return_value=None):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.language == "lb"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_user_without_preferred_language(self):
|
||||
"""Admin + user without preferred_language → falls back to 'en'."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
user = Mock(spec=[]) # no preferred_language attr
|
||||
request = _make_request(
|
||||
frontend_type=FrontendType.ADMIN,
|
||||
current_user=user,
|
||||
)
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with patch("middleware.language.parse_accept_language", return_value=None):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.language == "en"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_no_user_at_all(self):
|
||||
"""Admin + no user at all → falls back to 'en'."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
request = _make_request(frontend_type=FrontendType.ADMIN)
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with patch("middleware.language.parse_accept_language", return_value=None):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.language == "en"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_storefront_customer_with_preferred_language(self):
|
||||
"""Storefront + customer with pref → passed to resolve function."""
|
||||
middleware = LanguageMiddleware(app=None)
|
||||
customer = Mock()
|
||||
customer.preferred_language = "de"
|
||||
store = Mock()
|
||||
store.storefront_language = "fr"
|
||||
store.storefront_languages = ["fr", "de"]
|
||||
request = _make_request(
|
||||
frontend_type=FrontendType.STOREFRONT,
|
||||
store=store,
|
||||
current_customer=customer,
|
||||
)
|
||||
call_next, _ = _make_call_next()
|
||||
|
||||
with (
|
||||
patch("middleware.language.parse_accept_language", return_value=None),
|
||||
patch(
|
||||
"middleware.language.resolve_storefront_language",
|
||||
return_value="de",
|
||||
) as mock_resolve,
|
||||
):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
mock_resolve.assert_called_once_with(
|
||||
customer_preferred="de",
|
||||
session_language=None,
|
||||
store_storefront="fr",
|
||||
browser_language=None,
|
||||
enabled_languages=["fr", "de"],
|
||||
)
|
||||
assert request.state.language == "de"
|
||||
Reference in New Issue
Block a user