107 lines
4.0 KiB
Python
107 lines
4.0 KiB
Python
|
|
"""Gate F proof: a plugin built ONLY from docs/plugin-development-guide.md works.
|
||
|
|
|
||
|
|
The guide's chapter 29.1 "Minimal Plugin" was transcribed verbatim into
|
||
|
|
app/plugins/builtins/minimal_example/. This suite proves the resulting plugin
|
||
|
|
is discovered, installable, activatable, and serves authenticated requests —
|
||
|
|
without any guide corrections needed.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import pytest_asyncio
|
||
|
|
from httpx import ASGITransport, AsyncClient
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||
|
|
|
||
|
|
from app.core.db import close_engine, reset_engine_for_testing
|
||
|
|
from app.core.permission_registry import init_permission_registry
|
||
|
|
from app.core.service_container import get_container
|
||
|
|
from app.main import create_app
|
||
|
|
from app.plugins.builtins.minimal_example.plugin import MinimalExamplePlugin
|
||
|
|
from app.plugins.registry import reset_registry_for_testing
|
||
|
|
from app.services.plugin_service import reset_plugin_service_for_testing
|
||
|
|
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def gate_app(engine: AsyncEngine):
|
||
|
|
reset_engine_for_testing(engine)
|
||
|
|
app = create_app()
|
||
|
|
registry = reset_registry_for_testing()
|
||
|
|
registry.initialize(engine, app)
|
||
|
|
init_permission_registry(active_plugin_names={"minimal_example"})
|
||
|
|
container = get_container()
|
||
|
|
await container.initialize()
|
||
|
|
registry.register_plugin(MinimalExamplePlugin())
|
||
|
|
reset_plugin_service_for_testing(registry)
|
||
|
|
sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
|
||
|
|
async with sf() as session:
|
||
|
|
await registry.install(session, "minimal_example")
|
||
|
|
await registry.activate(session, "minimal_example")
|
||
|
|
await session.commit()
|
||
|
|
yield app
|
||
|
|
await close_engine()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def gate_client(gate_app) -> AsyncClient:
|
||
|
|
transport = ASGITransport(app=gate_app)
|
||
|
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||
|
|
yield c
|
||
|
|
|
||
|
|
|
||
|
|
def test_manifest_matches_guide_example():
|
||
|
|
"""The manifest is exactly the guide's 29.1 example."""
|
||
|
|
from app.plugins.builtins.minimal_example.plugin import MinimalExamplePlugin
|
||
|
|
|
||
|
|
m = MinimalExamplePlugin().manifest
|
||
|
|
assert m.name == "minimal_example"
|
||
|
|
assert m.dependencies == []
|
||
|
|
assert [p.path for p in m.routes] == ["/api/v1/minimal-example"]
|
||
|
|
assert m.permissions == ["minimal_example:read"]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_route_served_dynamically(
|
||
|
|
gate_app, gate_client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""Plugin routes are dispatched dynamically, not statically mounted.
|
||
|
|
|
||
|
|
Gate F finding #4: /api/v1/minimal-example never appears in app.routes
|
||
|
|
(plugin routes are resolved at request time against active plugins),
|
||
|
|
yet the endpoint serves authenticated requests correctly.
|
||
|
|
"""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
assert seed is not None
|
||
|
|
|
||
|
|
paths_before = [r.path for r in gate_app.routes if hasattr(r, "path")]
|
||
|
|
assert "/api/v1/minimal-example" not in paths_before, (
|
||
|
|
"Plugin routes must NOT be statically mounted — they are dispatched dynamically"
|
||
|
|
)
|
||
|
|
|
||
|
|
await login_client(gate_client, "admin@tenanta.com")
|
||
|
|
resp = await gate_client.get("/api/v1/minimal-example", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
assert resp.json() == {"items": []}
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_endpoint_serves_authenticated_request(
|
||
|
|
gate_client: AsyncClient, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
assert seed is not None
|
||
|
|
await login_client(gate_client, "admin@tenanta.com")
|
||
|
|
resp = await gate_client.get("/api/v1/minimal-example", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 200, resp.text
|
||
|
|
assert resp.json() == {"items": []}
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_endpoint_requires_permission_without_login(
|
||
|
|
gate_client: AsyncClient,
|
||
|
|
):
|
||
|
|
"""Without login the permission guard rejects the request (401/403)."""
|
||
|
|
resp = await gate_client.get("/api/v1/minimal-example", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code in (401, 403)
|