"""End-to-end test of the labels reference plugin (SPEC §11, §12 M3 acceptance). Slots + KV only, zero core diffs. Verifies the real HTTP surface (discovery -> router -> auth -> KV) and that labels are per-user isolated by construction: one user never sees another's labels or label set. Requires the plugin installed. """ from __future__ import annotations import uuid from collections.abc import AsyncIterator import httpx import pytest_asyncio 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 class _Ctx: users: list[uuid.UUID] client_a: httpx.AsyncClient client_b: httpx.AsyncClient @pytest_asyncio.fixture async def ctx() -> AsyncIterator[_Ctx]: engine = create_async_engine(get_settings().database_url, poolclass=NullPool) sm = async_sessionmaker(engine, expire_on_commit=False) c = _Ctx() c.users = [] async with sm() as s: for _ in range(2): user = User( handle=f"lb{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw") ) s.add(user) await s.flush() c.users.append(user.id) await s.commit() transport = ASGITransport(app=create_app()) async def client(uid: uuid.UUID) -> httpx.AsyncClient: token = await create_session(uid, "pytest") return httpx.AsyncClient( transport=transport, base_url="http://testserver", cookies={"session": token, "csrf": "csrf"}, headers={"X-CSRF": "csrf"}, ) c.client_a = await client(c.users[0]) c.client_b = await client(c.users[1]) try: yield c finally: await c.client_a.aclose() await c.client_b.aclose() async with sm() as s: # cascade drops the users' plugin_kv rows await s.execute(delete(User).where(User.id.in_(c.users))) await s.commit() await engine.dispose() async def test_labels_crud_and_per_user_isolation(ctx: _Ctx) -> None: mid = "msg-1" msg_path = f"/api/plugins/labels/message/{mid}/labels" # A labels a message; reads it back; the label joins A's known set. await ctx.client_a.put(msg_path, json={"labels": ["work", "urgent"]}) assert (await ctx.client_a.get(msg_path)).json() == [ "urgent", "work", ] assert (await ctx.client_a.get("/api/plugins/labels/labels")).json() == ["urgent", "work"] # B sees neither A's per-message labels nor A's label set (KV is per-user). assert (await ctx.client_b.get(msg_path)).json() == [] assert (await ctx.client_b.get("/api/plugins/labels/labels")).json() == [] # Empties/whitespace are dropped and the set is deduped/sorted. await ctx.client_a.put(msg_path, json={"labels": ["work", " ", "later"]}) assert (await ctx.client_a.get(msg_path)).json() == ["later", "work"] assert (await ctx.client_a.get("/api/plugins/labels/labels")).json() == [ "later", "urgent", "work", ] async def test_labels_requires_auth(ctx: _Ctx) -> None: transport = ASGITransport(app=create_app()) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as anon: assert (await anon.get("/api/plugins/labels/labels")).status_code == 401