One word everywhere: ReplyPier in UI copy, package and image names, the replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the --rp-* design tokens. - public widget API is window.ReplyPier with replypierId / replypierBaseUrl / replypierConfig / replypierToken, loader at webclient/replypier.min.js; both bundles rebuilt from source - NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime config rewritten; the demo config ships no third-party keys - migration adds the ReplyPier managed model value to the AI model enum - local compose project, database, role, volume and Docker network carry the same name
544 lines
21 KiB
Python
544 lines
21 KiB
Python
"""
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
"""
|
|
|
|
import pytest
|
|
import jwt
|
|
import time
|
|
import uuid
|
|
from unittest.mock import MagicMock, patch, AsyncMock
|
|
from fastapi import HTTPException, Request
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.services.shopify_session import (
|
|
ShopifySessionService,
|
|
require_shopify_session,
|
|
require_shopify_or_jwt_auth
|
|
)
|
|
|
|
|
|
class TestShopifySessionService:
|
|
"""Test cases for ShopifySessionService"""
|
|
|
|
def test_get_session_token_from_request_authorization_header(self):
|
|
"""Test extracting session token from Authorization header"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.headers = {"authorization": "Bearer test_token_123"}
|
|
mock_request.query_params = {}
|
|
|
|
# Act
|
|
token = ShopifySessionService.get_session_token_from_request(mock_request)
|
|
|
|
# Assert
|
|
assert token == "test_token_123"
|
|
|
|
def test_get_session_token_from_request_query_param(self):
|
|
"""Test extracting session token from URL query parameter"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.headers = {}
|
|
mock_request.query_params = {"id_token": "query_token_456"}
|
|
|
|
# Act
|
|
token = ShopifySessionService.get_session_token_from_request(mock_request)
|
|
|
|
# Assert
|
|
assert token == "query_token_456"
|
|
|
|
def test_get_session_token_from_request_authorization_header_priority(self):
|
|
"""Test that Authorization header takes priority over query param"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.headers = {"authorization": "Bearer header_token"}
|
|
mock_request.query_params = {"id_token": "query_token"}
|
|
|
|
# Act
|
|
token = ShopifySessionService.get_session_token_from_request(mock_request)
|
|
|
|
# Assert
|
|
assert token == "header_token"
|
|
|
|
def test_get_session_token_from_request_no_token(self):
|
|
"""Test when no token is present"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.headers = {}
|
|
mock_request.query_params = {}
|
|
|
|
# Act
|
|
token = ShopifySessionService.get_session_token_from_request(mock_request)
|
|
|
|
# Assert
|
|
assert token is None
|
|
|
|
def test_get_session_token_from_request_empty_bearer(self):
|
|
"""Test when Authorization header has empty Bearer token"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.headers = {"authorization": "Bearer "}
|
|
mock_request.query_params = {"id_token": "fallback_token"}
|
|
|
|
# Act
|
|
token = ShopifySessionService.get_session_token_from_request(mock_request)
|
|
|
|
# Assert
|
|
assert token == "fallback_token"
|
|
|
|
def test_get_session_token_from_request_malformed_auth_header(self):
|
|
"""Test when Authorization header is malformed"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.headers = {"authorization": "Basic some_token"}
|
|
mock_request.query_params = {"id_token": "fallback_token"}
|
|
|
|
# Act
|
|
token = ShopifySessionService.get_session_token_from_request(mock_request)
|
|
|
|
# Assert
|
|
assert token == "fallback_token"
|
|
|
|
@patch('app.services.shopify_session.settings')
|
|
def test_validate_session_token_success(self, mock_settings):
|
|
"""Test successful session token validation"""
|
|
# Arrange
|
|
mock_settings.SHOPIFY_API_SECRET = "test_secret"
|
|
mock_settings.SHOPIFY_API_KEY = "test_key"
|
|
|
|
# Create a valid JWT token
|
|
payload = {
|
|
'dest': 'https://test-shop.myshopify.com',
|
|
'aud': 'test_key',
|
|
'exp': int(time.time()) + 3600, # 1 hour from now
|
|
'nbf': int(time.time()) - 60, # 1 minute ago
|
|
'iat': int(time.time()),
|
|
'sub': 'test_user'
|
|
}
|
|
token = jwt.encode(payload, "test_secret", algorithm="HS256")
|
|
|
|
# Act
|
|
decoded = ShopifySessionService.validate_session_token(token)
|
|
|
|
# Assert
|
|
assert decoded['dest'] == 'https://test-shop.myshopify.com'
|
|
assert decoded['aud'] == 'test_key'
|
|
assert 'exp' in decoded
|
|
assert 'nbf' in decoded
|
|
|
|
@patch('app.services.shopify_session.settings')
|
|
def test_validate_session_token_expired(self, mock_settings):
|
|
"""Test validation of expired session token"""
|
|
# Arrange
|
|
mock_settings.SHOPIFY_API_SECRET = "test_secret"
|
|
mock_settings.SHOPIFY_API_KEY = "test_key"
|
|
|
|
# Create an expired JWT token
|
|
payload = {
|
|
'dest': 'https://test-shop.myshopify.com',
|
|
'aud': 'test_key',
|
|
'exp': int(time.time()) - 3600, # 1 hour ago (expired)
|
|
'nbf': int(time.time()) - 7200, # 2 hours ago
|
|
'iat': int(time.time()) - 7200
|
|
}
|
|
token = jwt.encode(payload, "test_secret", algorithm="HS256")
|
|
|
|
# Act & Assert
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
ShopifySessionService.validate_session_token(token)
|
|
|
|
assert exc_info.value.status_code == 401
|
|
assert "expired" in str(exc_info.value.detail).lower()
|
|
assert exc_info.value.headers.get("X-Shopify-Retry-Invalid-Session-Request") == "1"
|
|
|
|
@patch('app.services.shopify_session.settings')
|
|
def test_validate_session_token_not_yet_valid(self, mock_settings):
|
|
"""Test validation of session token that's not yet valid (nbf in future)"""
|
|
# Arrange
|
|
mock_settings.SHOPIFY_API_SECRET = "test_secret"
|
|
mock_settings.SHOPIFY_API_KEY = "test_key"
|
|
|
|
# Create a JWT token that's not yet valid
|
|
payload = {
|
|
'dest': 'https://test-shop.myshopify.com',
|
|
'aud': 'test_key',
|
|
'exp': int(time.time()) + 7200, # 2 hours from now
|
|
'nbf': int(time.time()) + 3600, # 1 hour from now (not yet valid)
|
|
'iat': int(time.time())
|
|
}
|
|
token = jwt.encode(payload, "test_secret", algorithm="HS256")
|
|
|
|
# Act & Assert
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
ShopifySessionService.validate_session_token(token)
|
|
|
|
assert exc_info.value.status_code == 401
|
|
assert "Invalid session token" in str(exc_info.value.detail)
|
|
|
|
@patch('app.services.shopify_session.settings')
|
|
def test_validate_session_token_invalid_signature(self, mock_settings):
|
|
"""Test validation of token with invalid signature"""
|
|
# Arrange
|
|
mock_settings.SHOPIFY_API_SECRET = "test_secret"
|
|
mock_settings.SHOPIFY_API_KEY = "test_key"
|
|
|
|
# Create a JWT token with wrong secret
|
|
payload = {
|
|
'dest': 'https://test-shop.myshopify.com',
|
|
'aud': 'test_key',
|
|
'exp': int(time.time()) + 3600,
|
|
'nbf': int(time.time()) - 60,
|
|
'iat': int(time.time())
|
|
}
|
|
token = jwt.encode(payload, "wrong_secret", algorithm="HS256")
|
|
|
|
# Act & Assert
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
ShopifySessionService.validate_session_token(token)
|
|
|
|
assert exc_info.value.status_code == 401
|
|
assert "Invalid session token" in str(exc_info.value.detail)
|
|
assert exc_info.value.headers.get("X-Shopify-Retry-Invalid-Session-Request") == "1"
|
|
|
|
def test_validate_session_token_malformed_token(self):
|
|
"""Test validation of malformed token"""
|
|
# Arrange
|
|
malformed_token = "not.a.valid.jwt.token"
|
|
|
|
# Act & Assert
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
ShopifySessionService.validate_session_token(malformed_token)
|
|
|
|
assert exc_info.value.status_code == 401
|
|
assert "Invalid session token" in str(exc_info.value.detail)
|
|
|
|
def test_is_document_request_no_auth_header(self):
|
|
"""Test document request detection when no Authorization header"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.headers = {}
|
|
|
|
# Act
|
|
is_document = ShopifySessionService.is_document_request(mock_request)
|
|
|
|
# Assert
|
|
assert is_document is True
|
|
|
|
def test_is_document_request_with_auth_header(self):
|
|
"""Test document request detection when Authorization header present"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.headers = {"authorization": "Bearer token"}
|
|
|
|
# Act
|
|
is_document = ShopifySessionService.is_document_request(mock_request)
|
|
|
|
# Assert
|
|
assert is_document is False
|
|
|
|
|
|
class TestRequireShopifySession:
|
|
"""Test cases for require_shopify_session dependency"""
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('app.repositories.shopify_shop_repository.ShopifyShopRepository')
|
|
@patch('app.services.shopify_session.ShopifySessionService.validate_session_token')
|
|
@patch('app.services.shopify_session.ShopifySessionService.get_session_token_from_request')
|
|
async def test_require_shopify_session_success(
|
|
self, mock_get_token, mock_validate_token, mock_shop_repo
|
|
):
|
|
"""Test successful session validation with shop lookup"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_db = MagicMock(spec=Session)
|
|
|
|
shop_id = str(uuid.uuid4())
|
|
org_id = str(uuid.uuid4())
|
|
|
|
mock_get_token.return_value = "valid_token"
|
|
mock_validate_token.return_value = {
|
|
'dest': 'https://test-shop.myshopify.com',
|
|
'aud': 'test_key',
|
|
'exp': int(time.time()) + 3600
|
|
}
|
|
|
|
# Mock shop repository
|
|
mock_shop_repo_instance = MagicMock()
|
|
mock_shop_repo.return_value = mock_shop_repo_instance
|
|
|
|
mock_shop = MagicMock()
|
|
mock_shop.id = shop_id
|
|
mock_shop.organization_id = org_id
|
|
mock_shop.is_installed = True
|
|
mock_shop_repo_instance.get_shop_by_domain.return_value = mock_shop
|
|
|
|
# Act
|
|
result = await require_shopify_session(mock_request, mock_db)
|
|
|
|
# Assert
|
|
assert result['shop_id'] == shop_id
|
|
assert result['organization_id'] == org_id
|
|
assert result['db_shop'] == mock_shop
|
|
assert result['dest'] == 'https://test-shop.myshopify.com'
|
|
mock_shop_repo_instance.get_shop_by_domain.assert_called_once_with('test-shop.myshopify.com')
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('app.services.shopify_session.ShopifySessionService.get_session_token_from_request')
|
|
async def test_require_shopify_session_no_token(self, mock_get_token):
|
|
"""Test when no session token is provided"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_db = MagicMock(spec=Session)
|
|
mock_get_token.return_value = None
|
|
|
|
# Act & Assert
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await require_shopify_session(mock_request, mock_db)
|
|
|
|
assert exc_info.value.status_code == 401
|
|
assert "Session token required" in str(exc_info.value.detail)
|
|
assert exc_info.value.headers.get("X-Shopify-Retry-Invalid-Session-Request") == "1"
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('app.repositories.shopify_shop_repository.ShopifyShopRepository')
|
|
@patch('app.services.shopify_session.ShopifySessionService.validate_session_token')
|
|
@patch('app.services.shopify_session.ShopifySessionService.get_session_token_from_request')
|
|
async def test_require_shopify_session_shop_not_found(
|
|
self, mock_get_token, mock_validate_token, mock_shop_repo
|
|
):
|
|
"""Test when shop is not found in database"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_db = MagicMock(spec=Session)
|
|
|
|
mock_get_token.return_value = "valid_token"
|
|
mock_validate_token.return_value = {
|
|
'dest': 'https://nonexistent-shop.myshopify.com'
|
|
}
|
|
|
|
mock_shop_repo_instance = MagicMock()
|
|
mock_shop_repo.return_value = mock_shop_repo_instance
|
|
mock_shop_repo_instance.get_shop_by_domain.return_value = None
|
|
|
|
# Act & Assert
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await require_shopify_session(mock_request, mock_db)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
assert "Shop not installed or not found" in str(exc_info.value.detail)
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('app.repositories.shopify_shop_repository.ShopifyShopRepository')
|
|
@patch('app.services.shopify_session.ShopifySessionService.validate_session_token')
|
|
@patch('app.services.shopify_session.ShopifySessionService.get_session_token_from_request')
|
|
async def test_require_shopify_session_shop_not_installed(
|
|
self, mock_get_token, mock_validate_token, mock_shop_repo
|
|
):
|
|
"""Test when shop exists but is not installed"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_db = MagicMock(spec=Session)
|
|
|
|
mock_get_token.return_value = "valid_token"
|
|
mock_validate_token.return_value = {
|
|
'dest': 'https://test-shop.myshopify.com'
|
|
}
|
|
|
|
mock_shop_repo_instance = MagicMock()
|
|
mock_shop_repo.return_value = mock_shop_repo_instance
|
|
|
|
mock_shop = MagicMock()
|
|
mock_shop.is_installed = False # Not installed
|
|
mock_shop_repo_instance.get_shop_by_domain.return_value = mock_shop
|
|
|
|
# Act & Assert
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await require_shopify_session(mock_request, mock_db)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
assert "Shop not installed or not found" in str(exc_info.value.detail)
|
|
|
|
|
|
class TestRequireShopifyOrJwtAuth:
|
|
"""Test cases for require_shopify_or_jwt_auth hybrid dependency"""
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('app.repositories.shopify_shop_repository.ShopifyShopRepository')
|
|
@patch('app.services.shopify_session.ShopifySessionService.validate_session_token')
|
|
@patch('app.services.shopify_session.ShopifySessionService.get_session_token_from_request')
|
|
async def test_require_shopify_or_jwt_auth_shopify_success(
|
|
self, mock_get_token, mock_validate_token, mock_shop_repo
|
|
):
|
|
"""Test successful Shopify session token authentication"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_db = MagicMock(spec=Session)
|
|
|
|
shop_id = str(uuid.uuid4())
|
|
org_id = str(uuid.uuid4())
|
|
|
|
mock_get_token.return_value = "valid_token"
|
|
mock_validate_token.return_value = {
|
|
'dest': 'https://test-shop.myshopify.com',
|
|
'aud': 'test_key'
|
|
}
|
|
|
|
# Mock shop repository
|
|
mock_shop_repo_instance = MagicMock()
|
|
mock_shop_repo.return_value = mock_shop_repo_instance
|
|
|
|
mock_shop = MagicMock()
|
|
mock_shop.id = shop_id
|
|
mock_shop.organization_id = org_id
|
|
mock_shop.is_installed = True
|
|
mock_shop_repo_instance.get_shop_by_domain.return_value = mock_shop
|
|
|
|
# Act
|
|
result = await require_shopify_or_jwt_auth(mock_request, mock_db)
|
|
|
|
# Assert
|
|
assert result['auth_type'] == 'shopify_session'
|
|
assert result['shop_id'] == shop_id
|
|
assert result['organization_id'] == org_id
|
|
assert result['user_id'] is None
|
|
assert result['db_shop'] == mock_shop
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('app.core.auth.get_current_user')
|
|
@patch('app.services.shopify_session.ShopifySessionService.get_session_token_from_request')
|
|
async def test_require_shopify_or_jwt_auth_jwt_fallback(
|
|
self, mock_get_token, mock_get_current_user
|
|
):
|
|
"""Test fallback to JWT authentication when no Shopify token"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_db = MagicMock(spec=Session)
|
|
|
|
user_id = str(uuid.uuid4())
|
|
org_id = str(uuid.uuid4())
|
|
|
|
mock_get_token.return_value = None # No Shopify token
|
|
|
|
# Mock JWT user
|
|
mock_user = MagicMock()
|
|
mock_user.id = user_id
|
|
mock_user.organization_id = org_id
|
|
mock_get_current_user.return_value = mock_user
|
|
|
|
# Act
|
|
result = await require_shopify_or_jwt_auth(mock_request, mock_db)
|
|
|
|
# Assert
|
|
assert result['auth_type'] == 'jwt'
|
|
assert result['user_id'] == user_id
|
|
assert result['organization_id'] == org_id
|
|
assert result['current_user'] == mock_user
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('app.core.auth.get_current_user')
|
|
@patch('app.services.shopify_session.ShopifySessionService.validate_session_token')
|
|
@patch('app.services.shopify_session.ShopifySessionService.get_session_token_from_request')
|
|
async def test_require_shopify_or_jwt_auth_shopify_fails_jwt_success(
|
|
self, mock_get_token, mock_validate_token, mock_get_current_user
|
|
):
|
|
"""Test fallback to JWT when Shopify token validation fails"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_db = MagicMock(spec=Session)
|
|
|
|
user_id = str(uuid.uuid4())
|
|
org_id = str(uuid.uuid4())
|
|
|
|
mock_get_token.return_value = "invalid_token"
|
|
mock_validate_token.side_effect = HTTPException(status_code=401, detail="Invalid token")
|
|
|
|
# Mock JWT user
|
|
mock_user = MagicMock()
|
|
mock_user.id = user_id
|
|
mock_user.organization_id = org_id
|
|
mock_get_current_user.return_value = mock_user
|
|
|
|
# Act
|
|
result = await require_shopify_or_jwt_auth(mock_request, mock_db)
|
|
|
|
# Assert
|
|
assert result['auth_type'] == 'jwt'
|
|
assert result['user_id'] == user_id
|
|
assert result['organization_id'] == org_id
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('app.core.auth.get_current_user')
|
|
@patch('app.services.shopify_session.ShopifySessionService.get_session_token_from_request')
|
|
async def test_require_shopify_or_jwt_auth_both_fail(
|
|
self, mock_get_token, mock_get_current_user
|
|
):
|
|
"""Test when both Shopify and JWT authentication fail"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_db = MagicMock(spec=Session)
|
|
|
|
mock_get_token.return_value = None # No Shopify token
|
|
mock_get_current_user.side_effect = HTTPException(status_code=401, detail="Not authenticated")
|
|
|
|
# Act & Assert
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await require_shopify_or_jwt_auth(mock_request, mock_db)
|
|
|
|
assert exc_info.value.status_code == 401
|
|
assert "Authentication required" in str(exc_info.value.detail)
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('app.repositories.shopify_shop_repository.ShopifyShopRepository')
|
|
@patch('app.core.auth.get_current_user')
|
|
@patch('app.services.shopify_session.ShopifySessionService.validate_session_token')
|
|
@patch('app.services.shopify_session.ShopifySessionService.get_session_token_from_request')
|
|
async def test_require_shopify_or_jwt_auth_shopify_shop_not_linked(
|
|
self, mock_get_token, mock_validate_token, mock_get_current_user, mock_shop_repo
|
|
):
|
|
"""Test fallback to JWT when Shopify shop is not linked to organization"""
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_db = MagicMock(spec=Session)
|
|
|
|
user_id = str(uuid.uuid4())
|
|
org_id = str(uuid.uuid4())
|
|
|
|
mock_get_token.return_value = "valid_token"
|
|
mock_validate_token.return_value = {
|
|
'dest': 'https://test-shop.myshopify.com'
|
|
}
|
|
|
|
# Mock shop repository - shop exists but no organization_id
|
|
mock_shop_repo_instance = MagicMock()
|
|
mock_shop_repo.return_value = mock_shop_repo_instance
|
|
|
|
mock_shop = MagicMock()
|
|
mock_shop.is_installed = True
|
|
mock_shop.organization_id = None # Not linked to organization
|
|
mock_shop_repo_instance.get_shop_by_domain.return_value = mock_shop
|
|
|
|
# Mock JWT user
|
|
mock_user = MagicMock()
|
|
mock_user.id = user_id
|
|
mock_user.organization_id = org_id
|
|
mock_get_current_user.return_value = mock_user
|
|
|
|
# Act
|
|
result = await require_shopify_or_jwt_auth(mock_request, mock_db)
|
|
|
|
# Assert
|
|
assert result['auth_type'] == 'jwt'
|
|
assert result['user_id'] == user_id
|
|
assert result['organization_id'] == org_id
|