From b1bd35c08254e48a54304d0c17d7eb9fb4151304 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 1 Jul 2026 17:53:42 +0200 Subject: [PATCH] test(capabilities): cover REST door, meter-gate, and batch bounds --- .../unit/capabilities/access/__init__.py | 0 .../capabilities/access/test_rate_limit.py | 37 ++++ .../capabilities/access/test_rest_router.py | 171 ++++++++++++++++++ .../tests/unit/capabilities/test_billing.py | 62 ++++++- .../capabilities/web/scrape/test_schemas.py | 25 ++- 5 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 surfsense_backend/tests/unit/capabilities/access/__init__.py create mode 100644 surfsense_backend/tests/unit/capabilities/access/test_rate_limit.py create mode 100644 surfsense_backend/tests/unit/capabilities/access/test_rest_router.py diff --git a/surfsense_backend/tests/unit/capabilities/access/__init__.py b/surfsense_backend/tests/unit/capabilities/access/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/capabilities/access/test_rate_limit.py b/surfsense_backend/tests/unit/capabilities/access/test_rate_limit.py new file mode 100644 index 000000000..f9c8076bf --- /dev/null +++ b/surfsense_backend/tests/unit/capabilities/access/test_rate_limit.py @@ -0,0 +1,37 @@ +"""Per-workspace rate limit: a secondary guard behind the credit meter-gate (05).""" + +import pytest +from fastapi import HTTPException +from starlette.requests import Request + +from app.capabilities.access import rate_limit + + +def _request(workspace_id: int) -> Request: + return Request({"type": "http", "path_params": {"workspace_id": workspace_id}}) + + +@pytest.mark.asyncio +async def test_passes_at_the_limit(monkeypatch): + monkeypatch.setattr( + rate_limit, "_incr", lambda *a, **k: rate_limit.CAPABILITY_RATE_LIMIT_PER_MINUTE + ) + await rate_limit.enforce_capability_rate_limit(_request(1)) + + +@pytest.mark.asyncio +async def test_blocks_over_the_limit(monkeypatch): + monkeypatch.setattr( + rate_limit, + "_incr", + lambda *a, **k: rate_limit.CAPABILITY_RATE_LIMIT_PER_MINUTE + 1, + ) + with pytest.raises(HTTPException) as exc: + await rate_limit.enforce_capability_rate_limit(_request(1)) + assert exc.value.status_code == 429 + + +def test_memory_fallback_counts_within_window(): + rate_limit._memory.clear() + assert rate_limit._incr_memory("k", window_seconds=60) == 1 + assert rate_limit._incr_memory("k", window_seconds=60) == 2 diff --git a/surfsense_backend/tests/unit/capabilities/access/test_rest_router.py b/surfsense_backend/tests/unit/capabilities/access/test_rest_router.py new file mode 100644 index 000000000..0f4366c4b --- /dev/null +++ b/surfsense_backend/tests/unit/capabilities/access/test_rest_router.py @@ -0,0 +1,171 @@ +"""The REST door generator turns registry verbs into typed POST routes (05).""" + +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from pydantic import BaseModel + +from app.capabilities.types import Capability +from app.db import get_async_session +from app.users import get_auth_context + + +class _EchoInput(BaseModel): + value: str + + +class _EchoOutput(BaseModel): + echo: str + + +async def _echo_executor(payload: _EchoInput) -> _EchoOutput: + return _EchoOutput(echo=payload.value) + + +_ECHO = Capability( + name="test.echo", + input_schema=_EchoInput, + output_schema=_EchoOutput, + executor=_echo_executor, + billing_unit=None, +) + + +def _build_app(capabilities, monkeypatch) -> FastAPI: + """Mount the generated door with auth/workspace/session/rate-limit stubbed.""" + from app.capabilities.access import rest + from app.capabilities.access.rate_limit import enforce_capability_rate_limit + + monkeypatch.setattr(rest, "check_workspace_access", _noop_async, raising=True) + + app = FastAPI() + app.include_router(rest.build_capabilities_router(capabilities), prefix="/api/v1") + app.dependency_overrides[get_auth_context] = lambda: SimpleNamespace(user=None) + app.dependency_overrides[enforce_capability_rate_limit] = _allow + + async def _session(): + yield SimpleNamespace() + + app.dependency_overrides[get_async_session] = _session + return app + + +async def _noop_async(*args, **kwargs) -> None: + return None + + +async def _allow() -> None: + return None + + +def _client(app: FastAPI) -> AsyncClient: + return AsyncClient(transport=ASGITransport(app=app), base_url="http://test") + + +@pytest.mark.asyncio +async def test_verb_is_exposed_as_typed_post_route(monkeypatch): + app = _build_app([_ECHO], monkeypatch) + async with _client(app) as client: + resp = await client.post( + "/api/v1/workspaces/7/capabilities/test.echo", + json={"value": "hi"}, + ) + assert resp.status_code == 200 + assert resp.json() == {"echo": "hi"} + + +@pytest.mark.asyncio +async def test_input_is_validated_against_the_verb_schema(monkeypatch): + app = _build_app([_ECHO], monkeypatch) + async with _client(app) as client: + resp = await client.post( + "/api/v1/workspaces/7/capabilities/test.echo", + json={"wrong": "field"}, + ) + assert resp.status_code == 422 + + +def test_registered_verbs_appear_on_rest(): + """A verb in the registry shows up as a route with no per-verb wiring.""" + import app.capabilities.web # noqa: F401 (registers web.* at import) + from app.capabilities.access import rest + + router = rest.build_capabilities_router() + paths = {route.path for route in router.routes} + assert "/workspaces/{workspace_id}/capabilities/web.scrape" in paths + assert "/workspaces/{workspace_id}/capabilities/web.discover" in paths + + +@pytest.mark.asyncio +async def test_over_budget_is_blocked_before_the_executor(monkeypatch): + from app.capabilities.access import rest + from app.services.web_crawl_credit_service import InsufficientCreditsError + + async def _raise(*args, **kwargs): + raise InsufficientCreditsError( + message="over budget", balance_micros=0, required_micros=1000 + ) + + monkeypatch.setattr(rest, "gate_capability", _raise, raising=True) + + app = _build_app([_ECHO], monkeypatch) + async with _client(app) as client: + resp = await client.post( + "/api/v1/workspaces/7/capabilities/test.echo", + json={"value": "hi"}, + ) + assert resp.status_code == 402 + + +@pytest.mark.asyncio +async def test_rate_limit_blocks_the_workspace(monkeypatch): + """The generated route enforces the per-workspace limit (429).""" + from app.capabilities.access import rate_limit, rest + + monkeypatch.setattr(rest, "check_workspace_access", _noop_async, raising=True) + monkeypatch.setattr( + rate_limit, + "_incr", + lambda *a, **k: rate_limit.CAPABILITY_RATE_LIMIT_PER_MINUTE + 1, + ) + + app = FastAPI() + app.include_router(rest.build_capabilities_router([_ECHO]), prefix="/api/v1") + app.dependency_overrides[get_auth_context] = lambda: SimpleNamespace(user=None) + + async def _session(): + yield SimpleNamespace() + + app.dependency_overrides[get_async_session] = _session + + async with _client(app) as client: + resp = await client.post( + "/api/v1/workspaces/7/capabilities/test.echo", + json={"value": "hi"}, + ) + assert resp.status_code == 429 + + +@pytest.mark.asyncio +async def test_success_charges_once(monkeypatch): + from unittest.mock import AsyncMock + + from app.capabilities.access import rest + + charge = AsyncMock() + monkeypatch.setattr(rest, "charge_capability", charge, raising=True) + + app = _build_app([_ECHO], monkeypatch) + async with _client(app) as client: + resp = await client.post( + "/api/v1/workspaces/7/capabilities/test.echo", + json={"value": "hi"}, + ) + assert resp.status_code == 200 + charge.assert_awaited_once() + (output, unit, ctx), _ = charge.call_args + assert isinstance(output, _EchoOutput) + assert unit is None + assert ctx.workspace_id == 7 diff --git a/surfsense_backend/tests/unit/capabilities/test_billing.py b/surfsense_backend/tests/unit/capabilities/test_billing.py index 210e69784..45e65c257 100644 --- a/surfsense_backend/tests/unit/capabilities/test_billing.py +++ b/surfsense_backend/tests/unit/capabilities/test_billing.py @@ -12,10 +12,11 @@ from uuid import UUID import pytest import app.capabilities.billing as billing -from app.capabilities.billing import charge_capability +from app.capabilities.billing import charge_capability, gate_capability from app.capabilities.types import BillingUnit, CapabilityContext -from app.capabilities.web.scrape.schemas import ScrapeOutput, ScrapeRow +from app.capabilities.web.scrape.schemas import ScrapeInput, ScrapeOutput, ScrapeRow from app.config import config +from app.services.web_crawl_credit_service import InsufficientCreditsError pytestmark = pytest.mark.unit @@ -125,3 +126,60 @@ async def test_free_verb_without_a_unit_is_noop(monkeypatch, record_usage): record_usage.assert_not_awaited() session.execute.assert_not_called() assert user.credit_micros_balance == 100_000 + + +def _gate_session(owner_id, balance_micros): + """Mock session serving owner-resolution and the spendable-balance read.""" + session = AsyncMock() + + def _make_result(*_args, **_kwargs): + result = MagicMock() + result.scalar_one_or_none.return_value = owner_id # owner resolution + result.first.return_value = (balance_micros, 0) # balance, reserved + return result + + session.execute = AsyncMock(side_effect=_make_result) + return session + + +async def test_gate_blocks_when_worst_case_exceeds_balance(monkeypatch): + monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", True) + monkeypatch.setattr(config, "WEB_CRAWL_MICROS_PER_SUCCESS", 1000) + session = _gate_session(_OWNER, balance_micros=1500) # affords 1 crawl, not 2 + + with pytest.raises(InsufficientCreditsError): + await gate_capability( + ScrapeInput(urls=["https://a.com", "https://b.com"]), + BillingUnit.WEB_CRAWL, + _ctx(session), + ) + + +async def test_gate_passes_when_balance_covers_worst_case(monkeypatch): + monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", True) + monkeypatch.setattr(config, "WEB_CRAWL_MICROS_PER_SUCCESS", 1000) + session = _gate_session(_OWNER, balance_micros=100_000) + + await gate_capability( + ScrapeInput(urls=["https://a.com", "https://b.com"]), + BillingUnit.WEB_CRAWL, + _ctx(session), + ) + + +async def test_gate_is_noop_when_disabled(monkeypatch): + monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", False) + session = _gate_session(_OWNER, balance_micros=0) + + await gate_capability( + ScrapeInput(urls=["https://a.com"]), BillingUnit.WEB_CRAWL, _ctx(session) + ) + + +async def test_gate_is_noop_for_free_verb(monkeypatch): + monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", True) + session = _gate_session(_OWNER, balance_micros=0) + + await gate_capability(ScrapeInput(urls=["https://a.com"]), None, _ctx(session)) + + session.execute.assert_not_called() diff --git a/surfsense_backend/tests/unit/capabilities/web/scrape/test_schemas.py b/surfsense_backend/tests/unit/capabilities/web/scrape/test_schemas.py index c8aa6c78a..9cc0eef23 100644 --- a/surfsense_backend/tests/unit/capabilities/web/scrape/test_schemas.py +++ b/surfsense_backend/tests/unit/capabilities/web/scrape/test_schemas.py @@ -1,10 +1,16 @@ -"""ScrapeOutput reports its own billable count (a success single-sources it).""" +"""ScrapeOutput reports its own billable count; ScrapeInput bounds its batch size.""" from __future__ import annotations import pytest +from pydantic import ValidationError -from app.capabilities.web.scrape.schemas import ScrapeOutput, ScrapeRow +from app.capabilities.web.scrape.schemas import ( + MAX_SCRAPE_URLS, + ScrapeInput, + ScrapeOutput, + ScrapeRow, +) pytestmark = pytest.mark.unit @@ -24,3 +30,18 @@ def test_billable_units_counts_successful_rows(): def test_billable_units_is_zero_without_successes(): assert _output("empty", "failed").billable_units == 0 + + +def test_rejects_empty_url_batch(): + with pytest.raises(ValidationError): + ScrapeInput(urls=[]) + + +def test_rejects_batch_over_the_cap(): + with pytest.raises(ValidationError): + ScrapeInput(urls=[f"https://{i}.com" for i in range(MAX_SCRAPE_URLS + 1)]) + + +def test_accepts_batch_at_the_cap(): + payload = ScrapeInput(urls=[f"https://{i}.com" for i in range(MAX_SCRAPE_URLS)]) + assert payload.estimated_units == MAX_SCRAPE_URLS