merge main
commit
4d36e784b7
@ -0,0 +1,51 @@
|
|||||||
|
"""update models
|
||||||
|
|
||||||
|
Revision ID: 1a83934ad6d1
|
||||||
|
Revises: 71f5020c6470
|
||||||
|
Create Date: 2025-07-21 09:35:48.774794
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import models as models
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '1a83934ad6d1'
|
||||||
|
down_revision = '71f5020c6470'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('tool_mcp_providers', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column('server_identifier',
|
||||||
|
existing_type=sa.VARCHAR(length=24),
|
||||||
|
type_=sa.String(length=64),
|
||||||
|
existing_nullable=False)
|
||||||
|
|
||||||
|
with op.batch_alter_table('tool_model_invokes', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column('tool_name',
|
||||||
|
existing_type=sa.VARCHAR(length=40),
|
||||||
|
type_=sa.String(length=128),
|
||||||
|
existing_nullable=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('tool_model_invokes', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column('tool_name',
|
||||||
|
existing_type=sa.String(length=128),
|
||||||
|
type_=sa.VARCHAR(length=40),
|
||||||
|
existing_nullable=False)
|
||||||
|
|
||||||
|
with op.batch_alter_table('tool_mcp_providers', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column('server_identifier',
|
||||||
|
existing_type=sa.String(length=64),
|
||||||
|
type_=sa.VARCHAR(length=24),
|
||||||
|
existing_nullable=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@ -0,0 +1 @@
|
|||||||
|
# API authentication service test module
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.auth.api_key_auth_base import ApiKeyAuthBase
|
||||||
|
|
||||||
|
|
||||||
|
class ConcreteApiKeyAuth(ApiKeyAuthBase):
|
||||||
|
"""Concrete implementation for testing abstract base class"""
|
||||||
|
|
||||||
|
def validate_credentials(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiKeyAuthBase:
|
||||||
|
def test_should_store_credentials_on_init(self):
|
||||||
|
"""Test that credentials are properly stored during initialization"""
|
||||||
|
credentials = {"api_key": "test_key", "auth_type": "bearer"}
|
||||||
|
auth = ConcreteApiKeyAuth(credentials)
|
||||||
|
assert auth.credentials == credentials
|
||||||
|
|
||||||
|
def test_should_not_instantiate_abstract_class(self):
|
||||||
|
"""Test that ApiKeyAuthBase cannot be instantiated directly"""
|
||||||
|
credentials = {"api_key": "test_key"}
|
||||||
|
|
||||||
|
with pytest.raises(TypeError) as exc_info:
|
||||||
|
ApiKeyAuthBase(credentials)
|
||||||
|
|
||||||
|
assert "Can't instantiate abstract class" in str(exc_info.value)
|
||||||
|
assert "validate_credentials" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_should_allow_subclass_implementation(self):
|
||||||
|
"""Test that subclasses can properly implement the abstract method"""
|
||||||
|
credentials = {"api_key": "test_key", "auth_type": "bearer"}
|
||||||
|
auth = ConcreteApiKeyAuth(credentials)
|
||||||
|
|
||||||
|
# Should not raise any exception
|
||||||
|
result = auth.validate_credentials()
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_should_handle_empty_credentials(self):
|
||||||
|
"""Test initialization with empty credentials"""
|
||||||
|
credentials = {}
|
||||||
|
auth = ConcreteApiKeyAuth(credentials)
|
||||||
|
assert auth.credentials == {}
|
||||||
|
|
||||||
|
def test_should_handle_none_credentials(self):
|
||||||
|
"""Test initialization with None credentials"""
|
||||||
|
credentials = None
|
||||||
|
auth = ConcreteApiKeyAuth(credentials)
|
||||||
|
assert auth.credentials is None
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.auth.api_key_auth_factory import ApiKeyAuthFactory
|
||||||
|
from services.auth.auth_type import AuthType
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiKeyAuthFactory:
|
||||||
|
"""Test cases for ApiKeyAuthFactory"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("provider", "auth_class_path"),
|
||||||
|
[
|
||||||
|
(AuthType.FIRECRAWL, "services.auth.firecrawl.firecrawl.FirecrawlAuth"),
|
||||||
|
(AuthType.WATERCRAWL, "services.auth.watercrawl.watercrawl.WatercrawlAuth"),
|
||||||
|
(AuthType.JINA, "services.auth.jina.jina.JinaAuth"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_get_apikey_auth_factory_valid_providers(self, provider, auth_class_path):
|
||||||
|
"""Test getting auth factory for all valid providers"""
|
||||||
|
with patch(auth_class_path) as mock_auth:
|
||||||
|
auth_class = ApiKeyAuthFactory.get_apikey_auth_factory(provider)
|
||||||
|
assert auth_class == mock_auth
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"invalid_provider",
|
||||||
|
[
|
||||||
|
"invalid_provider",
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
123,
|
||||||
|
"UNSUPPORTED",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_get_apikey_auth_factory_invalid_providers(self, invalid_provider):
|
||||||
|
"""Test getting auth factory with various invalid providers"""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
ApiKeyAuthFactory.get_apikey_auth_factory(invalid_provider)
|
||||||
|
assert str(exc_info.value) == "Invalid provider"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("credentials_return_value", "expected_result"),
|
||||||
|
[
|
||||||
|
(True, True),
|
||||||
|
(False, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("services.auth.api_key_auth_factory.ApiKeyAuthFactory.get_apikey_auth_factory")
|
||||||
|
def test_validate_credentials_delegates_to_auth_instance(
|
||||||
|
self, mock_get_factory, credentials_return_value, expected_result
|
||||||
|
):
|
||||||
|
"""Test that validate_credentials delegates to auth instance correctly"""
|
||||||
|
# Arrange
|
||||||
|
mock_auth_instance = MagicMock()
|
||||||
|
mock_auth_instance.validate_credentials.return_value = credentials_return_value
|
||||||
|
mock_auth_class = MagicMock(return_value=mock_auth_instance)
|
||||||
|
mock_get_factory.return_value = mock_auth_class
|
||||||
|
|
||||||
|
# Act
|
||||||
|
factory = ApiKeyAuthFactory(AuthType.FIRECRAWL, {"api_key": "test_key"})
|
||||||
|
result = factory.validate_credentials()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is expected_result
|
||||||
|
mock_auth_instance.validate_credentials.assert_called_once()
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_factory.ApiKeyAuthFactory.get_apikey_auth_factory")
|
||||||
|
def test_validate_credentials_propagates_exceptions(self, mock_get_factory):
|
||||||
|
"""Test that exceptions from auth instance are propagated"""
|
||||||
|
# Arrange
|
||||||
|
mock_auth_instance = MagicMock()
|
||||||
|
mock_auth_instance.validate_credentials.side_effect = Exception("Authentication error")
|
||||||
|
mock_auth_class = MagicMock(return_value=mock_auth_instance)
|
||||||
|
mock_get_factory.return_value = mock_auth_class
|
||||||
|
|
||||||
|
# Act & Assert
|
||||||
|
factory = ApiKeyAuthFactory(AuthType.FIRECRAWL, {"api_key": "test_key"})
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
factory.validate_credentials()
|
||||||
|
assert str(exc_info.value) == "Authentication error"
|
||||||
@ -0,0 +1,382 @@
|
|||||||
|
import json
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from models.source import DataSourceApiKeyAuthBinding
|
||||||
|
from services.auth.api_key_auth_service import ApiKeyAuthService
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiKeyAuthService:
|
||||||
|
"""API key authentication service security tests"""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Setup test fixtures"""
|
||||||
|
self.tenant_id = "test_tenant_123"
|
||||||
|
self.category = "search"
|
||||||
|
self.provider = "google"
|
||||||
|
self.binding_id = "binding_123"
|
||||||
|
self.mock_credentials = {"auth_type": "api_key", "config": {"api_key": "test_secret_key_123"}}
|
||||||
|
self.mock_args = {"category": self.category, "provider": self.provider, "credentials": self.mock_credentials}
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
def test_get_provider_auth_list_success(self, mock_session):
|
||||||
|
"""Test get provider auth list - success scenario"""
|
||||||
|
# Mock database query result
|
||||||
|
mock_binding = Mock()
|
||||||
|
mock_binding.tenant_id = self.tenant_id
|
||||||
|
mock_binding.provider = self.provider
|
||||||
|
mock_binding.disabled = False
|
||||||
|
|
||||||
|
mock_session.query.return_value.filter.return_value.all.return_value = [mock_binding]
|
||||||
|
|
||||||
|
result = ApiKeyAuthService.get_provider_auth_list(self.tenant_id)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].tenant_id == self.tenant_id
|
||||||
|
mock_session.query.assert_called_once_with(DataSourceApiKeyAuthBinding)
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
def test_get_provider_auth_list_empty(self, mock_session):
|
||||||
|
"""Test get provider auth list - empty result"""
|
||||||
|
mock_session.query.return_value.filter.return_value.all.return_value = []
|
||||||
|
|
||||||
|
result = ApiKeyAuthService.get_provider_auth_list(self.tenant_id)
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
def test_get_provider_auth_list_filters_disabled(self, mock_session):
|
||||||
|
"""Test get provider auth list - filters disabled items"""
|
||||||
|
mock_session.query.return_value.filter.return_value.all.return_value = []
|
||||||
|
|
||||||
|
ApiKeyAuthService.get_provider_auth_list(self.tenant_id)
|
||||||
|
|
||||||
|
# Verify filter conditions include disabled.is_(False)
|
||||||
|
filter_call = mock_session.query.return_value.filter.call_args[0]
|
||||||
|
assert len(filter_call) == 2 # tenant_id and disabled filter conditions
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
@patch("services.auth.api_key_auth_service.ApiKeyAuthFactory")
|
||||||
|
@patch("services.auth.api_key_auth_service.encrypter")
|
||||||
|
def test_create_provider_auth_success(self, mock_encrypter, mock_factory, mock_session):
|
||||||
|
"""Test create provider auth - success scenario"""
|
||||||
|
# Mock successful auth validation
|
||||||
|
mock_auth_instance = Mock()
|
||||||
|
mock_auth_instance.validate_credentials.return_value = True
|
||||||
|
mock_factory.return_value = mock_auth_instance
|
||||||
|
|
||||||
|
# Mock encryption
|
||||||
|
encrypted_key = "encrypted_test_key_123"
|
||||||
|
mock_encrypter.encrypt_token.return_value = encrypted_key
|
||||||
|
|
||||||
|
# Mock database operations
|
||||||
|
mock_session.add = Mock()
|
||||||
|
mock_session.commit = Mock()
|
||||||
|
|
||||||
|
ApiKeyAuthService.create_provider_auth(self.tenant_id, self.mock_args)
|
||||||
|
|
||||||
|
# Verify factory class calls
|
||||||
|
mock_factory.assert_called_once_with(self.provider, self.mock_credentials)
|
||||||
|
mock_auth_instance.validate_credentials.assert_called_once()
|
||||||
|
|
||||||
|
# Verify encryption calls
|
||||||
|
mock_encrypter.encrypt_token.assert_called_once_with(self.tenant_id, "test_secret_key_123")
|
||||||
|
|
||||||
|
# Verify database operations
|
||||||
|
mock_session.add.assert_called_once()
|
||||||
|
mock_session.commit.assert_called_once()
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
@patch("services.auth.api_key_auth_service.ApiKeyAuthFactory")
|
||||||
|
def test_create_provider_auth_validation_failed(self, mock_factory, mock_session):
|
||||||
|
"""Test create provider auth - validation failed"""
|
||||||
|
# Mock failed auth validation
|
||||||
|
mock_auth_instance = Mock()
|
||||||
|
mock_auth_instance.validate_credentials.return_value = False
|
||||||
|
mock_factory.return_value = mock_auth_instance
|
||||||
|
|
||||||
|
ApiKeyAuthService.create_provider_auth(self.tenant_id, self.mock_args)
|
||||||
|
|
||||||
|
# Verify no database operations when validation fails
|
||||||
|
mock_session.add.assert_not_called()
|
||||||
|
mock_session.commit.assert_not_called()
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
@patch("services.auth.api_key_auth_service.ApiKeyAuthFactory")
|
||||||
|
@patch("services.auth.api_key_auth_service.encrypter")
|
||||||
|
def test_create_provider_auth_encrypts_api_key(self, mock_encrypter, mock_factory, mock_session):
|
||||||
|
"""Test create provider auth - ensures API key is encrypted"""
|
||||||
|
# Mock successful auth validation
|
||||||
|
mock_auth_instance = Mock()
|
||||||
|
mock_auth_instance.validate_credentials.return_value = True
|
||||||
|
mock_factory.return_value = mock_auth_instance
|
||||||
|
|
||||||
|
# Mock encryption
|
||||||
|
encrypted_key = "encrypted_test_key_123"
|
||||||
|
mock_encrypter.encrypt_token.return_value = encrypted_key
|
||||||
|
|
||||||
|
# Mock database operations
|
||||||
|
mock_session.add = Mock()
|
||||||
|
mock_session.commit = Mock()
|
||||||
|
|
||||||
|
args_copy = self.mock_args.copy()
|
||||||
|
original_key = args_copy["credentials"]["config"]["api_key"] # type: ignore
|
||||||
|
|
||||||
|
ApiKeyAuthService.create_provider_auth(self.tenant_id, args_copy)
|
||||||
|
|
||||||
|
# Verify original key is replaced with encrypted key
|
||||||
|
assert args_copy["credentials"]["config"]["api_key"] == encrypted_key # type: ignore
|
||||||
|
assert args_copy["credentials"]["config"]["api_key"] != original_key # type: ignore
|
||||||
|
|
||||||
|
# Verify encryption function is called correctly
|
||||||
|
mock_encrypter.encrypt_token.assert_called_once_with(self.tenant_id, original_key)
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
def test_get_auth_credentials_success(self, mock_session):
|
||||||
|
"""Test get auth credentials - success scenario"""
|
||||||
|
# Mock database query result
|
||||||
|
mock_binding = Mock()
|
||||||
|
mock_binding.credentials = json.dumps(self.mock_credentials)
|
||||||
|
mock_session.query.return_value.filter.return_value.first.return_value = mock_binding
|
||||||
|
|
||||||
|
result = ApiKeyAuthService.get_auth_credentials(self.tenant_id, self.category, self.provider)
|
||||||
|
|
||||||
|
assert result == self.mock_credentials
|
||||||
|
mock_session.query.assert_called_once_with(DataSourceApiKeyAuthBinding)
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
def test_get_auth_credentials_not_found(self, mock_session):
|
||||||
|
"""Test get auth credentials - not found"""
|
||||||
|
mock_session.query.return_value.filter.return_value.first.return_value = None
|
||||||
|
|
||||||
|
result = ApiKeyAuthService.get_auth_credentials(self.tenant_id, self.category, self.provider)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
def test_get_auth_credentials_filters_correctly(self, mock_session):
|
||||||
|
"""Test get auth credentials - applies correct filters"""
|
||||||
|
mock_session.query.return_value.filter.return_value.first.return_value = None
|
||||||
|
|
||||||
|
ApiKeyAuthService.get_auth_credentials(self.tenant_id, self.category, self.provider)
|
||||||
|
|
||||||
|
# Verify filter conditions are correct
|
||||||
|
filter_call = mock_session.query.return_value.filter.call_args[0]
|
||||||
|
assert len(filter_call) == 4 # tenant_id, category, provider, disabled
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
def test_get_auth_credentials_json_parsing(self, mock_session):
|
||||||
|
"""Test get auth credentials - JSON parsing"""
|
||||||
|
# Mock credentials with special characters
|
||||||
|
special_credentials = {"auth_type": "api_key", "config": {"api_key": "key_with_中文_and_special_chars_!@#$%"}}
|
||||||
|
|
||||||
|
mock_binding = Mock()
|
||||||
|
mock_binding.credentials = json.dumps(special_credentials, ensure_ascii=False)
|
||||||
|
mock_session.query.return_value.filter.return_value.first.return_value = mock_binding
|
||||||
|
|
||||||
|
result = ApiKeyAuthService.get_auth_credentials(self.tenant_id, self.category, self.provider)
|
||||||
|
|
||||||
|
assert result == special_credentials
|
||||||
|
assert result["config"]["api_key"] == "key_with_中文_and_special_chars_!@#$%"
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
def test_delete_provider_auth_success(self, mock_session):
|
||||||
|
"""Test delete provider auth - success scenario"""
|
||||||
|
# Mock database query result
|
||||||
|
mock_binding = Mock()
|
||||||
|
mock_session.query.return_value.filter.return_value.first.return_value = mock_binding
|
||||||
|
|
||||||
|
ApiKeyAuthService.delete_provider_auth(self.tenant_id, self.binding_id)
|
||||||
|
|
||||||
|
# Verify delete operations
|
||||||
|
mock_session.delete.assert_called_once_with(mock_binding)
|
||||||
|
mock_session.commit.assert_called_once()
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
def test_delete_provider_auth_not_found(self, mock_session):
|
||||||
|
"""Test delete provider auth - not found"""
|
||||||
|
mock_session.query.return_value.filter.return_value.first.return_value = None
|
||||||
|
|
||||||
|
ApiKeyAuthService.delete_provider_auth(self.tenant_id, self.binding_id)
|
||||||
|
|
||||||
|
# Verify no delete operations when not found
|
||||||
|
mock_session.delete.assert_not_called()
|
||||||
|
mock_session.commit.assert_not_called()
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
def test_delete_provider_auth_filters_by_tenant(self, mock_session):
|
||||||
|
"""Test delete provider auth - filters by tenant"""
|
||||||
|
mock_session.query.return_value.filter.return_value.first.return_value = None
|
||||||
|
|
||||||
|
ApiKeyAuthService.delete_provider_auth(self.tenant_id, self.binding_id)
|
||||||
|
|
||||||
|
# Verify filter conditions include tenant_id and binding_id
|
||||||
|
filter_call = mock_session.query.return_value.filter.call_args[0]
|
||||||
|
assert len(filter_call) == 2
|
||||||
|
|
||||||
|
def test_validate_api_key_auth_args_success(self):
|
||||||
|
"""Test API key auth args validation - success scenario"""
|
||||||
|
# Should not raise any exception
|
||||||
|
ApiKeyAuthService.validate_api_key_auth_args(self.mock_args)
|
||||||
|
|
||||||
|
def test_validate_api_key_auth_args_missing_category(self):
|
||||||
|
"""Test API key auth args validation - missing category"""
|
||||||
|
args = self.mock_args.copy()
|
||||||
|
del args["category"]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="category is required"):
|
||||||
|
ApiKeyAuthService.validate_api_key_auth_args(args)
|
||||||
|
|
||||||
|
def test_validate_api_key_auth_args_empty_category(self):
|
||||||
|
"""Test API key auth args validation - empty category"""
|
||||||
|
args = self.mock_args.copy()
|
||||||
|
args["category"] = ""
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="category is required"):
|
||||||
|
ApiKeyAuthService.validate_api_key_auth_args(args)
|
||||||
|
|
||||||
|
def test_validate_api_key_auth_args_missing_provider(self):
|
||||||
|
"""Test API key auth args validation - missing provider"""
|
||||||
|
args = self.mock_args.copy()
|
||||||
|
del args["provider"]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="provider is required"):
|
||||||
|
ApiKeyAuthService.validate_api_key_auth_args(args)
|
||||||
|
|
||||||
|
def test_validate_api_key_auth_args_empty_provider(self):
|
||||||
|
"""Test API key auth args validation - empty provider"""
|
||||||
|
args = self.mock_args.copy()
|
||||||
|
args["provider"] = ""
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="provider is required"):
|
||||||
|
ApiKeyAuthService.validate_api_key_auth_args(args)
|
||||||
|
|
||||||
|
def test_validate_api_key_auth_args_missing_credentials(self):
|
||||||
|
"""Test API key auth args validation - missing credentials"""
|
||||||
|
args = self.mock_args.copy()
|
||||||
|
del args["credentials"]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="credentials is required"):
|
||||||
|
ApiKeyAuthService.validate_api_key_auth_args(args)
|
||||||
|
|
||||||
|
def test_validate_api_key_auth_args_empty_credentials(self):
|
||||||
|
"""Test API key auth args validation - empty credentials"""
|
||||||
|
args = self.mock_args.copy()
|
||||||
|
args["credentials"] = None # type: ignore
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="credentials is required"):
|
||||||
|
ApiKeyAuthService.validate_api_key_auth_args(args)
|
||||||
|
|
||||||
|
def test_validate_api_key_auth_args_invalid_credentials_type(self):
|
||||||
|
"""Test API key auth args validation - invalid credentials type"""
|
||||||
|
args = self.mock_args.copy()
|
||||||
|
args["credentials"] = "not_a_dict"
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="credentials must be a dictionary"):
|
||||||
|
ApiKeyAuthService.validate_api_key_auth_args(args)
|
||||||
|
|
||||||
|
def test_validate_api_key_auth_args_missing_auth_type(self):
|
||||||
|
"""Test API key auth args validation - missing auth_type"""
|
||||||
|
args = self.mock_args.copy()
|
||||||
|
del args["credentials"]["auth_type"] # type: ignore
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="auth_type is required"):
|
||||||
|
ApiKeyAuthService.validate_api_key_auth_args(args)
|
||||||
|
|
||||||
|
def test_validate_api_key_auth_args_empty_auth_type(self):
|
||||||
|
"""Test API key auth args validation - empty auth_type"""
|
||||||
|
args = self.mock_args.copy()
|
||||||
|
args["credentials"]["auth_type"] = "" # type: ignore
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="auth_type is required"):
|
||||||
|
ApiKeyAuthService.validate_api_key_auth_args(args)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"malicious_input",
|
||||||
|
[
|
||||||
|
"<script>alert('xss')</script>",
|
||||||
|
"'; DROP TABLE users; --",
|
||||||
|
"../../../etc/passwd",
|
||||||
|
"\\x00\\x00", # null bytes
|
||||||
|
"A" * 10000, # very long input
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_api_key_auth_args_malicious_input(self, malicious_input):
|
||||||
|
"""Test API key auth args validation - malicious input"""
|
||||||
|
args = self.mock_args.copy()
|
||||||
|
args["category"] = malicious_input
|
||||||
|
|
||||||
|
# Verify parameter validator doesn't crash on malicious input
|
||||||
|
# Should validate normally rather than raising security-related exceptions
|
||||||
|
ApiKeyAuthService.validate_api_key_auth_args(args)
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
@patch("services.auth.api_key_auth_service.ApiKeyAuthFactory")
|
||||||
|
@patch("services.auth.api_key_auth_service.encrypter")
|
||||||
|
def test_create_provider_auth_database_error_handling(self, mock_encrypter, mock_factory, mock_session):
|
||||||
|
"""Test create provider auth - database error handling"""
|
||||||
|
# Mock successful auth validation
|
||||||
|
mock_auth_instance = Mock()
|
||||||
|
mock_auth_instance.validate_credentials.return_value = True
|
||||||
|
mock_factory.return_value = mock_auth_instance
|
||||||
|
|
||||||
|
# Mock encryption
|
||||||
|
mock_encrypter.encrypt_token.return_value = "encrypted_key"
|
||||||
|
|
||||||
|
# Mock database error
|
||||||
|
mock_session.commit.side_effect = Exception("Database error")
|
||||||
|
|
||||||
|
with pytest.raises(Exception, match="Database error"):
|
||||||
|
ApiKeyAuthService.create_provider_auth(self.tenant_id, self.mock_args)
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
def test_get_auth_credentials_invalid_json(self, mock_session):
|
||||||
|
"""Test get auth credentials - invalid JSON"""
|
||||||
|
# Mock database returning invalid JSON
|
||||||
|
mock_binding = Mock()
|
||||||
|
mock_binding.credentials = "invalid json content"
|
||||||
|
mock_session.query.return_value.filter.return_value.first.return_value = mock_binding
|
||||||
|
|
||||||
|
with pytest.raises(json.JSONDecodeError):
|
||||||
|
ApiKeyAuthService.get_auth_credentials(self.tenant_id, self.category, self.provider)
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
@patch("services.auth.api_key_auth_service.ApiKeyAuthFactory")
|
||||||
|
def test_create_provider_auth_factory_exception(self, mock_factory, mock_session):
|
||||||
|
"""Test create provider auth - factory exception"""
|
||||||
|
# Mock factory raising exception
|
||||||
|
mock_factory.side_effect = Exception("Factory error")
|
||||||
|
|
||||||
|
with pytest.raises(Exception, match="Factory error"):
|
||||||
|
ApiKeyAuthService.create_provider_auth(self.tenant_id, self.mock_args)
|
||||||
|
|
||||||
|
@patch("services.auth.api_key_auth_service.db.session")
|
||||||
|
@patch("services.auth.api_key_auth_service.ApiKeyAuthFactory")
|
||||||
|
@patch("services.auth.api_key_auth_service.encrypter")
|
||||||
|
def test_create_provider_auth_encryption_exception(self, mock_encrypter, mock_factory, mock_session):
|
||||||
|
"""Test create provider auth - encryption exception"""
|
||||||
|
# Mock successful auth validation
|
||||||
|
mock_auth_instance = Mock()
|
||||||
|
mock_auth_instance.validate_credentials.return_value = True
|
||||||
|
mock_factory.return_value = mock_auth_instance
|
||||||
|
|
||||||
|
# Mock encryption exception
|
||||||
|
mock_encrypter.encrypt_token.side_effect = Exception("Encryption error")
|
||||||
|
|
||||||
|
with pytest.raises(Exception, match="Encryption error"):
|
||||||
|
ApiKeyAuthService.create_provider_auth(self.tenant_id, self.mock_args)
|
||||||
|
|
||||||
|
def test_validate_api_key_auth_args_none_input(self):
|
||||||
|
"""Test API key auth args validation - None input"""
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
ApiKeyAuthService.validate_api_key_auth_args(None)
|
||||||
|
|
||||||
|
def test_validate_api_key_auth_args_dict_credentials_with_list_auth_type(self):
|
||||||
|
"""Test API key auth args validation - dict credentials with list auth_type"""
|
||||||
|
args = self.mock_args.copy()
|
||||||
|
args["credentials"]["auth_type"] = ["api_key"] # type: ignore # list instead of string
|
||||||
|
|
||||||
|
# Current implementation checks if auth_type exists and is truthy, list ["api_key"] is truthy
|
||||||
|
# So this should not raise exception, this test should pass
|
||||||
|
ApiKeyAuthService.validate_api_key_auth_args(args)
|
||||||
@ -0,0 +1,191 @@
|
|||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from services.auth.firecrawl.firecrawl import FirecrawlAuth
|
||||||
|
|
||||||
|
|
||||||
|
class TestFirecrawlAuth:
|
||||||
|
@pytest.fixture
|
||||||
|
def valid_credentials(self):
|
||||||
|
"""Fixture for valid bearer credentials"""
|
||||||
|
return {"auth_type": "bearer", "config": {"api_key": "test_api_key_123"}}
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_instance(self, valid_credentials):
|
||||||
|
"""Fixture for FirecrawlAuth instance with valid credentials"""
|
||||||
|
return FirecrawlAuth(valid_credentials)
|
||||||
|
|
||||||
|
def test_should_initialize_with_valid_bearer_credentials(self, valid_credentials):
|
||||||
|
"""Test successful initialization with valid bearer credentials"""
|
||||||
|
auth = FirecrawlAuth(valid_credentials)
|
||||||
|
assert auth.api_key == "test_api_key_123"
|
||||||
|
assert auth.base_url == "https://api.firecrawl.dev"
|
||||||
|
assert auth.credentials == valid_credentials
|
||||||
|
|
||||||
|
def test_should_initialize_with_custom_base_url(self):
|
||||||
|
"""Test initialization with custom base URL"""
|
||||||
|
credentials = {
|
||||||
|
"auth_type": "bearer",
|
||||||
|
"config": {"api_key": "test_api_key_123", "base_url": "https://custom.firecrawl.dev"},
|
||||||
|
}
|
||||||
|
auth = FirecrawlAuth(credentials)
|
||||||
|
assert auth.api_key == "test_api_key_123"
|
||||||
|
assert auth.base_url == "https://custom.firecrawl.dev"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("auth_type", "expected_error"),
|
||||||
|
[
|
||||||
|
("basic", "Invalid auth type, Firecrawl auth type must be Bearer"),
|
||||||
|
("x-api-key", "Invalid auth type, Firecrawl auth type must be Bearer"),
|
||||||
|
("", "Invalid auth type, Firecrawl auth type must be Bearer"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_should_raise_error_for_invalid_auth_type(self, auth_type, expected_error):
|
||||||
|
"""Test that non-bearer auth types raise ValueError"""
|
||||||
|
credentials = {"auth_type": auth_type, "config": {"api_key": "test_api_key_123"}}
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
FirecrawlAuth(credentials)
|
||||||
|
assert str(exc_info.value) == expected_error
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("credentials", "expected_error"),
|
||||||
|
[
|
||||||
|
({"auth_type": "bearer", "config": {}}, "No API key provided"),
|
||||||
|
({"auth_type": "bearer"}, "No API key provided"),
|
||||||
|
({"auth_type": "bearer", "config": {"api_key": ""}}, "No API key provided"),
|
||||||
|
({"auth_type": "bearer", "config": {"api_key": None}}, "No API key provided"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_should_raise_error_for_missing_api_key(self, credentials, expected_error):
|
||||||
|
"""Test that missing or empty API key raises ValueError"""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
FirecrawlAuth(credentials)
|
||||||
|
assert str(exc_info.value) == expected_error
|
||||||
|
|
||||||
|
@patch("services.auth.firecrawl.firecrawl.requests.post")
|
||||||
|
def test_should_validate_valid_credentials_successfully(self, mock_post, auth_instance):
|
||||||
|
"""Test successful credential validation"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
result = auth_instance.validate_credentials()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
expected_data = {
|
||||||
|
"url": "https://example.com",
|
||||||
|
"includePaths": [],
|
||||||
|
"excludePaths": [],
|
||||||
|
"limit": 1,
|
||||||
|
"scrapeOptions": {"onlyMainContent": True},
|
||||||
|
}
|
||||||
|
mock_post.assert_called_once_with(
|
||||||
|
"https://api.firecrawl.dev/v1/crawl",
|
||||||
|
headers={"Content-Type": "application/json", "Authorization": "Bearer test_api_key_123"},
|
||||||
|
json=expected_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("status_code", "error_message"),
|
||||||
|
[
|
||||||
|
(402, "Payment required"),
|
||||||
|
(409, "Conflict error"),
|
||||||
|
(500, "Internal server error"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("services.auth.firecrawl.firecrawl.requests.post")
|
||||||
|
def test_should_handle_http_errors(self, mock_post, status_code, error_message, auth_instance):
|
||||||
|
"""Test handling of various HTTP error codes"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = status_code
|
||||||
|
mock_response.json.return_value = {"error": error_message}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
auth_instance.validate_credentials()
|
||||||
|
assert str(exc_info.value) == f"Failed to authorize. Status code: {status_code}. Error: {error_message}"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("status_code", "response_text", "has_json_error", "expected_error_contains"),
|
||||||
|
[
|
||||||
|
(403, '{"error": "Forbidden"}', True, "Failed to authorize. Status code: 403. Error: Forbidden"),
|
||||||
|
(404, "", True, "Unexpected error occurred while trying to authorize. Status code: 404"),
|
||||||
|
(401, "Not JSON", True, "Expecting value"), # JSON decode error
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("services.auth.firecrawl.firecrawl.requests.post")
|
||||||
|
def test_should_handle_unexpected_errors(
|
||||||
|
self, mock_post, status_code, response_text, has_json_error, expected_error_contains, auth_instance
|
||||||
|
):
|
||||||
|
"""Test handling of unexpected errors with various response formats"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = status_code
|
||||||
|
mock_response.text = response_text
|
||||||
|
if has_json_error:
|
||||||
|
mock_response.json.side_effect = Exception("Not JSON")
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
auth_instance.validate_credentials()
|
||||||
|
assert expected_error_contains in str(exc_info.value)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("exception_type", "exception_message"),
|
||||||
|
[
|
||||||
|
(requests.ConnectionError, "Network error"),
|
||||||
|
(requests.Timeout, "Request timeout"),
|
||||||
|
(requests.ReadTimeout, "Read timeout"),
|
||||||
|
(requests.ConnectTimeout, "Connection timeout"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("services.auth.firecrawl.firecrawl.requests.post")
|
||||||
|
def test_should_handle_network_errors(self, mock_post, exception_type, exception_message, auth_instance):
|
||||||
|
"""Test handling of various network-related errors including timeouts"""
|
||||||
|
mock_post.side_effect = exception_type(exception_message)
|
||||||
|
|
||||||
|
with pytest.raises(exception_type) as exc_info:
|
||||||
|
auth_instance.validate_credentials()
|
||||||
|
assert exception_message in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_should_not_expose_api_key_in_error_messages(self):
|
||||||
|
"""Test that API key is not exposed in error messages"""
|
||||||
|
credentials = {"auth_type": "bearer", "config": {"api_key": "super_secret_key_12345"}}
|
||||||
|
auth = FirecrawlAuth(credentials)
|
||||||
|
|
||||||
|
# Verify API key is stored but not in any error message
|
||||||
|
assert auth.api_key == "super_secret_key_12345"
|
||||||
|
|
||||||
|
# Test various error scenarios don't expose the key
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
FirecrawlAuth({"auth_type": "basic", "config": {"api_key": "super_secret_key_12345"}})
|
||||||
|
assert "super_secret_key_12345" not in str(exc_info.value)
|
||||||
|
|
||||||
|
@patch("services.auth.firecrawl.firecrawl.requests.post")
|
||||||
|
def test_should_use_custom_base_url_in_validation(self, mock_post):
|
||||||
|
"""Test that custom base URL is used in validation"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
credentials = {
|
||||||
|
"auth_type": "bearer",
|
||||||
|
"config": {"api_key": "test_api_key_123", "base_url": "https://custom.firecrawl.dev"},
|
||||||
|
}
|
||||||
|
auth = FirecrawlAuth(credentials)
|
||||||
|
result = auth.validate_credentials()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_post.call_args[0][0] == "https://custom.firecrawl.dev/v1/crawl"
|
||||||
|
|
||||||
|
@patch("services.auth.firecrawl.firecrawl.requests.post")
|
||||||
|
def test_should_handle_timeout_with_retry_suggestion(self, mock_post, auth_instance):
|
||||||
|
"""Test that timeout errors are handled gracefully with appropriate error message"""
|
||||||
|
mock_post.side_effect = requests.Timeout("The request timed out after 30 seconds")
|
||||||
|
|
||||||
|
with pytest.raises(requests.Timeout) as exc_info:
|
||||||
|
auth_instance.validate_credentials()
|
||||||
|
|
||||||
|
# Verify the timeout exception is raised with original message
|
||||||
|
assert "timed out" in str(exc_info.value)
|
||||||
@ -0,0 +1,155 @@
|
|||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from services.auth.jina.jina import JinaAuth
|
||||||
|
|
||||||
|
|
||||||
|
class TestJinaAuth:
|
||||||
|
def test_should_initialize_with_valid_bearer_credentials(self):
|
||||||
|
"""Test successful initialization with valid bearer credentials"""
|
||||||
|
credentials = {"auth_type": "bearer", "config": {"api_key": "test_api_key_123"}}
|
||||||
|
auth = JinaAuth(credentials)
|
||||||
|
assert auth.api_key == "test_api_key_123"
|
||||||
|
assert auth.credentials == credentials
|
||||||
|
|
||||||
|
def test_should_raise_error_for_invalid_auth_type(self):
|
||||||
|
"""Test that non-bearer auth type raises ValueError"""
|
||||||
|
credentials = {"auth_type": "basic", "config": {"api_key": "test_api_key_123"}}
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
JinaAuth(credentials)
|
||||||
|
assert str(exc_info.value) == "Invalid auth type, Jina Reader auth type must be Bearer"
|
||||||
|
|
||||||
|
def test_should_raise_error_for_missing_api_key(self):
|
||||||
|
"""Test that missing API key raises ValueError"""
|
||||||
|
credentials = {"auth_type": "bearer", "config": {}}
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
JinaAuth(credentials)
|
||||||
|
assert str(exc_info.value) == "No API key provided"
|
||||||
|
|
||||||
|
def test_should_raise_error_for_missing_config(self):
|
||||||
|
"""Test that missing config section raises ValueError"""
|
||||||
|
credentials = {"auth_type": "bearer"}
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
JinaAuth(credentials)
|
||||||
|
assert str(exc_info.value) == "No API key provided"
|
||||||
|
|
||||||
|
@patch("services.auth.jina.jina.requests.post")
|
||||||
|
def test_should_validate_valid_credentials_successfully(self, mock_post):
|
||||||
|
"""Test successful credential validation"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
credentials = {"auth_type": "bearer", "config": {"api_key": "test_api_key_123"}}
|
||||||
|
auth = JinaAuth(credentials)
|
||||||
|
result = auth.validate_credentials()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_post.assert_called_once_with(
|
||||||
|
"https://r.jina.ai",
|
||||||
|
headers={"Content-Type": "application/json", "Authorization": "Bearer test_api_key_123"},
|
||||||
|
json={"url": "https://example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("services.auth.jina.jina.requests.post")
|
||||||
|
def test_should_handle_http_402_error(self, mock_post):
|
||||||
|
"""Test handling of 402 Payment Required error"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 402
|
||||||
|
mock_response.json.return_value = {"error": "Payment required"}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
credentials = {"auth_type": "bearer", "config": {"api_key": "test_api_key_123"}}
|
||||||
|
auth = JinaAuth(credentials)
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
auth.validate_credentials()
|
||||||
|
assert str(exc_info.value) == "Failed to authorize. Status code: 402. Error: Payment required"
|
||||||
|
|
||||||
|
@patch("services.auth.jina.jina.requests.post")
|
||||||
|
def test_should_handle_http_409_error(self, mock_post):
|
||||||
|
"""Test handling of 409 Conflict error"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 409
|
||||||
|
mock_response.json.return_value = {"error": "Conflict error"}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
credentials = {"auth_type": "bearer", "config": {"api_key": "test_api_key_123"}}
|
||||||
|
auth = JinaAuth(credentials)
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
auth.validate_credentials()
|
||||||
|
assert str(exc_info.value) == "Failed to authorize. Status code: 409. Error: Conflict error"
|
||||||
|
|
||||||
|
@patch("services.auth.jina.jina.requests.post")
|
||||||
|
def test_should_handle_http_500_error(self, mock_post):
|
||||||
|
"""Test handling of 500 Internal Server Error"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 500
|
||||||
|
mock_response.json.return_value = {"error": "Internal server error"}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
credentials = {"auth_type": "bearer", "config": {"api_key": "test_api_key_123"}}
|
||||||
|
auth = JinaAuth(credentials)
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
auth.validate_credentials()
|
||||||
|
assert str(exc_info.value) == "Failed to authorize. Status code: 500. Error: Internal server error"
|
||||||
|
|
||||||
|
@patch("services.auth.jina.jina.requests.post")
|
||||||
|
def test_should_handle_unexpected_error_with_text_response(self, mock_post):
|
||||||
|
"""Test handling of unexpected errors with text response"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 403
|
||||||
|
mock_response.text = '{"error": "Forbidden"}'
|
||||||
|
mock_response.json.side_effect = Exception("Not JSON")
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
credentials = {"auth_type": "bearer", "config": {"api_key": "test_api_key_123"}}
|
||||||
|
auth = JinaAuth(credentials)
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
auth.validate_credentials()
|
||||||
|
assert str(exc_info.value) == "Failed to authorize. Status code: 403. Error: Forbidden"
|
||||||
|
|
||||||
|
@patch("services.auth.jina.jina.requests.post")
|
||||||
|
def test_should_handle_unexpected_error_without_text(self, mock_post):
|
||||||
|
"""Test handling of unexpected errors without text response"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 404
|
||||||
|
mock_response.text = ""
|
||||||
|
mock_response.json.side_effect = Exception("Not JSON")
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
credentials = {"auth_type": "bearer", "config": {"api_key": "test_api_key_123"}}
|
||||||
|
auth = JinaAuth(credentials)
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
auth.validate_credentials()
|
||||||
|
assert str(exc_info.value) == "Unexpected error occurred while trying to authorize. Status code: 404"
|
||||||
|
|
||||||
|
@patch("services.auth.jina.jina.requests.post")
|
||||||
|
def test_should_handle_network_errors(self, mock_post):
|
||||||
|
"""Test handling of network connection errors"""
|
||||||
|
mock_post.side_effect = requests.ConnectionError("Network error")
|
||||||
|
|
||||||
|
credentials = {"auth_type": "bearer", "config": {"api_key": "test_api_key_123"}}
|
||||||
|
auth = JinaAuth(credentials)
|
||||||
|
|
||||||
|
with pytest.raises(requests.ConnectionError):
|
||||||
|
auth.validate_credentials()
|
||||||
|
|
||||||
|
def test_should_not_expose_api_key_in_error_messages(self):
|
||||||
|
"""Test that API key is not exposed in error messages"""
|
||||||
|
credentials = {"auth_type": "bearer", "config": {"api_key": "super_secret_key_12345"}}
|
||||||
|
auth = JinaAuth(credentials)
|
||||||
|
|
||||||
|
# Verify API key is stored but not in any error message
|
||||||
|
assert auth.api_key == "super_secret_key_12345"
|
||||||
|
|
||||||
|
# Test various error scenarios don't expose the key
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
JinaAuth({"auth_type": "basic", "config": {"api_key": "super_secret_key_12345"}})
|
||||||
|
assert "super_secret_key_12345" not in str(exc_info.value)
|
||||||
@ -0,0 +1,205 @@
|
|||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from services.auth.watercrawl.watercrawl import WatercrawlAuth
|
||||||
|
|
||||||
|
|
||||||
|
class TestWatercrawlAuth:
|
||||||
|
@pytest.fixture
|
||||||
|
def valid_credentials(self):
|
||||||
|
"""Fixture for valid x-api-key credentials"""
|
||||||
|
return {"auth_type": "x-api-key", "config": {"api_key": "test_api_key_123"}}
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_instance(self, valid_credentials):
|
||||||
|
"""Fixture for WatercrawlAuth instance with valid credentials"""
|
||||||
|
return WatercrawlAuth(valid_credentials)
|
||||||
|
|
||||||
|
def test_should_initialize_with_valid_x_api_key_credentials(self, valid_credentials):
|
||||||
|
"""Test successful initialization with valid x-api-key credentials"""
|
||||||
|
auth = WatercrawlAuth(valid_credentials)
|
||||||
|
assert auth.api_key == "test_api_key_123"
|
||||||
|
assert auth.base_url == "https://app.watercrawl.dev"
|
||||||
|
assert auth.credentials == valid_credentials
|
||||||
|
|
||||||
|
def test_should_initialize_with_custom_base_url(self):
|
||||||
|
"""Test initialization with custom base URL"""
|
||||||
|
credentials = {
|
||||||
|
"auth_type": "x-api-key",
|
||||||
|
"config": {"api_key": "test_api_key_123", "base_url": "https://custom.watercrawl.dev"},
|
||||||
|
}
|
||||||
|
auth = WatercrawlAuth(credentials)
|
||||||
|
assert auth.api_key == "test_api_key_123"
|
||||||
|
assert auth.base_url == "https://custom.watercrawl.dev"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("auth_type", "expected_error"),
|
||||||
|
[
|
||||||
|
("bearer", "Invalid auth type, WaterCrawl auth type must be x-api-key"),
|
||||||
|
("basic", "Invalid auth type, WaterCrawl auth type must be x-api-key"),
|
||||||
|
("", "Invalid auth type, WaterCrawl auth type must be x-api-key"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_should_raise_error_for_invalid_auth_type(self, auth_type, expected_error):
|
||||||
|
"""Test that non-x-api-key auth types raise ValueError"""
|
||||||
|
credentials = {"auth_type": auth_type, "config": {"api_key": "test_api_key_123"}}
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
WatercrawlAuth(credentials)
|
||||||
|
assert str(exc_info.value) == expected_error
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("credentials", "expected_error"),
|
||||||
|
[
|
||||||
|
({"auth_type": "x-api-key", "config": {}}, "No API key provided"),
|
||||||
|
({"auth_type": "x-api-key"}, "No API key provided"),
|
||||||
|
({"auth_type": "x-api-key", "config": {"api_key": ""}}, "No API key provided"),
|
||||||
|
({"auth_type": "x-api-key", "config": {"api_key": None}}, "No API key provided"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_should_raise_error_for_missing_api_key(self, credentials, expected_error):
|
||||||
|
"""Test that missing or empty API key raises ValueError"""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
WatercrawlAuth(credentials)
|
||||||
|
assert str(exc_info.value) == expected_error
|
||||||
|
|
||||||
|
@patch("services.auth.watercrawl.watercrawl.requests.get")
|
||||||
|
def test_should_validate_valid_credentials_successfully(self, mock_get, auth_instance):
|
||||||
|
"""Test successful credential validation"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
result = auth_instance.validate_credentials()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_get.assert_called_once_with(
|
||||||
|
"https://app.watercrawl.dev/api/v1/core/crawl-requests/",
|
||||||
|
headers={"Content-Type": "application/json", "X-API-KEY": "test_api_key_123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("status_code", "error_message"),
|
||||||
|
[
|
||||||
|
(402, "Payment required"),
|
||||||
|
(409, "Conflict error"),
|
||||||
|
(500, "Internal server error"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("services.auth.watercrawl.watercrawl.requests.get")
|
||||||
|
def test_should_handle_http_errors(self, mock_get, status_code, error_message, auth_instance):
|
||||||
|
"""Test handling of various HTTP error codes"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = status_code
|
||||||
|
mock_response.json.return_value = {"error": error_message}
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
auth_instance.validate_credentials()
|
||||||
|
assert str(exc_info.value) == f"Failed to authorize. Status code: {status_code}. Error: {error_message}"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("status_code", "response_text", "has_json_error", "expected_error_contains"),
|
||||||
|
[
|
||||||
|
(403, '{"error": "Forbidden"}', True, "Failed to authorize. Status code: 403. Error: Forbidden"),
|
||||||
|
(404, "", True, "Unexpected error occurred while trying to authorize. Status code: 404"),
|
||||||
|
(401, "Not JSON", True, "Expecting value"), # JSON decode error
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("services.auth.watercrawl.watercrawl.requests.get")
|
||||||
|
def test_should_handle_unexpected_errors(
|
||||||
|
self, mock_get, status_code, response_text, has_json_error, expected_error_contains, auth_instance
|
||||||
|
):
|
||||||
|
"""Test handling of unexpected errors with various response formats"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = status_code
|
||||||
|
mock_response.text = response_text
|
||||||
|
if has_json_error:
|
||||||
|
mock_response.json.side_effect = Exception("Not JSON")
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
auth_instance.validate_credentials()
|
||||||
|
assert expected_error_contains in str(exc_info.value)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("exception_type", "exception_message"),
|
||||||
|
[
|
||||||
|
(requests.ConnectionError, "Network error"),
|
||||||
|
(requests.Timeout, "Request timeout"),
|
||||||
|
(requests.ReadTimeout, "Read timeout"),
|
||||||
|
(requests.ConnectTimeout, "Connection timeout"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("services.auth.watercrawl.watercrawl.requests.get")
|
||||||
|
def test_should_handle_network_errors(self, mock_get, exception_type, exception_message, auth_instance):
|
||||||
|
"""Test handling of various network-related errors including timeouts"""
|
||||||
|
mock_get.side_effect = exception_type(exception_message)
|
||||||
|
|
||||||
|
with pytest.raises(exception_type) as exc_info:
|
||||||
|
auth_instance.validate_credentials()
|
||||||
|
assert exception_message in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_should_not_expose_api_key_in_error_messages(self):
|
||||||
|
"""Test that API key is not exposed in error messages"""
|
||||||
|
credentials = {"auth_type": "x-api-key", "config": {"api_key": "super_secret_key_12345"}}
|
||||||
|
auth = WatercrawlAuth(credentials)
|
||||||
|
|
||||||
|
# Verify API key is stored but not in any error message
|
||||||
|
assert auth.api_key == "super_secret_key_12345"
|
||||||
|
|
||||||
|
# Test various error scenarios don't expose the key
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
WatercrawlAuth({"auth_type": "bearer", "config": {"api_key": "super_secret_key_12345"}})
|
||||||
|
assert "super_secret_key_12345" not in str(exc_info.value)
|
||||||
|
|
||||||
|
@patch("services.auth.watercrawl.watercrawl.requests.get")
|
||||||
|
def test_should_use_custom_base_url_in_validation(self, mock_get):
|
||||||
|
"""Test that custom base URL is used in validation"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
credentials = {
|
||||||
|
"auth_type": "x-api-key",
|
||||||
|
"config": {"api_key": "test_api_key_123", "base_url": "https://custom.watercrawl.dev"},
|
||||||
|
}
|
||||||
|
auth = WatercrawlAuth(credentials)
|
||||||
|
result = auth.validate_credentials()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_get.call_args[0][0] == "https://custom.watercrawl.dev/api/v1/core/crawl-requests/"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("base_url", "expected_url"),
|
||||||
|
[
|
||||||
|
("https://app.watercrawl.dev", "https://app.watercrawl.dev/api/v1/core/crawl-requests/"),
|
||||||
|
("https://app.watercrawl.dev/", "https://app.watercrawl.dev/api/v1/core/crawl-requests/"),
|
||||||
|
("https://app.watercrawl.dev//", "https://app.watercrawl.dev/api/v1/core/crawl-requests/"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("services.auth.watercrawl.watercrawl.requests.get")
|
||||||
|
def test_should_use_urljoin_for_url_construction(self, mock_get, base_url, expected_url):
|
||||||
|
"""Test that urljoin is used correctly for URL construction with various base URLs"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
credentials = {"auth_type": "x-api-key", "config": {"api_key": "test_api_key_123", "base_url": base_url}}
|
||||||
|
auth = WatercrawlAuth(credentials)
|
||||||
|
auth.validate_credentials()
|
||||||
|
|
||||||
|
# Verify the correct URL was called
|
||||||
|
assert mock_get.call_args[0][0] == expected_url
|
||||||
|
|
||||||
|
@patch("services.auth.watercrawl.watercrawl.requests.get")
|
||||||
|
def test_should_handle_timeout_with_retry_suggestion(self, mock_get, auth_instance):
|
||||||
|
"""Test that timeout errors are handled gracefully with appropriate error message"""
|
||||||
|
mock_get.side_effect = requests.Timeout("The request timed out after 30 seconds")
|
||||||
|
|
||||||
|
with pytest.raises(requests.Timeout) as exc_info:
|
||||||
|
auth_instance.validate_credentials()
|
||||||
|
|
||||||
|
# Verify the timeout exception is raised with original message
|
||||||
|
assert "timed out" in str(exc_info.value)
|
||||||
@ -1 +1,7 @@
|
|||||||
from dify_client.client import ChatClient, CompletionClient, WorkflowClient, KnowledgeBaseClient, DifyClient
|
from dify_client.client import (
|
||||||
|
ChatClient,
|
||||||
|
CompletionClient,
|
||||||
|
WorkflowClient,
|
||||||
|
KnowledgeBaseClient,
|
||||||
|
DifyClient,
|
||||||
|
)
|
||||||
|
|||||||
@ -0,0 +1,41 @@
|
|||||||
|
'use client'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
RiAddLine,
|
||||||
|
RiArrowRightLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
type CreateAppCardProps = {
|
||||||
|
ref?: React.Ref<HTMLAnchorElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateAppCard = ({ ref }: CreateAppCardProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='bg-background-default-dimm flex min-h-[160px] flex-col rounded-xl border-[0.5px]
|
||||||
|
border-components-panel-border transition-all duration-200 ease-in-out'
|
||||||
|
>
|
||||||
|
<Link ref={ref} className='group flex grow cursor-pointer items-start p-4' href='/datasets/create'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<div className='flex h-10 w-10 items-center justify-center rounded-lg border border-dashed border-divider-regular bg-background-default-lighter
|
||||||
|
p-2 group-hover:border-solid group-hover:border-effects-highlight group-hover:bg-background-default-dodge'
|
||||||
|
>
|
||||||
|
<RiAddLine className='h-4 w-4 text-text-tertiary group-hover:text-text-accent' />
|
||||||
|
</div>
|
||||||
|
<div className='system-md-semibold text-text-secondary group-hover:text-text-accent'>{t('dataset.createDataset')}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className='system-xs-regular p-4 pt-0 text-text-tertiary'>{t('dataset.createDatasetIntro')}</div>
|
||||||
|
<Link className='group flex cursor-pointer items-center gap-1 rounded-b-xl border-t-[0.5px] border-divider-subtle p-4' href='/datasets/connect'>
|
||||||
|
<div className='system-xs-medium text-text-tertiary group-hover:text-text-accent'>{t('dataset.connectDataset')}</div>
|
||||||
|
<RiArrowRightLine className='h-3.5 w-3.5 text-text-tertiary group-hover:text-text-accent' />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateAppCard.displayName = 'CreateAppCard'
|
||||||
|
|
||||||
|
export default CreateAppCard
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue