4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
342 lines
15 KiB
Python
342 lines
15 KiB
Python
"""Unit tests for the Supabase integration helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from integrations.supabase import (
|
|
SupabaseConfig,
|
|
build_supabase_config,
|
|
get_service_health,
|
|
get_storage_buckets,
|
|
resolve_supabase_config,
|
|
supabase_config_from_env,
|
|
supabase_extract_params,
|
|
supabase_is_available,
|
|
validate_supabase_config,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config construction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSupabaseConfig:
|
|
def test_is_configured_with_url_and_key(self) -> None:
|
|
config = SupabaseConfig(url="https://abc.supabase.co", service_key="key123")
|
|
assert config.is_configured is True
|
|
|
|
def test_is_configured_missing_url(self) -> None:
|
|
config = SupabaseConfig(url="", service_key="key123")
|
|
assert config.is_configured is False
|
|
|
|
def test_is_configured_missing_service_key(self) -> None:
|
|
config = SupabaseConfig(url="https://abc.supabase.co", service_key="")
|
|
assert config.is_configured is False
|
|
|
|
def test_url_trailing_slash_stripped(self) -> None:
|
|
config = SupabaseConfig(url="https://abc.supabase.co/", service_key="key123")
|
|
assert not config.url.endswith("/")
|
|
|
|
def test_headers_include_auth_and_apikey(self) -> None:
|
|
config = SupabaseConfig(url="https://abc.supabase.co", service_key="secret")
|
|
assert config.headers["Authorization"] == "Bearer secret"
|
|
assert config.headers["apikey"] == "secret"
|
|
|
|
def test_build_supabase_config_from_none(self) -> None:
|
|
config = build_supabase_config(None)
|
|
assert config.is_configured is False
|
|
|
|
def test_build_supabase_config_from_dict(self) -> None:
|
|
config = build_supabase_config({"url": "https://proj.supabase.co", "service_key": "svc"})
|
|
assert config.url == "https://proj.supabase.co"
|
|
assert config.service_key == "svc"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Environment loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSupabaseConfigFromEnv:
|
|
def test_loads_from_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("SUPABASE_URL", "https://proj.supabase.co")
|
|
monkeypatch.setenv("SUPABASE_SERVICE_KEY", "svc_key")
|
|
config = supabase_config_from_env()
|
|
assert config is not None
|
|
assert config.url == "https://proj.supabase.co"
|
|
assert config.service_key == "svc_key"
|
|
|
|
def test_returns_none_when_url_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("SUPABASE_URL", raising=False)
|
|
monkeypatch.setenv("SUPABASE_SERVICE_KEY", "svc_key")
|
|
assert supabase_config_from_env() is None
|
|
|
|
def test_returns_none_when_key_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("SUPABASE_URL", "https://proj.supabase.co")
|
|
monkeypatch.delenv("SUPABASE_SERVICE_KEY", raising=False)
|
|
assert supabase_config_from_env() is None
|
|
|
|
def test_returns_none_when_both_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("SUPABASE_URL", raising=False)
|
|
monkeypatch.delenv("SUPABASE_SERVICE_KEY", raising=False)
|
|
assert supabase_config_from_env() is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_supabase_config — URL origin validation (security)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveSupabaseConfig:
|
|
def test_resolves_matching_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("SUPABASE_URL", "https://proj.supabase.co")
|
|
monkeypatch.setenv("SUPABASE_SERVICE_KEY", "svc")
|
|
config = resolve_supabase_config("https://proj.supabase.co")
|
|
assert config.service_key == "svc"
|
|
|
|
def test_rejects_mismatched_host(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("SUPABASE_URL", "https://proj.supabase.co")
|
|
monkeypatch.setenv("SUPABASE_SERVICE_KEY", "svc")
|
|
with pytest.raises(ValueError, match="unrecognised host"):
|
|
resolve_supabase_config("https://attacker.example.com")
|
|
|
|
def test_rejects_when_env_not_configured(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("SUPABASE_URL", raising=False)
|
|
monkeypatch.delenv("SUPABASE_SERVICE_KEY", raising=False)
|
|
with pytest.raises(ValueError, match="not configured"):
|
|
resolve_supabase_config("https://proj.supabase.co")
|
|
|
|
def test_resolves_from_integration_store_v2_without_env(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""v2 store records keep credentials under instances[0]; resolve must still work."""
|
|
monkeypatch.delenv("SUPABASE_URL", raising=False)
|
|
monkeypatch.delenv("SUPABASE_SERVICE_KEY", raising=False)
|
|
v2_record = {
|
|
"service": "supabase",
|
|
"status": "active",
|
|
"instances": [
|
|
{
|
|
"name": "default",
|
|
"tags": {},
|
|
"credentials": {
|
|
"url": "https://proj.supabase.co",
|
|
"service_key": "from-store",
|
|
},
|
|
}
|
|
],
|
|
}
|
|
with patch("integrations.store.load_integrations", return_value=[v2_record]):
|
|
config = resolve_supabase_config("https://proj.supabase.co")
|
|
assert config.service_key == "from-store"
|
|
assert config.url == "https://proj.supabase.co"
|
|
|
|
def test_strips_trailing_slash_before_comparison(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("SUPABASE_URL", "https://proj.supabase.co")
|
|
monkeypatch.setenv("SUPABASE_SERVICE_KEY", "svc")
|
|
config = resolve_supabase_config("https://proj.supabase.co/")
|
|
assert config.url == "https://proj.supabase.co"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Availability and param extraction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSupabaseAvailability:
|
|
def test_is_available_with_project_url(self) -> None:
|
|
assert supabase_is_available({"supabase": {"project_url": "https://proj.supabase.co"}})
|
|
|
|
def test_is_available_without_project_url(self) -> None:
|
|
assert not supabase_is_available({"supabase": {}})
|
|
|
|
def test_is_available_missing_supabase_key(self) -> None:
|
|
assert not supabase_is_available({})
|
|
|
|
def test_extract_params_strips_whitespace(self) -> None:
|
|
sources = {"supabase": {"project_url": " https://proj.supabase.co "}}
|
|
params = supabase_extract_params(sources)
|
|
assert params["project_url"] == "https://proj.supabase.co"
|
|
|
|
def test_extract_params_empty_sources(self) -> None:
|
|
params = supabase_extract_params({})
|
|
assert params["project_url"] == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateSupabaseConfig:
|
|
def test_returns_error_when_url_missing(self) -> None:
|
|
config = SupabaseConfig(url="", service_key="key")
|
|
result = validate_supabase_config(config)
|
|
assert result.ok is False
|
|
assert "URL" in result.detail
|
|
|
|
def test_returns_error_when_key_missing(self) -> None:
|
|
config = SupabaseConfig(url="https://proj.supabase.co", service_key="")
|
|
result = validate_supabase_config(config)
|
|
assert result.ok is False
|
|
assert "service key" in result.detail
|
|
|
|
def test_returns_ok_on_200(self) -> None:
|
|
config = SupabaseConfig(url="https://proj.supabase.co", service_key="key")
|
|
with patch("integrations.supabase._make_request", return_value=(200, {})):
|
|
result = validate_supabase_config(config)
|
|
assert result.ok is True
|
|
assert "proj.supabase.co" in result.detail
|
|
|
|
def test_returns_error_on_non_200(self) -> None:
|
|
config = SupabaseConfig(url="https://proj.supabase.co", service_key="key")
|
|
with patch("integrations.supabase._make_request", return_value=(503, {})):
|
|
result = validate_supabase_config(config)
|
|
assert result.ok is False
|
|
assert "503" in result.detail
|
|
|
|
def test_returns_error_on_connection_failure(self) -> None:
|
|
config = SupabaseConfig(url="https://proj.supabase.co", service_key="key")
|
|
with patch(
|
|
"integrations.supabase._make_request",
|
|
side_effect=ConnectionError("timed out"),
|
|
):
|
|
result = validate_supabase_config(config)
|
|
assert result.ok is False
|
|
assert "failed" in result.detail.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Service health
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetServiceHealth:
|
|
def test_returns_unavailable_when_not_configured(self) -> None:
|
|
config = SupabaseConfig()
|
|
result = get_service_health(config)
|
|
assert result["available"] is False
|
|
assert result["source"] == "supabase"
|
|
|
|
def test_all_services_healthy(self) -> None:
|
|
config = SupabaseConfig(url="https://proj.supabase.co", service_key="key")
|
|
with patch("integrations.supabase._make_request", return_value=(200, {})):
|
|
result = get_service_health(config)
|
|
assert result["available"] is True
|
|
assert result["overall_healthy"] is True
|
|
assert result["degraded_services"] == []
|
|
assert set(result["services"].keys()) == {"postgrest", "auth", "storage"}
|
|
|
|
def test_storage_health_uses_dedicated_endpoint(self) -> None:
|
|
"""Ensure we call /storage/v1/health not the bucket-listing endpoint."""
|
|
config = SupabaseConfig(url="https://proj.supabase.co", service_key="key")
|
|
called_paths: list[str] = []
|
|
|
|
def _capture(cfg: SupabaseConfig, path: str, **_: Any) -> tuple[int, Any]:
|
|
called_paths.append(path)
|
|
return (200, {})
|
|
|
|
with patch("integrations.supabase._make_request", side_effect=_capture):
|
|
get_service_health(config)
|
|
|
|
assert "/storage/v1/health" in called_paths
|
|
assert "/storage/v1/bucket" not in called_paths
|
|
|
|
def test_partial_degradation_reports_correct_service(self) -> None:
|
|
config = SupabaseConfig(url="https://proj.supabase.co", service_key="key")
|
|
|
|
def _side_effect(cfg: SupabaseConfig, path: str, **_: Any) -> tuple[int, Any]:
|
|
return (503, {}) if path == "/auth/v1/health" else (200, {})
|
|
|
|
with patch("integrations.supabase._make_request", side_effect=_side_effect):
|
|
result = get_service_health(config)
|
|
|
|
assert result["available"] is True
|
|
assert result["overall_healthy"] is False
|
|
assert "auth" in result["degraded_services"]
|
|
assert result["services"]["postgrest"]["healthy"] is True
|
|
assert result["services"]["auth"]["healthy"] is False
|
|
|
|
def test_connection_error_marks_service_degraded(self) -> None:
|
|
config = SupabaseConfig(url="https://proj.supabase.co", service_key="key")
|
|
with patch(
|
|
"integrations.supabase._make_request",
|
|
side_effect=ConnectionError("refused"),
|
|
):
|
|
result = get_service_health(config)
|
|
assert result["available"] is True
|
|
assert result["overall_healthy"] is False
|
|
assert len(result["degraded_services"]) == 3
|
|
|
|
def test_project_url_included_in_result(self) -> None:
|
|
config = SupabaseConfig(url="https://proj.supabase.co", service_key="key")
|
|
with patch("integrations.supabase._make_request", return_value=(200, {})):
|
|
result = get_service_health(config)
|
|
assert result["project_url"] == "https://proj.supabase.co"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Storage buckets
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetStorageBuckets:
|
|
def test_returns_unavailable_when_not_configured(self) -> None:
|
|
config = SupabaseConfig()
|
|
result = get_storage_buckets(config)
|
|
assert result["available"] is False
|
|
|
|
def test_returns_bucket_list(self) -> None:
|
|
config = SupabaseConfig(url="https://proj.supabase.co", service_key="key")
|
|
mock_buckets = [
|
|
{"id": "avatars", "name": "avatars", "public": True, "created_at": "2024-01-01"},
|
|
{"id": "docs", "name": "docs", "public": False, "created_at": "2024-01-02"},
|
|
]
|
|
with patch("integrations.supabase._make_request", return_value=(200, mock_buckets)):
|
|
result = get_storage_buckets(config)
|
|
assert result["available"] is True
|
|
assert result["total_buckets"] == 2
|
|
assert result["returned_buckets"] == 2
|
|
assert result["truncated"] is False
|
|
assert result["buckets"][0]["name"] == "avatars"
|
|
assert result["buckets"][1]["public"] is False
|
|
|
|
def test_returns_error_on_403(self) -> None:
|
|
config = SupabaseConfig(url="https://proj.supabase.co", service_key="key")
|
|
with patch("integrations.supabase._make_request", return_value=(403, {})):
|
|
result = get_storage_buckets(config)
|
|
assert result["available"] is False
|
|
assert "403" in result["error"]
|
|
|
|
def test_handles_non_list_body_gracefully(self) -> None:
|
|
config = SupabaseConfig(url="https://proj.supabase.co", service_key="key")
|
|
with patch(
|
|
"integrations.supabase._make_request",
|
|
return_value=(200, {"error": "unexpected"}),
|
|
):
|
|
result = get_storage_buckets(config)
|
|
assert result["available"] is True
|
|
assert result["total_buckets"] == 0
|
|
assert result["returned_buckets"] == 0
|
|
assert result["truncated"] is False
|
|
|
|
def test_handles_exception(self) -> None:
|
|
config = SupabaseConfig(url="https://proj.supabase.co", service_key="key")
|
|
with patch("integrations.supabase._make_request", side_effect=RuntimeError("boom")):
|
|
result = get_storage_buckets(config)
|
|
assert result["available"] is False
|
|
assert "boom" in result["error"]
|
|
|
|
def test_caps_results_at_max_results(self) -> None:
|
|
config = SupabaseConfig(url="https://proj.supabase.co", service_key="key", max_results=2)
|
|
mock_buckets = [{"id": str(i), "name": f"bucket-{i}"} for i in range(10)]
|
|
with patch("integrations.supabase._make_request", return_value=(200, mock_buckets)):
|
|
result = get_storage_buckets(config)
|
|
assert result["total_buckets"] == 10
|
|
assert result["returned_buckets"] == 2
|
|
assert result["truncated"] is True
|