mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 15:24:22 +00:00
feat: add Lovable AI app builder provider
- Add lovable SDK provider with apps and published services - Add 12 security checks mapped to Lovable security best practices - Add Prowler ThreatScore for Lovable compliance framework - Add API support: provider type, secret schema, UID validator, migration - Add UI support: provider choice, credentials form, badges, wiring - Add SDK tests for provider, services, and critical checks
This commit is contained in:
1 parent
34727a7237
commit
077a4e7dab
100 files changed
+3106
-4
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,59 @@
|
||||
"""Reusable Lovable test fixtures."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from prowler.providers.lovable.models import (
|
||||
LovableIdentityInfo,
|
||||
LovableSession,
|
||||
LovableWorkspaceInfo,
|
||||
)
|
||||
|
||||
API_TOKEN = "lovable_test_token_xxxxxxxxxxxxxxxxxxxxxx"
|
||||
WORKSPACE_ID = "ws_test_workspace"
|
||||
WORKSPACE_NAME = "Test Workspace"
|
||||
WORKSPACE_SLUG = "test-workspace"
|
||||
USER_ID = "user_test_id"
|
||||
USER_EMAIL = "test@example.com"
|
||||
USERNAME = "testuser"
|
||||
|
||||
|
||||
def make_session(workspace_id: str = WORKSPACE_ID) -> LovableSession:
|
||||
return LovableSession(
|
||||
api_token=API_TOKEN,
|
||||
workspace_id=workspace_id,
|
||||
http_session=MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
def make_identity() -> LovableIdentityInfo:
|
||||
workspace = LovableWorkspaceInfo(
|
||||
id=WORKSPACE_ID,
|
||||
name=WORKSPACE_NAME,
|
||||
slug=WORKSPACE_SLUG,
|
||||
plan="pro",
|
||||
)
|
||||
return LovableIdentityInfo(
|
||||
user_id=USER_ID,
|
||||
username=USERNAME,
|
||||
email=USER_EMAIL,
|
||||
workspace=workspace,
|
||||
workspaces=[workspace],
|
||||
)
|
||||
|
||||
|
||||
def make_provider() -> MagicMock:
|
||||
"""Build a stub provider sufficient for service constructors."""
|
||||
provider = MagicMock()
|
||||
provider.session = make_session()
|
||||
provider.identity = make_identity()
|
||||
provider.audit_config = {"max_retries": 0}
|
||||
provider.fixer_config = {}
|
||||
provider.filter_projects = None
|
||||
provider.published_app_urls = []
|
||||
provider.supabase_access_token = None
|
||||
return provider
|
||||
|
||||
|
||||
def set_mocked_lovable_provider() -> MagicMock:
|
||||
"""Alias used by check tests, mirroring the Vercel test pattern."""
|
||||
return make_provider()
|
||||
@@ -0,0 +1,90 @@
|
||||
import os
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler.providers.common.models import Connection
|
||||
from prowler.providers.lovable.exceptions.exceptions import (
|
||||
LovableAuthenticationError,
|
||||
LovableCredentialsError,
|
||||
)
|
||||
from prowler.providers.lovable.lovable_provider import LovableProvider
|
||||
from prowler.providers.lovable.models import LovableSession
|
||||
from tests.providers.lovable.lovable_fixtures import API_TOKEN, WORKSPACE_ID
|
||||
|
||||
|
||||
class TestLovableProviderSetupSession:
|
||||
def test_setup_session_with_env_var(self):
|
||||
with mock.patch.dict(os.environ, {"LOVABLE_API_TOKEN": API_TOKEN}, clear=True):
|
||||
session = LovableProvider.setup_session()
|
||||
|
||||
assert isinstance(session, LovableSession)
|
||||
assert session.api_token == API_TOKEN
|
||||
assert session.http_session is not None
|
||||
|
||||
def test_setup_session_with_arguments(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
session = LovableProvider.setup_session(
|
||||
api_token=API_TOKEN, workspace_id=WORKSPACE_ID
|
||||
)
|
||||
assert session.api_token == API_TOKEN
|
||||
assert session.workspace_id == WORKSPACE_ID
|
||||
|
||||
def test_setup_session_no_credentials_raises(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
os.environ.pop("LOVABLE_API_TOKEN", None)
|
||||
with pytest.raises(LovableCredentialsError):
|
||||
LovableProvider.setup_session()
|
||||
|
||||
def test_setup_session_workspace_from_env(self):
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"LOVABLE_API_TOKEN": API_TOKEN, "LOVABLE_WORKSPACE_ID": WORKSPACE_ID},
|
||||
clear=True,
|
||||
):
|
||||
session = LovableProvider.setup_session()
|
||||
assert session.workspace_id == WORKSPACE_ID
|
||||
|
||||
|
||||
class TestLovableProviderTestConnection:
|
||||
def test_test_connection_success(self):
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.lovable.lovable_provider.LovableProvider.setup_session"
|
||||
) as session_mock,
|
||||
mock.patch(
|
||||
"prowler.providers.lovable.lovable_provider.LovableProvider.validate_credentials"
|
||||
) as validate_mock,
|
||||
):
|
||||
session_mock.return_value = MagicMock()
|
||||
validate_mock.return_value = None
|
||||
|
||||
result = LovableProvider.test_connection(api_token=API_TOKEN)
|
||||
|
||||
assert isinstance(result, Connection)
|
||||
assert result.is_connected is True
|
||||
|
||||
def test_test_connection_no_credentials_returns_error(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
result = LovableProvider.test_connection(raise_on_exception=False)
|
||||
|
||||
assert isinstance(result, Connection)
|
||||
assert result.is_connected is False
|
||||
assert isinstance(result.error, LovableCredentialsError)
|
||||
|
||||
def test_test_connection_raises_on_invalid_token(self):
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.lovable.lovable_provider.LovableProvider.setup_session"
|
||||
) as session_mock,
|
||||
mock.patch(
|
||||
"prowler.providers.lovable.lovable_provider.LovableProvider.validate_credentials",
|
||||
side_effect=LovableAuthenticationError(file=__file__),
|
||||
),
|
||||
):
|
||||
session_mock.return_value = MagicMock()
|
||||
with pytest.raises(LovableAuthenticationError):
|
||||
LovableProvider.test_connection(
|
||||
api_token=API_TOKEN, raise_on_exception=True
|
||||
)
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,76 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.lovable.services.apps.apps_service import LovableApp
|
||||
from tests.providers.lovable.lovable_fixtures import (
|
||||
WORKSPACE_ID,
|
||||
set_mocked_lovable_provider,
|
||||
)
|
||||
|
||||
|
||||
def _make_app(**overrides) -> LovableApp:
|
||||
base = dict(
|
||||
id="app_1",
|
||||
name="App 1",
|
||||
slug="app-1",
|
||||
workspace_id=WORKSPACE_ID,
|
||||
visibility="public",
|
||||
is_published=True,
|
||||
published_url="https://app.lovable.app",
|
||||
has_supabase_backing=True,
|
||||
rls_enabled_on_all_tables=True,
|
||||
tables_without_rls=[],
|
||||
)
|
||||
base.update(overrides)
|
||||
return LovableApp(**base)
|
||||
|
||||
|
||||
class Test_apps_supabase_rls_enabled_on_all_tables:
|
||||
def _run(self, apps_dict):
|
||||
apps_client = mock.MagicMock
|
||||
apps_client.apps = apps_dict
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_lovable_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.lovable.services.apps.apps_supabase_rls_enabled_on_all_tables.apps_supabase_rls_enabled_on_all_tables.apps_client",
|
||||
new=apps_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.lovable.services.apps.apps_supabase_rls_enabled_on_all_tables.apps_supabase_rls_enabled_on_all_tables import (
|
||||
apps_supabase_rls_enabled_on_all_tables,
|
||||
)
|
||||
|
||||
check = apps_supabase_rls_enabled_on_all_tables()
|
||||
return check.execute()
|
||||
|
||||
def test_pass_when_all_tables_have_rls(self):
|
||||
app = _make_app(rls_enabled_on_all_tables=True, tables_without_rls=[])
|
||||
findings = self._run({app.id: app})
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "Row Level Security" in findings[0].status_extended
|
||||
|
||||
def test_fail_when_tables_missing_rls(self):
|
||||
app = _make_app(
|
||||
rls_enabled_on_all_tables=False,
|
||||
tables_without_rls=["public.users", "public.orders"],
|
||||
)
|
||||
findings = self._run({app.id: app})
|
||||
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "public.users" in findings[0].status_extended
|
||||
assert "public.orders" in findings[0].status_extended
|
||||
|
||||
def test_manual_when_no_supabase_backing(self):
|
||||
app = _make_app(has_supabase_backing=False)
|
||||
findings = self._run({app.id: app})
|
||||
|
||||
assert findings[0].status == "MANUAL"
|
||||
|
||||
def test_no_findings_when_no_apps(self):
|
||||
findings = self._run({})
|
||||
assert findings == []
|
||||
Whitespace-only changes.
+76
@@ -0,0 +1,76 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.lovable.services.published.published_service import (
|
||||
PublishedAppInspection,
|
||||
)
|
||||
from tests.providers.lovable.lovable_fixtures import set_mocked_lovable_provider
|
||||
|
||||
|
||||
def _inspection(**overrides) -> PublishedAppInspection:
|
||||
base = dict(
|
||||
app_id="app_1",
|
||||
app_name="App 1",
|
||||
workspace_id="ws_1",
|
||||
published_url="https://app.lovable.app",
|
||||
reachable=True,
|
||||
is_https=True,
|
||||
status_code=200,
|
||||
headers={"content-type": "text/html"},
|
||||
bundles_inspected=["https://app.lovable.app/assets/main.js"],
|
||||
leaked_secrets=[],
|
||||
id="app_1",
|
||||
name="App 1",
|
||||
)
|
||||
base.update(overrides)
|
||||
return PublishedAppInspection(**base)
|
||||
|
||||
|
||||
class Test_published_app_no_secrets_in_frontend_bundle:
|
||||
def _run(self, inspections):
|
||||
published_client = mock.MagicMock
|
||||
published_client.inspections = inspections
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_lovable_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.lovable.services.published.published_app_no_secrets_in_frontend_bundle.published_app_no_secrets_in_frontend_bundle.published_client",
|
||||
new=published_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.lovable.services.published.published_app_no_secrets_in_frontend_bundle.published_app_no_secrets_in_frontend_bundle import (
|
||||
published_app_no_secrets_in_frontend_bundle,
|
||||
)
|
||||
|
||||
check = published_app_no_secrets_in_frontend_bundle()
|
||||
return check.execute()
|
||||
|
||||
def test_pass_when_no_secrets_detected(self):
|
||||
inspection = _inspection(leaked_secrets=[])
|
||||
findings = self._run({inspection.app_id: inspection})
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
|
||||
def test_fail_when_supabase_jwt_leaked(self):
|
||||
inspection = _inspection(
|
||||
leaked_secrets=[
|
||||
{
|
||||
"type": "supabase_jwt",
|
||||
"bundle": "https://app.lovable.app/assets/main.js",
|
||||
"match_preview": "eyJh...abcd",
|
||||
}
|
||||
]
|
||||
)
|
||||
findings = self._run({inspection.app_id: inspection})
|
||||
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "1 supabase_jwt" in findings[0].status_extended
|
||||
|
||||
def test_manual_when_app_unreachable(self):
|
||||
inspection = _inspection(reachable=False)
|
||||
findings = self._run({inspection.app_id: inspection})
|
||||
|
||||
assert findings[0].status == "MANUAL"
|
||||
Reference in new issue
Block a user