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:
Toni de la Fuente committed 2026-05-08 18:14:18 +02:00
1 parent 34727a7237
commit 077a4e7dab
100 files changed
+3106 -4

No files matched your search

View File
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.
@@ -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"