M3 plugin platform: SDK + loader, RLS-isolated tables, migrations, jobs
Backend foundation for the plugin platform (SPEC §11), Phases 1-3.
Phase 1 — SDK + loader:
- app/plugins/types.py: frozen Plugin/UiManifest/Slot/Page/JobSpec contract.
- app/plugins/sdk.py: sdk(name) facade — the only app.* import allowed for plugins
(scoped session, plugin.{name}.* publish, blob, outbox, settings, SSRF http_get, KV, enqueue).
- app/plugins/loader.py: entry-point discovery (webmail.plugins), hook forwarding
(API + worker), router mounting at /api/plugins/{name}, ui-manifest aggregation.
- plugin_kv core table (migration 0013) + GET /api/plugins/ui-manifest.
Phase 2 — non-removable core plugins + DB-enforced privacy:
- builtin flag (calendar/contacts will be builtin=True; uninstall refuses them).
- RLS per-user isolation on plg_* tables: least-privilege webmail_plugin role
(migration 0014), plugin_session(user_id) sets LOCAL ROLE + app.current_user_id,
rls.enable_rls() (ENABLE+FORCE RLS + USING/WITH CHECK policy). Mail untouched.
- Per-plugin Alembic runner (shared env, dedicated version table) + install/uninstall
--drop-data CLI; skeleton fixture proves install -> run -> uninstall --drop-data
leaves zero residue (§12 M3 acceptance).
Phase 3 — plugin jobs (§11.2):
- JobSpec registered as arq function plg.{plugin}.{func} + cron (5-field parser);
singleton=True guarded by a per-(plugin,user) Redis lock; wired onto the shared
interactive worker.
Gates: ruff, mypy --strict (94 files + tests), full suite 326 passed. RLS isolation,
migration round-trip, and singleton lock covered by tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
"""Reset the plugin registry after each test so a test override never leaks into
|
||||
other suites that call ``create_app`` (which mounts discovered plugins)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from app.plugins import loader
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_plugins() -> Iterator[None]:
|
||||
yield
|
||||
loader.set_plugins_for_test(None)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Builtin (non-removable core) plugin handling (SPEC §11 / §12)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.plugins import loader
|
||||
from app.plugins.sdk import Plugin
|
||||
|
||||
|
||||
def test_builtin_defaults_false() -> None:
|
||||
assert Plugin(name="filters", version="1").builtin is False
|
||||
assert Plugin(name="calendar", version="1", builtin=True).builtin is True
|
||||
|
||||
|
||||
def test_assert_removable_blocks_builtin() -> None:
|
||||
loader.set_plugins_for_test(
|
||||
[
|
||||
Plugin(name="filters", version="1"), # removable
|
||||
Plugin(name="calendar", version="1", builtin=True), # core, non-removable
|
||||
]
|
||||
)
|
||||
assert loader.assert_removable("filters").name == "filters"
|
||||
|
||||
with pytest.raises(loader.PluginRemovalError, match="builtin"):
|
||||
loader.assert_removable("calendar")
|
||||
with pytest.raises(loader.PluginRemovalError, match="unknown"):
|
||||
loader.assert_removable("nope")
|
||||
|
||||
|
||||
def test_builtin_surfaces_in_ui_manifest() -> None:
|
||||
from app.plugins.sdk import UiManifest
|
||||
|
||||
loader.set_plugins_for_test(
|
||||
[Plugin(name="calendar", version="1", builtin=True, ui_manifest=UiManifest())]
|
||||
)
|
||||
entry = loader.build_ui_manifest()["plugins"][0]
|
||||
assert entry["name"] == "calendar" and entry["builtin"] is True
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Plugin background jobs: registration, cron parsing, singleton lock (SPEC §11.2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.plugins import jobs, loader
|
||||
from app.plugins.jobs import job_name, parse_cron, plugin_arq_functions, plugin_cron_jobs
|
||||
from app.plugins.sdk import JobSpec, Plugin, sdk
|
||||
from app.redis_client import get_redis
|
||||
|
||||
|
||||
async def _noop(ctx: dict[str, Any], **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_job_name_is_namespaced() -> None:
|
||||
assert job_name("calendar", JobSpec(func=_noop)) == "plg.calendar._noop"
|
||||
|
||||
|
||||
def test_parse_cron() -> None:
|
||||
assert parse_cron("*/15 * * * *") == {
|
||||
"minute": {0, 15, 30, 45},
|
||||
"hour": None,
|
||||
"day": None,
|
||||
"month": None,
|
||||
"weekday": None,
|
||||
}
|
||||
assert parse_cron("0 9-11 1 * 1,3") == {
|
||||
"minute": {0},
|
||||
"hour": {9, 10, 11},
|
||||
"day": {1},
|
||||
"month": None,
|
||||
"weekday": {1, 3},
|
||||
}
|
||||
with pytest.raises(ValueError, match="5 fields"):
|
||||
parse_cron("* * *")
|
||||
|
||||
|
||||
def test_functions_and_cron_registered() -> None:
|
||||
async def refresh(ctx: dict[str, Any], **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
async def poll(ctx: dict[str, Any], **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
loader.set_plugins_for_test(
|
||||
[
|
||||
Plugin(
|
||||
name="cal",
|
||||
version="1",
|
||||
jobs=[JobSpec(func=refresh), JobSpec(func=poll, schedule="*/5 * * * *")],
|
||||
)
|
||||
]
|
||||
)
|
||||
fn_names = {f.name for f in plugin_arq_functions()}
|
||||
assert fn_names == {"plg.cal.refresh", "plg.cal.poll"} # both enqueueable
|
||||
|
||||
crons = plugin_cron_jobs()
|
||||
assert [c.name for c in crons] == ["plg.cal.poll.cron"] # only the scheduled one
|
||||
|
||||
|
||||
async def test_singleton_lock_prevents_overlap() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
async def work(ctx: dict[str, Any], **kwargs: Any) -> None:
|
||||
calls.append(str(kwargs.get("user_id")))
|
||||
|
||||
plugin = Plugin(name="cal", version="1", jobs=[JobSpec(func=work, singleton=True)])
|
||||
runner, _ = jobs._make_runner(plugin, plugin.jobs[0])
|
||||
|
||||
await runner({}, user_id="u1") # runs; lock acquired then released
|
||||
assert calls == ["u1"]
|
||||
|
||||
# Simulate an instance already holding the lock for u2 -> the run is skipped.
|
||||
held = jobs._lock_key("cal", "work", "u2")
|
||||
await get_redis().set(held, b"1", nx=True, ex=60)
|
||||
await runner({}, user_id="u2")
|
||||
assert calls == ["u1"] # unchanged
|
||||
|
||||
await get_redis().delete(held)
|
||||
await runner({}, user_id="u2") # lock free now -> runs
|
||||
assert calls == ["u1", "u2"]
|
||||
|
||||
|
||||
async def test_non_singleton_always_runs() -> None:
|
||||
calls: list[int] = []
|
||||
|
||||
async def work(ctx: dict[str, Any], **kwargs: Any) -> None:
|
||||
calls.append(1)
|
||||
|
||||
plugin = Plugin(name="cal", version="1", jobs=[JobSpec(func=work)])
|
||||
runner, _ = jobs._make_runner(plugin, plugin.jobs[0])
|
||||
await runner({}, user_id="u1")
|
||||
await runner({}, user_id="u1")
|
||||
assert calls == [1, 1] # no lock, both run
|
||||
|
||||
|
||||
async def test_sdk_enqueue_uses_namespaced_name(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_enqueue(name: str, **kwargs: Any) -> None:
|
||||
captured["name"] = name
|
||||
captured["kwargs"] = kwargs
|
||||
|
||||
monkeypatch.setattr("app.plugins.sdk.enqueue_job", fake_enqueue)
|
||||
|
||||
async def refresh(ctx: dict[str, Any], **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
await sdk("cal").enqueue(refresh, user_id="u1")
|
||||
assert captured["name"] == "plg.cal.refresh"
|
||||
assert captured["kwargs"] == {"user_id": "u1"}
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Namespaced plugin KV store round-trips (SPEC §11 plugin_kv)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import session_scope
|
||||
from app.identity.passwords import hash_password
|
||||
from app.models import User
|
||||
from app.plugins.sdk import sdk
|
||||
|
||||
|
||||
async def test_plugin_kv_crud_is_namespaced() -> None:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = sm()
|
||||
user = User(handle=f"kv{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
kv = sdk("filters").kv(user.id)
|
||||
other = sdk("labels").kv(user.id) # different plugin, same user
|
||||
|
||||
assert await kv.get("rules", default=[]) == [] # missing key -> default
|
||||
await kv.set("rules", [{"match": "boss", "flag": "important"}])
|
||||
await kv.set("count", 3)
|
||||
assert await kv.get("rules") == [{"match": "boss", "flag": "important"}]
|
||||
assert await kv.get("count") == 3
|
||||
|
||||
# Namespacing: the labels plugin sees nothing filters wrote.
|
||||
assert await other.get("rules") is None
|
||||
assert await other.items() == {}
|
||||
|
||||
assert await kv.items() == {"rules": [{"match": "boss", "flag": "important"}], "count": 3}
|
||||
|
||||
await kv.set("count", 4) # upsert overwrites
|
||||
assert await kv.get("count") == 4
|
||||
|
||||
await kv.delete("count")
|
||||
assert await kv.get("count") is None
|
||||
assert set((await kv.items()).keys()) == {"rules"}
|
||||
finally:
|
||||
async with session_scope() as cleanup: # cascade drops plugin_kv rows
|
||||
await cleanup.execute(delete(User).where(User.id == user.id))
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Plugin discovery + core wiring (SPEC §11)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from httpx import ASGITransport
|
||||
|
||||
from app.events import get_event_bus
|
||||
from app.plugins import loader
|
||||
from app.plugins.sdk import Page, PageNav, Plugin, Slot, UiManifest
|
||||
|
||||
|
||||
class _FakeEP:
|
||||
def __init__(self, name: str, obj: object) -> None:
|
||||
self.name = name
|
||||
self._obj = obj
|
||||
|
||||
def load(self) -> object:
|
||||
return self._obj
|
||||
|
||||
|
||||
def test_discover_reads_entry_points(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
plugin = Plugin(name="filters", version="1.2")
|
||||
monkeypatch.setattr(loader, "entry_points", lambda group: [_FakeEP("filters", plugin)])
|
||||
loader.set_plugins_for_test(None) # force rediscovery through the fake
|
||||
found = loader.discover_plugins()
|
||||
assert [(p.name, p.version) for p in found] == [("filters", "1.2")]
|
||||
|
||||
|
||||
def test_discover_accepts_module_exposing_plugin(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class _Mod:
|
||||
plugin = Plugin(name="labels", version="0.1")
|
||||
|
||||
monkeypatch.setattr(loader, "entry_points", lambda group: [_FakeEP("labels", _Mod())])
|
||||
loader.set_plugins_for_test(None)
|
||||
assert [p.name for p in loader.discover_plugins()] == ["labels"]
|
||||
|
||||
|
||||
def test_discover_skips_duplicate_names(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
eps = [
|
||||
_FakeEP("a", Plugin(name="dup", version="1")),
|
||||
_FakeEP("b", Plugin(name="dup", version="2")),
|
||||
]
|
||||
monkeypatch.setattr(loader, "entry_points", lambda group: eps)
|
||||
loader.set_plugins_for_test(None)
|
||||
found = loader.discover_plugins()
|
||||
assert [p.version for p in found] == ["1"] # first wins, duplicate dropped
|
||||
|
||||
|
||||
async def test_register_hooks_forwards_events() -> None:
|
||||
seen: list[dict[str, object]] = []
|
||||
|
||||
async def on_new(payload: dict[str, object]) -> None:
|
||||
seen.append(payload)
|
||||
|
||||
loader.set_plugins_for_test(
|
||||
[Plugin(name="watcher", version="1", hooks={"message.new": on_new})]
|
||||
)
|
||||
bus = get_event_bus()
|
||||
loader.register_hooks(bus)
|
||||
loader.register_hooks(bus) # idempotent: no double subscription
|
||||
|
||||
await bus.publish("message.new", {"user_id": "u", "message_id": "m"})
|
||||
assert seen == [{"user_id": "u", "message_id": "m"}]
|
||||
|
||||
|
||||
async def test_mount_routers_prefixes_by_name() -> None:
|
||||
r = APIRouter()
|
||||
|
||||
@r.get("/ping")
|
||||
async def _ping() -> dict[str, str]:
|
||||
return {"ok": "1"}
|
||||
|
||||
loader.set_plugins_for_test([Plugin(name="demo", version="1", router=r)])
|
||||
app = FastAPI()
|
||||
loader.mount_routers(app)
|
||||
transport = ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
resp = await client.get("/api/plugins/demo/ping")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": "1"}
|
||||
|
||||
|
||||
def test_build_ui_manifest_aggregates() -> None:
|
||||
manifest = UiManifest(
|
||||
slots=[Slot(name="message.actions", bundle="/a.js")],
|
||||
pages=[
|
||||
Page(
|
||||
route="/notes",
|
||||
nav=PageNav(icon="note", label="Notes", order=5),
|
||||
bundle="/p.js",
|
||||
)
|
||||
],
|
||||
capabilities={"recipients.suggest": "/api/plugins/contacts/suggest"},
|
||||
)
|
||||
loader.set_plugins_for_test([Plugin(name="notes", version="2.0", ui_manifest=manifest)])
|
||||
out = loader.build_ui_manifest()
|
||||
assert out == {
|
||||
"plugins": [
|
||||
{
|
||||
"name": "notes",
|
||||
"version": "2.0",
|
||||
"builtin": False,
|
||||
"slots": [{"name": "message.actions", "bundle": "/a.js"}],
|
||||
"pages": [
|
||||
{
|
||||
"route": "/notes",
|
||||
"bundle": "/p.js",
|
||||
"nav": {"icon": "note", "label": "Notes", "order": 5},
|
||||
}
|
||||
],
|
||||
"capabilities": {"recipients.suggest": "/api/plugins/contacts/suggest"},
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Per-plugin migration runner + install/uninstall lifecycle (SPEC §11.1, §12 M3).
|
||||
|
||||
The runner drives Alembic synchronously (env.py calls asyncio.run), so it's
|
||||
invoked via ``asyncio.to_thread`` from these async tests to avoid a nested loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.plugins import loader, migrate
|
||||
from app.plugins.sdk import Plugin
|
||||
|
||||
_VERSIONS = str(Path(__file__).parent.parent / "fixtures" / "plugin_demo" / "versions")
|
||||
|
||||
|
||||
async def _scalar(sql: str, **params: str) -> object:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
return (await conn.execute(text(sql), params)).scalar()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _regclass(name: str) -> object:
|
||||
return await _scalar("SELECT to_regclass(:n)", n=name)
|
||||
|
||||
|
||||
async def _cleanup() -> None:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("DROP TABLE IF EXISTS plg_demo_note"))
|
||||
await conn.execute(text("DROP TABLE IF EXISTS alembic_version_plg_demo"))
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_install_run_uninstall_dropdata_roundtrip() -> None:
|
||||
loader.set_plugins_for_test([Plugin(name="demo", version="1.0", migrations=_VERSIONS)])
|
||||
try:
|
||||
await asyncio.to_thread(migrate.install, "demo")
|
||||
|
||||
# Table exists, RLS is enabled on it, and the per-plugin version table is present.
|
||||
assert await _regclass("plg_demo_note") == "plg_demo_note"
|
||||
assert await _scalar(
|
||||
"SELECT relrowsecurity FROM pg_class WHERE relname = 'plg_demo_note'"
|
||||
) is True
|
||||
assert await _regclass("alembic_version_plg_demo") == "alembic_version_plg_demo"
|
||||
|
||||
# --drop-data removes the table AND the version table: zero residue.
|
||||
await asyncio.to_thread(migrate.uninstall, "demo", drop_data=True)
|
||||
assert await _regclass("plg_demo_note") is None
|
||||
assert await _regclass("alembic_version_plg_demo") is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_uninstall_without_drop_data_keeps_the_table() -> None:
|
||||
loader.set_plugins_for_test([Plugin(name="demo", version="1.0", migrations=_VERSIONS)])
|
||||
try:
|
||||
await asyncio.to_thread(migrate.install, "demo")
|
||||
await asyncio.to_thread(migrate.uninstall, "demo", drop_data=False)
|
||||
assert await _regclass("plg_demo_note") == "plg_demo_note" # data preserved
|
||||
finally:
|
||||
await asyncio.to_thread(migrate.uninstall, "demo", drop_data=True)
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_uninstall_refuses_builtin_plugin() -> None:
|
||||
loader.set_plugins_for_test(
|
||||
[Plugin(name="calendar", version="1", builtin=True, migrations=_VERSIONS)]
|
||||
)
|
||||
with pytest.raises(loader.PluginRemovalError, match="builtin"):
|
||||
await asyncio.to_thread(migrate.uninstall, "calendar", drop_data=True)
|
||||
|
||||
|
||||
async def test_install_unknown_plugin_errors() -> None:
|
||||
loader.set_plugins_for_test([])
|
||||
with pytest.raises(migrate.PluginMigrationError, match="unknown"):
|
||||
await asyncio.to_thread(migrate.install, "nope")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Row-Level Security enforcement for plugin tables (SPEC §11.1 + privacy).
|
||||
|
||||
Proves that per-user isolation is enforced by Postgres — not by the query — so a
|
||||
plugin that forgets ``WHERE user_id`` still cannot read or write another user's
|
||||
rows. Requires the ``webmail_plugin`` role (migration 0014) to be applied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import session_scope
|
||||
from app.plugins.db import plugin_session
|
||||
from app.plugins.rls import rls_ddl
|
||||
from app.plugins.sdk import sdk
|
||||
|
||||
TABLE = "plg_rlsdemo_note"
|
||||
|
||||
|
||||
async def _create_demo_table() -> None:
|
||||
async with session_scope() as s:
|
||||
await s.execute(text(f"DROP TABLE IF EXISTS {TABLE}"))
|
||||
await s.execute(
|
||||
text(
|
||||
f"CREATE TABLE {TABLE} ("
|
||||
"id uuid PRIMARY KEY DEFAULT gen_random_uuid(), "
|
||||
"user_id uuid NOT NULL, body text)"
|
||||
)
|
||||
)
|
||||
for stmt in rls_ddl(TABLE):
|
||||
await s.execute(text(stmt))
|
||||
|
||||
|
||||
async def test_rls_enforces_per_user_isolation() -> None:
|
||||
user_a, user_b = uuid.uuid4(), uuid.uuid4()
|
||||
await _create_demo_table()
|
||||
try:
|
||||
# Seed one row per user as the privileged role (superuser bypasses RLS).
|
||||
async with session_scope() as s:
|
||||
await s.execute(
|
||||
text(f"INSERT INTO {TABLE}(user_id, body) VALUES (:u, 'a-note')"),
|
||||
{"u": str(user_a)},
|
||||
)
|
||||
b_id = (
|
||||
await s.execute(
|
||||
text(f"INSERT INTO {TABLE}(user_id, body) VALUES (:u, 'b-note') RETURNING id"),
|
||||
{"u": str(user_b)},
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
# Scoped to user A: the query has NO user filter, yet only A's row is visible.
|
||||
async with plugin_session(user_a) as s:
|
||||
assert (await s.execute(text("SELECT current_user"))).scalar_one() == "webmail_plugin"
|
||||
visible = (await s.execute(text(f"SELECT user_id FROM {TABLE}"))).scalars().all()
|
||||
assert set(visible) == {user_a}
|
||||
# IDOR blocked: fetching B's row by its exact id returns nothing.
|
||||
got = (
|
||||
await s.execute(text(f"SELECT id FROM {TABLE} WHERE id = :i"), {"i": str(b_id)})
|
||||
).first()
|
||||
assert got is None
|
||||
|
||||
# WITH CHECK: A cannot insert a row owned by B (error aborts the txn).
|
||||
with pytest.raises(Exception, match="row-level security"):
|
||||
async with plugin_session(user_a) as s:
|
||||
await s.execute(
|
||||
text(f"INSERT INTO {TABLE}(user_id, body) VALUES (:u, 'forged')"),
|
||||
{"u": str(user_b)},
|
||||
)
|
||||
|
||||
# A can write its own rows via the SDK session; B's data stays invisible.
|
||||
async with sdk("demo").session(user_a) as s:
|
||||
await s.execute(
|
||||
text(f"INSERT INTO {TABLE}(user_id, body) VALUES (:u, 'a-note-2')"),
|
||||
{"u": str(user_a)},
|
||||
)
|
||||
async with plugin_session(user_a) as s:
|
||||
count = (await s.execute(text(f"SELECT count(*) FROM {TABLE}"))).scalar_one()
|
||||
assert count == 2 # both of A's rows, none of B's
|
||||
finally:
|
||||
async with session_scope() as s:
|
||||
await s.execute(text(f"DROP TABLE IF EXISTS {TABLE}"))
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Plugin SDK facade behaviour (SPEC §11)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.events import get_event_bus
|
||||
from app.plugins.sdk import sdk
|
||||
|
||||
|
||||
async def test_publish_namespaces_under_plugin_name() -> None:
|
||||
received: list[dict[str, object]] = []
|
||||
|
||||
async def handler(payload: dict[str, object]) -> None:
|
||||
received.append(payload)
|
||||
|
||||
event = f"plugin.demo.thing_{uuid.uuid4().hex[:6]}"
|
||||
suffix = event.removeprefix("plugin.demo.")
|
||||
get_event_bus().subscribe(event, handler)
|
||||
|
||||
await sdk("demo").publish(suffix, {"user_id": "u1", "x": 1})
|
||||
assert received == [{"user_id": "u1", "x": 1}]
|
||||
|
||||
|
||||
async def test_publish_requires_user_id() -> None:
|
||||
with pytest.raises(ValueError, match="user_id"):
|
||||
await sdk("demo").publish("thing", {"x": 1})
|
||||
|
||||
|
||||
async def test_http_get_uses_ssrf_guard(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_guarded_get(url: str) -> str | None:
|
||||
calls.append(url)
|
||||
return f"body:{url}"
|
||||
|
||||
monkeypatch.setattr("app.plugins.sdk.guarded_get", fake_guarded_get)
|
||||
out = await sdk("demo").http_get("http://example.test/x")
|
||||
assert out == "body:http://example.test/x"
|
||||
assert calls == ["http://example.test/x"]
|
||||
|
||||
|
||||
def test_sdk_exposes_shared_services() -> None:
|
||||
s = sdk("demo")
|
||||
assert s.name == "demo"
|
||||
assert s.settings is not None
|
||||
# blob store + outbox helpers are reachable through the SDK namespace.
|
||||
assert hasattr(s.blob, "store") and hasattr(s.blob, "load")
|
||||
assert hasattr(s.outbox, "upsert_draft")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Frozen plugin contract validation (SPEC §11)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.plugins.sdk import Plugin, Slot, UiManifest
|
||||
|
||||
|
||||
def test_slot_rejects_unknown_name() -> None:
|
||||
Slot(name="message.actions", bundle="/b.js") # a known slot is fine
|
||||
with pytest.raises(ValueError, match="unknown slot"):
|
||||
Slot(name="mail.everywhere", bundle="/b.js")
|
||||
|
||||
|
||||
def test_plugin_name_must_be_identifier_like() -> None:
|
||||
Plugin(name="filters", version="1.0")
|
||||
Plugin(name="my_plugin2", version="1.0")
|
||||
for bad in ("Filters", "2cool", "has-dash", "with space", ""):
|
||||
with pytest.raises(ValueError, match="invalid plugin name"):
|
||||
Plugin(name=bad, version="1.0")
|
||||
|
||||
|
||||
def test_plugin_defaults_are_independent() -> None:
|
||||
a = Plugin(name="a", version="1.0")
|
||||
b = Plugin(name="b", version="1.0")
|
||||
assert a.hooks == {} and a.jobs == [] and a.ui_manifest is None
|
||||
a.hooks["x"] = lambda _p: None # type: ignore[assignment,return-value]
|
||||
assert b.hooks == {} # default_factory gives each instance its own dict
|
||||
|
||||
|
||||
def test_ui_manifest_defaults() -> None:
|
||||
m = UiManifest()
|
||||
assert m.slots == [] and m.pages == [] and m.capabilities == {}
|
||||
@@ -0,0 +1,73 @@
|
||||
"""GET /api/plugins/ui-manifest returns the aggregated manifest (SPEC §11.3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.sessions import create_session
|
||||
from app.main import create_app
|
||||
from app.models import User
|
||||
from app.plugins import loader
|
||||
from app.plugins.sdk import Plugin, Slot, UiManifest
|
||||
|
||||
|
||||
async def test_ui_manifest_endpoint() -> None:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = sm()
|
||||
user = User(handle=f"ui{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
|
||||
loader.set_plugins_for_test(
|
||||
[
|
||||
Plugin(
|
||||
name="labels",
|
||||
version="1.0",
|
||||
ui_manifest=UiManifest(slots=[Slot(name="message.actions", bundle="/labels.js")]),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
token = await create_session(user.id, "pytest")
|
||||
transport = ASGITransport(app=create_app())
|
||||
client = httpx.AsyncClient(
|
||||
transport=transport, base_url="http://testserver", cookies={"session": token}
|
||||
)
|
||||
try:
|
||||
# Unauthenticated request is rejected.
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://testserver"
|
||||
) as anon:
|
||||
assert (await anon.get("/api/plugins/ui-manifest")).status_code == 401
|
||||
|
||||
resp = await client.get("/api/plugins/ui-manifest")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body == {
|
||||
"plugins": [
|
||||
{
|
||||
"name": "labels",
|
||||
"version": "1.0",
|
||||
"builtin": False,
|
||||
"slots": [{"name": "message.actions", "bundle": "/labels.js"}],
|
||||
"pages": [],
|
||||
"capabilities": {},
|
||||
}
|
||||
]
|
||||
}
|
||||
finally:
|
||||
await client.aclose()
|
||||
async with sm() as cleanup:
|
||||
await cleanup.execute(delete(User).where(User.id == user.id))
|
||||
await cleanup.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
Reference in New Issue
Block a user