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:
2026-07-26 21:42:35 +02:00
co-authored by Claude Opus 4.8
parent 4d5a30cffb
commit 4e077f1a37
31 changed files with 1766 additions and 2 deletions
@@ -0,0 +1,35 @@
"""demo plugin: a user-owned note table with RLS (skeleton for SPEC §11.1 tests)
Revision ID: demo0001
Revises:
Create Date: 2026-07-26
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.plugins.rls import enable_rls
revision: str = "demo0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"CREATE TABLE plg_demo_note ("
"id uuid PRIMARY KEY DEFAULT gen_random_uuid(), "
"user_id uuid NOT NULL, "
"body text NOT NULL DEFAULT '')"
)
# Per-user isolation enforced by Postgres (SPEC §11.1); every plg_ user-data
# table calls this right after creation.
enable_rls("plg_demo_note")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS plg_demo_note")
View File
+16
View File
@@ -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)
+38
View File
@@ -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
+115
View File
@@ -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"}
+52
View File
@@ -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()
+117
View File
@@ -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"},
}
]
}
+89
View File
@@ -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")
+84
View File
@@ -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}"))
+51
View File
@@ -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")
+34
View File
@@ -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()