[arnav] pytest config, conftest isolation, and API endpoint integration tests

This commit is contained in:
Arnav Naval
2026-05-02 11:08:38 -05:00
parent bc816437b6
commit 154fb60584
11 changed files with 1677 additions and 0 deletions
+250
View File
@@ -0,0 +1,250 @@
"""Shared fixtures for the backend test suite.
The hard part of testing this app is environment setup — every sub-app
reads `OPENSWARM_DATA_DIR` (via `backend.config.paths`) at import time and
several lifespans kick off network/IO side effects (PostHog, 9Router,
MCP registry refresh). This conftest centralises that boilerplate so a
new endpoint test can be a one-liner:
def test_x(client):
r = client.get("/api/x/list")
assert r.status_code == 200
What's done here:
1. `OPENSWARM_DATA_DIR` is redirected to a fresh tmpdir BEFORE any
`backend.*` import. Same trick as the existing stress-test files.
2. PostHog is replaced with a Mock so `record(...)` never tries to
phone home.
3. `nine_router.ensure_running` is no-opped so the analytics lifespan
doesn't try to spawn a node subprocess.
4. Exposes a `client` fixture that builds a TestClient against the real
`backend.main:app`, drives the FastAPI lifespan (which seeds the
built-in modes / runs the dashboards migration), and pre-injects
the per-install bearer token.
5. Opt-in fixtures (`patched_skills_dir`, `stub_agent_loop`) for
individual test files that need extra isolation.
"""
from __future__ import annotations
import asyncio
import os
import shutil
import sys
import tempfile
from typing import Iterator
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Pre-import bootstrap: redirect data dir BEFORE backend.* is imported.
# ---------------------------------------------------------------------------
# This MUST happen at module load time. backend/config/paths.py snapshots
# the env var into module-level constants (DATA_ROOT, SESSIONS_DIR, etc.),
# so any backend.* import after this line will pick up our tmp root.
_TMPROOT = tempfile.mkdtemp(prefix="openswarm-tests-")
os.environ["OPENSWARM_DATA_DIR"] = _TMPROOT
# Defensive: never let any test run hit the real OAuth helper service.
os.environ.setdefault("OPENSWARM_OAUTH_BASE_URL", "http://127.0.0.1:0")
# Re-home the user. Several agent paths fall back to `~/.openswarm/workspaces/...`
# when no default_folder/target_directory is configured (see
# `AgentManager.launch_agent`). On a CI runner that's likely fine, but on a
# developer machine it would write into the real home directory — and on
# sandboxed runs (e.g. agent tests under macOS sandbox) it fails outright with
# PermissionError. Pinning HOME inside the tmp root keeps the tests
# self-contained AND prevents accidental writes to the user's `~/.claude/skills`.
_FAKE_HOME = os.path.join(_TMPROOT, "home")
os.makedirs(_FAKE_HOME, exist_ok=True)
os.environ["HOME"] = _FAKE_HOME
# ---------------------------------------------------------------------------
# Mock external services BEFORE backend.main is imported.
# ---------------------------------------------------------------------------
def _install_external_service_mocks() -> None:
"""Patch PostHog + 9Router so lifespans don't make outbound calls.
Called once at module load. We don't unwind these — they live for
the entire pytest session (matching the lifetime of the app fixture).
"""
# PostHog: replace the class so `Posthog(...)` returns a mock that
# silently absorbs `.capture(...)` / `.set(...)` / `.shutdown()`.
import posthog
_posthog_mock = MagicMock()
_posthog_mock.capture = MagicMock()
_posthog_mock.set = MagicMock()
_posthog_mock.shutdown = MagicMock()
posthog.Posthog = MagicMock(return_value=_posthog_mock)
_install_external_service_mocks()
# ---------------------------------------------------------------------------
# Now safe to import backend.* — paths are pinned to _TMPROOT, posthog
# is a mock.
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def app():
"""The real FastAPI app, lifespan NOT yet run.
Importing main is what registers all routers and middleware. We
additionally patch out the real 9Router and Skills sync triggers
that the analytics + settings lifespans spawn.
"""
# nine_router.ensure_running (called from analytics_lifespan) tries
# to pgrep / spawn a Node process. Replace with no-ops, matching
# each function's original sync/async shape (mismatch produces a
# "coroutine was never awaited" RuntimeWarning).
from backend.apps import nine_router as _nine
async def _async_noop(*args, **kwargs):
return None
def _sync_noop(*args, **kwargs):
return None
_nine.ensure_running = _async_noop # type: ignore[assignment]
_nine.stop = _sync_noop # type: ignore[assignment]
_nine.sync_gemini_api_key = _async_noop # type: ignore[assignment]
_nine.sync_openswarm_pro_as_claude = _async_noop # type: ignore[assignment]
from backend.main import app as _app
return _app
@pytest.fixture(scope="session")
def auth_token() -> str:
"""Initialise the per-install bearer token once for the session."""
from backend.auth import get_auth_token, init_auth_token
token = get_auth_token() or init_auth_token()
return token
@pytest.fixture(scope="session")
def auth_headers(auth_token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {auth_token}"}
@pytest.fixture
def tmp_data_dirs(monkeypatch) -> Iterator[str]:
"""Wipe + recreate every per-feature data dir before each test.
Keeps tests order-independent. We don't move DATA_ROOT itself
(paths.py snapshotted that at import time); we just clear the
children so a previous test's mode/dashboard/tool/etc files don't
leak into the next.
"""
from backend.config import paths as _p
targets = [
_p.SESSIONS_DIR,
_p.MODES_DIR,
_p.DASHBOARDS_DIR,
_p.SETTINGS_DIR,
_p.TOOLS_DIR,
_p.OUTPUTS_DIR,
_p.OUTPUTS_WORKSPACE_DIR,
_p.SKILLS_WORKSPACE_DIR,
_p.DASHBOARD_LAYOUT_DIR,
]
for d in targets:
if os.path.exists(d):
shutil.rmtree(d, ignore_errors=True)
os.makedirs(d, exist_ok=True)
# Drop in-memory agent_manager state that survived a prior test.
try:
from backend.apps.agents.agent_manager import agent_manager as _am
_am.sessions.clear()
_am.tasks.clear()
except Exception:
pass
# Drop in-memory seq_log state too. Session ids are UUIDs so cross-test
# collisions are vanishingly rare, but a leaked ring buffer between
# tests can still produce confusing replay results in any test that
# inspects seq_log directly.
try:
from backend.apps.agents.seq_log import seq_log as _sl
_sl._per_session.clear()
except Exception:
pass
yield _p.DATA_ROOT
@pytest.fixture
def client(app, auth_headers, tmp_data_dirs):
"""A pre-authenticated TestClient with FastAPI lifespan running.
The `with` block triggers startup events — this is what seeds the
built-in modes (modes_lifespan) and runs the dashboards migration
(dashboards_lifespan). Without it the modes endpoint would return
an empty list and the dashboards list would be empty too.
"""
from fastapi.testclient import TestClient
# Per-test: seed the built-ins manually too. Lifespan only seeds
# them if the directory is empty, which `tmp_data_dirs` guarantees.
with TestClient(app) as tc:
tc.headers.update(auth_headers)
yield tc
# ---------------------------------------------------------------------------
# Opt-in fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def patched_skills_dir(monkeypatch, tmp_path) -> str:
"""Repoint backend.apps.skills at a tmp dir.
The real SKILLS_DIR lives at `~/.claude/skills` and is shared with
the user's actual Claude Code install. Tests must never touch it.
Hard-asserts that the patched path is inside the test tmpdir before
yielding.
"""
from backend.apps.skills import skills as skills_mod
skills_root = str(tmp_path / "skills")
os.makedirs(skills_root, exist_ok=True)
# Sanity gate — fail fast if pytest's tmp_path is somehow ~/.claude.
real_skills = os.path.expanduser("~/.claude/skills")
assert os.path.abspath(skills_root) != os.path.abspath(real_skills), (
"patched_skills_dir would clobber the real ~/.claude/skills"
)
monkeypatch.setattr(skills_mod, "SKILLS_DIR", skills_root)
monkeypatch.setattr(
skills_mod, "INDEX_PATH", os.path.join(skills_root, ".skills_index.json")
)
return skills_root
@pytest.fixture
def stub_agent_loop(monkeypatch):
"""Replace AgentManager._run_agent_loop with a no-op coroutine.
Lets routes that internally `asyncio.create_task(self._run_agent_loop(...))`
succeed without spawning the Claude Code CLI / making real model calls.
"""
from backend.apps.agents.agent_manager import AgentManager
async def _noop(self, session_id, prompt, *args, **kwargs):
return None
monkeypatch.setattr(AgentManager, "_run_agent_loop", _noop)
+550
View File
@@ -0,0 +1,550 @@
"""REST-surface smoke tests for /api/agents.
Tests:
- Auth: a request without the bearer token returns 401 (positive
control on the middleware).
- GET /sessions returns an empty list on a clean root.
- POST /launch creates a session; subsequent GET /sessions/{id}
succeeds; DELETE removes it.
- POST /sessions/{id}/edit_message validates input.
- PATCH /sessions/{id} updates allowed fields (name, system_prompt).
- GET /sessions/{id}/branches returns the default 'main' branch.
- GET /history returns paginated results.
"""
from __future__ import annotations
def test_protected_route_requires_auth(app, tmp_data_dirs):
"""Positive control: hitting an agents route without auth → 401."""
from fastapi.testclient import TestClient
with TestClient(app) as tc:
# No Authorization header.
resp = tc.get("/api/agents/sessions")
assert resp.status_code == 401
def test_list_sessions_empty(client):
resp = client.get("/api/agents/sessions")
assert resp.status_code == 200
assert resp.json() == {"sessions": []}
def test_get_unknown_session_returns_404(client):
resp = client.get("/api/agents/sessions/does-not-exist")
assert resp.status_code == 404
def test_launch_get_delete_session(client, stub_agent_loop):
"""End-to-end REST round-trip for a session.
Uses stub_agent_loop defensively even though `launch` itself
doesn't kick the loop — keeps the test stable if the launch path
is ever refactored to start streaming immediately.
"""
launch = client.post(
"/api/agents/launch",
json={
"name": "Smoke Agent",
"model": "sonnet",
"mode": "agent",
"provider": "anthropic",
},
)
assert launch.status_code == 200, launch.text
session_id = launch.json()["session_id"]
fetched = client.get(f"/api/agents/sessions/{session_id}")
assert fetched.status_code == 200
body = fetched.json()
assert body["id"] == session_id
assert body["name"] == "Smoke Agent"
listed = client.get("/api/agents/sessions").json()["sessions"]
assert any(s["id"] == session_id for s in listed)
deleted = client.delete(f"/api/agents/sessions/{session_id}")
assert deleted.status_code == 200
gone = client.get(f"/api/agents/sessions/{session_id}")
assert gone.status_code == 404
def test_send_message_requires_prompt(client, stub_agent_loop):
launch = client.post(
"/api/agents/launch",
json={"name": "X", "model": "sonnet", "mode": "agent"},
)
session_id = launch.json()["session_id"]
resp = client.post(
f"/api/agents/sessions/{session_id}/message",
json={"prompt": ""},
)
assert resp.status_code == 400
def test_edit_message_requires_id_and_content(client, stub_agent_loop):
launch = client.post(
"/api/agents/launch",
json={"name": "X", "model": "sonnet", "mode": "agent"},
)
session_id = launch.json()["session_id"]
missing_id = client.post(
f"/api/agents/sessions/{session_id}/edit_message",
json={"content": "hi"},
)
assert missing_id.status_code == 400
missing_content = client.post(
f"/api/agents/sessions/{session_id}/edit_message",
json={"message_id": "abc"},
)
assert missing_content.status_code == 400
def test_patch_session_updates_name(client, stub_agent_loop):
"""PATCH /sessions/{id} only mutates the allowlist {name,
system_prompt, thinking_level}. Anything else is silently ignored
in `update_session` — tested implicitly by the round-trip below."""
launch = client.post(
"/api/agents/launch",
json={"name": "Original", "model": "sonnet", "mode": "agent"},
)
session_id = launch.json()["session_id"]
resp = client.patch(
f"/api/agents/sessions/{session_id}",
json={"name": "Renamed", "model": "ignored-because-not-allowed"},
)
assert resp.status_code == 200
fetched = client.get(f"/api/agents/sessions/{session_id}").json()
assert fetched["name"] == "Renamed"
assert fetched["model"] == "sonnet" # not changed by the PATCH
def test_get_branches_returns_main(client, stub_agent_loop):
launch = client.post(
"/api/agents/launch",
json={"name": "B", "model": "sonnet", "mode": "agent"},
)
session_id = launch.json()["session_id"]
resp = client.get(f"/api/agents/sessions/{session_id}/branches")
assert resp.status_code == 200
body = resp.json()
assert body["active_branch_id"] == "main"
assert "main" in body["branches"]
def test_history_endpoint_returns_paginated_shape(client):
"""`/history` is the search-and-resume endpoint. Even with no
saved sessions it should return the paginated wrapper without
error."""
resp = client.get("/api/agents/history", params={"q": "", "limit": 5, "offset": 0})
assert resp.status_code == 200
body = resp.json()
# We don't pin the exact key set here (it grows over time); just
# that the response is a JSON object with a list-of-things in it.
assert isinstance(body, dict)
# The paginated wrapper does have stable keys though — assert them so
# we catch accidental reshapes that would break the frontend's
# history drawer.
assert "sessions" in body and isinstance(body["sessions"], list)
assert "total" in body
assert "has_more" in body
# ---------------------------------------------------------------------------
# Lifecycle: stop / close / resume / duplicate / switch_branch
# ---------------------------------------------------------------------------
def _launch(client, **overrides) -> str:
"""Helper: launch a session and return its id."""
payload = {"name": "T", "model": "sonnet", "mode": "agent"}
payload.update(overrides)
resp = client.post("/api/agents/launch", json=payload)
assert resp.status_code == 200, resp.text
return resp.json()["session_id"]
def test_stop_agent_marks_session_stopped(client, stub_agent_loop):
"""POST /sessions/{id}/stop transitions status to 'stopped'.
The route is idempotent on a freshly-launched session that has no
running task — `stop_agent` no-ops on the task side and still flips
the status field.
"""
session_id = _launch(client)
resp = client.post(f"/api/agents/sessions/{session_id}/stop")
assert resp.status_code == 200
assert resp.json() == {"ok": True}
fetched = client.get(f"/api/agents/sessions/{session_id}").json()
assert fetched["status"] == "stopped"
def test_close_session_removes_from_active_and_lands_in_history(client, stub_agent_loop):
"""`/close` is the soft-delete path: persists the session JSON to
disk, drops it from in-memory, and the `/history` endpoint should
serve it back. Distinct from DELETE which is a hard purge."""
session_id = _launch(client, name="To-Close")
resp = client.post(f"/api/agents/sessions/{session_id}/close")
assert resp.status_code == 200
assert resp.json() == {"ok": True}
listed = client.get("/api/agents/sessions").json()["sessions"]
assert all(s["id"] != session_id for s in listed)
history = client.get("/api/agents/history").json()
assert any(item["id"] == session_id for item in history["sessions"])
def test_close_unknown_session_returns_404(client):
resp = client.post("/api/agents/sessions/does-not-exist/close")
assert resp.status_code == 404
def test_resume_session_restores_to_active(client, stub_agent_loop):
"""Round-trip: launch → close → resume → session is active again
(in-memory) and gone from history."""
session_id = _launch(client, name="To-Resume")
close = client.post(f"/api/agents/sessions/{session_id}/close")
assert close.status_code == 200
resume = client.post(f"/api/agents/sessions/{session_id}/resume")
assert resume.status_code == 200
body = resume.json()
assert body["session"]["id"] == session_id
fetched = client.get(f"/api/agents/sessions/{session_id}")
assert fetched.status_code == 200
# `resume_session` deletes the on-disk file, so the entry should no
# longer appear in history.
history = client.get("/api/agents/history").json()
assert all(item["id"] != session_id for item in history["sessions"])
def test_resume_unknown_session_returns_404(client):
resp = client.post("/api/agents/sessions/does-not-exist/resume")
assert resp.status_code == 404
def test_duplicate_session_returns_new_session(client, stub_agent_loop):
"""Duplicate forks the chat history into a new session id. The
original must still be reachable; the copy gets ` (copy)` appended
to the name."""
original_id = _launch(client, name="Original")
resp = client.post(f"/api/agents/sessions/{original_id}/duplicate", json={})
assert resp.status_code == 200
new_session = resp.json()["session"]
assert new_session["id"] != original_id
assert new_session["name"].endswith("(copy)")
listed_ids = {s["id"] for s in client.get("/api/agents/sessions").json()["sessions"]}
assert original_id in listed_ids
assert new_session["id"] in listed_ids
def test_switch_branch_validation(client, stub_agent_loop):
"""Empty `branch_id` → 400; switching to the default `main` branch
that always exists → 200."""
session_id = _launch(client)
missing = client.post(
f"/api/agents/sessions/{session_id}/switch_branch",
json={},
)
assert missing.status_code == 400
ok = client.post(
f"/api/agents/sessions/{session_id}/switch_branch",
json={"branch_id": "main"},
)
assert ok.status_code == 200
# ---------------------------------------------------------------------------
# Routes defined directly on `app` in main.py: /compact and /clear
# ---------------------------------------------------------------------------
def test_session_compact_returns_status(client, stub_agent_loop):
"""`/compact` is a programmatic summarisation pass — no LLM call.
On a session with < 4 messages it short-circuits with `compacted=False`
but still returns 200."""
session_id = _launch(client)
resp = client.post(f"/api/agents/sessions/{session_id}/compact")
assert resp.status_code == 200
body = resp.json()
assert "compacted" in body
assert body["compacted"] is False # short prompt, nothing to compact
def test_session_compact_unknown_returns_404(client):
resp = client.post("/api/agents/sessions/does-not-exist/compact")
assert resp.status_code == 404
def test_session_clear_resets_sdk_state(client, stub_agent_loop):
"""`/clear` keeps `messages` but mints a new sdk_session_id and
resets MCPs/outputs/tokens/cost. We assert the response shape and
that the session-level fields snap back to defaults."""
session_id = _launch(client)
resp = client.post(f"/api/agents/sessions/{session_id}/clear")
assert resp.status_code == 200
assert resp.json() == {"cleared": True}
fetched = client.get(f"/api/agents/sessions/{session_id}").json()
assert fetched["sdk_session_id"] is None
assert fetched["active_mcps"] == []
assert fetched["active_outputs"] == []
assert fetched["tokens"] == {"input": 0, "output": 0}
assert fetched["cost_usd"] == 0.0
def test_session_clear_unknown_returns_404(client):
resp = client.post("/api/agents/sessions/does-not-exist/clear")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Read-only metadata endpoints
# ---------------------------------------------------------------------------
def test_browser_agent_children_empty_for_fresh_session(client, stub_agent_loop):
session_id = _launch(client)
resp = client.get(f"/api/agents/sessions/{session_id}/browser-agents")
assert resp.status_code == 200
assert resp.json() == {"sessions": []}
def test_list_models_returns_envelope(client):
"""`GET /models` returns `{"models": <dict>, "notes": <list>}`. With
no API keys configured and 9Router down (the test environment), the
`models` dict can legitimately be empty — the contract is the
envelope, not the contents."""
resp = client.get("/api/agents/models")
assert resp.status_code == 200
body = resp.json()
assert isinstance(body.get("models"), dict)
assert isinstance(body.get("notes"), list)
# ---------------------------------------------------------------------------
# Validation-only: generate-title, generate-group-meta, approval
#
# These routes' happy paths fan out to Anthropic / WebSocket-resolved
# events; we deliberately stop at "the route rejects bad input" so the
# tests stay hermetic. End-to-end coverage lives elsewhere.
# ---------------------------------------------------------------------------
def test_generate_title_requires_prompt(client, stub_agent_loop):
session_id = _launch(client)
resp = client.post(
f"/api/agents/sessions/{session_id}/generate-title",
json={"prompt": ""},
)
assert resp.status_code == 400
def test_generate_group_meta_requires_group_id_and_tool_calls(client, stub_agent_loop):
session_id = _launch(client)
missing_group = client.post(
f"/api/agents/sessions/{session_id}/generate-group-meta",
json={"tool_calls": [{"tool": "Bash"}]},
)
assert missing_group.status_code == 400
missing_calls = client.post(
f"/api/agents/sessions/{session_id}/generate-group-meta",
json={"group_id": "g1", "tool_calls": []},
)
assert missing_calls.status_code == 400
def test_approval_pydantic_validation(client):
"""`/approval` is the only agents route gated by a Pydantic model —
Pydantic returns 422 (not 400) on validation errors. We test all
three failure modes plus a well-formed body that just no-ops because
the request_id has no live waiter (handle_approval is best-effort)."""
empty = client.post("/api/agents/approval", json={})
assert empty.status_code == 422
missing_behavior = client.post(
"/api/agents/approval",
json={"request_id": "abc"},
)
assert missing_behavior.status_code == 422
bad_behavior = client.post(
"/api/agents/approval",
json={"request_id": "abc", "behavior": "maybe"},
)
assert bad_behavior.status_code == 422
well_formed = client.post(
"/api/agents/approval",
json={"request_id": "abc", "behavior": "deny"},
)
assert well_formed.status_code == 200
assert well_formed.json() == {"ok": True}
# ---------------------------------------------------------------------------
# Query parameters / launch field round-trip
# ---------------------------------------------------------------------------
def test_launch_round_trips_optional_fields(client, stub_agent_loop, tmp_path):
"""Launch with the full optional surface and assert each field
that's actually preserved comes back out via GET.
Note: `allowed_tools` is intentionally NOT round-tripped — the
launch path ignores `config.allowed_tools` and resolves the tool
set from the mode definition (`_resolve_mode`). The caller-supplied
list is dropped on the floor; what ends up on the session is the
mode's tool roster. See agent_manager.launch_agent for the source.
"""
target = str(tmp_path / "workdir")
import os as _os
_os.makedirs(target, exist_ok=True)
launch = client.post(
"/api/agents/launch",
json={
"name": "Full",
"model": "sonnet",
"mode": "agent",
"system_prompt": "be concise",
"target_directory": target,
"dashboard_id": "dash-test",
},
)
assert launch.status_code == 200, launch.text
session_id = launch.json()["session_id"]
body = client.get(f"/api/agents/sessions/{session_id}").json()
assert body["system_prompt"] == "be concise"
assert body["dashboard_id"] == "dash-test"
assert body["cwd"] == target
# Mode-resolved tools are non-empty for the default "agent" mode.
assert isinstance(body["allowed_tools"], list)
assert len(body["allowed_tools"]) > 0
def test_list_sessions_dashboard_filter(client, stub_agent_loop):
"""`?dashboard_id=` scopes the list to one dashboard. Sessions
without a dashboard never leak into a filtered list."""
no_dash_id = _launch(client, name="Loose")
in_dash_id = _launch(client, name="Pinned", dashboard_id="dash-A")
all_sessions = client.get("/api/agents/sessions").json()["sessions"]
assert {s["id"] for s in all_sessions} >= {no_dash_id, in_dash_id}
filtered = client.get(
"/api/agents/sessions",
params={"dashboard_id": "dash-A"},
).json()["sessions"]
filtered_ids = {s["id"] for s in filtered}
assert in_dash_id in filtered_ids
assert no_dash_id not in filtered_ids
def test_history_pagination_and_search(client, stub_agent_loop):
"""Close three sessions and exercise q / limit / offset.
The search index is built from `name + message content`; with no
messages, only `name` is indexable.
"""
ids = [
_launch(client, name=f"alpha-{i}") for i in range(3)
]
for sid in ids:
client.post(f"/api/agents/sessions/{sid}/close").raise_for_status()
# No filter, limit=2 → first page returns 2, has_more=True.
page1 = client.get(
"/api/agents/history",
params={"limit": 2, "offset": 0},
).json()
assert page1["total"] == 3
assert len(page1["sessions"]) == 2
assert page1["has_more"] is True
# Offset to the tail.
page2 = client.get(
"/api/agents/history",
params={"limit": 2, "offset": 2},
).json()
assert len(page2["sessions"]) == 1
assert page2["has_more"] is False
# Search restricts. "alpha" matches all three by name.
matched = client.get(
"/api/agents/history",
params={"q": "alpha"},
).json()
assert matched["total"] == 3
# An obviously-absent token returns nothing.
none = client.get(
"/api/agents/history",
params={"q": "zzzz-no-match-zzzz"},
).json()
assert none["total"] == 0
assert none["sessions"] == []
# ---------------------------------------------------------------------------
# Expanded PATCH coverage
# ---------------------------------------------------------------------------
def test_patch_session_updates_system_prompt(client, stub_agent_loop):
session_id = _launch(client)
resp = client.patch(
f"/api/agents/sessions/{session_id}",
json={"system_prompt": "you are a helpful otter"},
)
assert resp.status_code == 200
body = client.get(f"/api/agents/sessions/{session_id}").json()
assert body["system_prompt"] == "you are a helpful otter"
def test_patch_session_updates_thinking_level(client, stub_agent_loop):
"""`thinking_level` accepts an enum {off, low, medium, high, auto}.
Garbage values are silently ignored by `update_session` — assert
that round-trip behaviour."""
session_id = _launch(client)
accepted = client.patch(
f"/api/agents/sessions/{session_id}",
json={"thinking_level": "high"},
)
assert accepted.status_code == 200
assert client.get(f"/api/agents/sessions/{session_id}").json()["thinking_level"] == "high"
# Bogus enum value: the route still returns 200 (silently ignored)
# and the previous value is preserved.
rejected = client.patch(
f"/api/agents/sessions/{session_id}",
json={"thinking_level": "extreme"},
)
assert rejected.status_code == 200
assert client.get(f"/api/agents/sessions/{session_id}").json()["thinking_level"] == "high"
@@ -0,0 +1,229 @@
"""REST-surface tests for /api/agents/subscriptions.
These routes wrap 9Router (the bundled OAuth/proxy daemon). The
`backend.tests.conftest` already no-ops `nine_router.ensure_running` so
no node subprocess is spawned, but `nine_router.is_running()` still tries
a real `httpx.get` to localhost:20128 and we don't want any network
chatter from these tests. Two fixtures here:
- `nine_router_down` — pins is_running() to False.
- `nine_router_up` — pins is_running() to True and stubs the four
async helpers (get_providers, get_models,
start_oauth, poll_oauth, exchange_oauth) with
canned dicts.
Coverage:
- GET /subscriptions/status — both down + up shapes.
- GET /subscriptions/models — both down + up shapes.
- POST /subscriptions/connect — missing provider → 400; 503 when
9Router is unavailable; happy path
round-trips the device_code flow.
- POST /subscriptions/poll — missing provider / device_code → 400.
- POST /subscriptions/exchange — missing provider / code → 400.
- POST /subscriptions/disconnect — missing provider → 400.
Happy paths for poll / exchange / disconnect involve writing real
provider connection state via 9Router and are out of scope for the REST
surface tests; they're covered by the integration suite.
"""
from __future__ import annotations
import pytest
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def nine_router_down(monkeypatch):
"""Pin nine_router.is_running() to False.
Defends against the cached True branch in the real implementation
(see _IS_RUNNING_TTL in backend/apps/nine_router.py) leaking from
other test runs.
"""
import backend.apps.nine_router as _nr
monkeypatch.setattr(_nr, "is_running", lambda: False)
# ensure_running is also stubbed in conftest, but re-stubbing here
# makes the contract of this fixture self-evident.
async def _async_noop(*args, **kwargs):
return None
monkeypatch.setattr(_nr, "ensure_running", _async_noop)
return _nr
@pytest.fixture
def nine_router_up(monkeypatch):
"""Pretend 9Router is running and serve canned data.
All async helpers used by the subscriptions routes are replaced.
Tests can override individual helpers via `monkeypatch.setattr`
after this fixture runs.
"""
import backend.apps.nine_router as _nr
monkeypatch.setattr(_nr, "is_running", lambda: True)
async def _providers():
return [
{"provider": "claude", "id": "conn-1", "isActive": True},
]
async def _models():
return [{"id": "claude-sonnet-4", "owned_by": "anthropic"}]
async def _start_oauth(provider: str):
return {
"flow": "device_code",
"user_code": "ABCD-1234",
"verification_uri": "https://example.com/activate",
"device_code": "dev-code-xyz",
"code_verifier": "",
"extra_data": {},
}
async def _poll_oauth(provider, device_code, **_):
return {"success": True, "connection": {"provider": provider, "id": "conn-1"}}
async def _exchange_oauth(provider, code, redirect_uri, code_verifier, state=""):
return {"success": True, "connection": {"provider": provider, "id": "conn-1"}}
monkeypatch.setattr(_nr, "get_providers", _providers)
monkeypatch.setattr(_nr, "get_models", _models)
monkeypatch.setattr(_nr, "start_oauth", _start_oauth)
monkeypatch.setattr(_nr, "poll_oauth", _poll_oauth)
monkeypatch.setattr(_nr, "exchange_oauth", _exchange_oauth)
return _nr
# ---------------------------------------------------------------------------
# GET /subscriptions/status
# ---------------------------------------------------------------------------
def test_status_when_9router_down(client, nine_router_down):
resp = client.get("/api/agents/subscriptions/status")
assert resp.status_code == 200
assert resp.json() == {"running": False, "providers": [], "models": []}
def test_status_when_9router_up(client, nine_router_up):
"""When up, the route wraps providers in a `connections` envelope —
the OnboardingModal and Settings UI both read `data.providers.connections`.
Pin that shape so an accidental rename surfaces as a test failure."""
resp = client.get("/api/agents/subscriptions/status")
assert resp.status_code == 200
body = resp.json()
assert body["running"] is True
assert "connections" in body["providers"]
assert isinstance(body["providers"]["connections"], list)
assert isinstance(body["models"], list)
# ---------------------------------------------------------------------------
# GET /subscriptions/models
# ---------------------------------------------------------------------------
def test_models_when_9router_down(client, nine_router_down):
resp = client.get("/api/agents/subscriptions/models")
assert resp.status_code == 200
assert resp.json() == {"models": []}
def test_models_when_9router_up(client, nine_router_up):
resp = client.get("/api/agents/subscriptions/models")
assert resp.status_code == 200
body = resp.json()
assert isinstance(body["models"], list)
assert len(body["models"]) == 1
# ---------------------------------------------------------------------------
# POST /subscriptions/connect
# ---------------------------------------------------------------------------
def test_connect_requires_provider(client, nine_router_down):
resp = client.post("/api/agents/subscriptions/connect", json={})
assert resp.status_code == 400
def test_connect_returns_503_when_9router_unavailable(client, nine_router_down):
"""Provider given but 9Router can't be started — the route returns
503 with a Node-install hint. `nine_router_down` pins both
is_running() AND ensure_running() so the route's retry path
short-circuits."""
resp = client.post(
"/api/agents/subscriptions/connect",
json={"provider": "claude"},
)
assert resp.status_code == 503
def test_connect_round_trips_device_code(client, nine_router_up):
resp = client.post(
"/api/agents/subscriptions/connect",
json={"provider": "github"},
)
assert resp.status_code == 200
body = resp.json()
assert body["flow"] == "device_code"
assert body["user_code"] == "ABCD-1234"
assert body["device_code"] == "dev-code-xyz"
# ---------------------------------------------------------------------------
# POST /subscriptions/poll
# ---------------------------------------------------------------------------
def test_poll_requires_provider_and_device_code(client):
"""Both fields must be present. We test each missing-half separately
so a regression that only validates one of them is caught."""
no_provider = client.post(
"/api/agents/subscriptions/poll",
json={"device_code": "dev"},
)
assert no_provider.status_code == 400
no_device = client.post(
"/api/agents/subscriptions/poll",
json={"provider": "claude"},
)
assert no_device.status_code == 400
# ---------------------------------------------------------------------------
# POST /subscriptions/exchange
# ---------------------------------------------------------------------------
def test_exchange_requires_provider_and_code(client):
no_provider = client.post(
"/api/agents/subscriptions/exchange",
json={"code": "abc"},
)
assert no_provider.status_code == 400
no_code = client.post(
"/api/agents/subscriptions/exchange",
json={"provider": "claude"},
)
assert no_code.status_code == 400
# ---------------------------------------------------------------------------
# POST /subscriptions/disconnect
# ---------------------------------------------------------------------------
def test_disconnect_requires_provider(client):
resp = client.post("/api/agents/subscriptions/disconnect", json={})
assert resp.status_code == 400
+156
View File
@@ -0,0 +1,156 @@
"""CRUD smoke for /api/dashboards.
Dashboards are the spatial canvas units. Each holds a `DashboardLayout`
(positions for agent/view/browser cards + sticky notes). Sessions are
tagged with `dashboard_id` so deleting a dashboard cascades into its
sessions.
Tests:
- lifespan migration creates "Dashboard 1" on a fresh boot
- create / get / update (name + layout) / duplicate / delete round-trip
- delete cascades into SESSIONS_DIR (sessions tagged with the
dashboard id are removed from disk)
- seed-demo lays down a session JSON with two messages
"""
from __future__ import annotations
import json
import os
def test_lifespan_seeds_default_dashboard(client):
"""First boot with no existing dashboards creates 'Dashboard 1'."""
resp = client.get("/api/dashboards/list")
assert resp.status_code == 200
items = resp.json()["dashboards"]
assert len(items) >= 1
assert any(d["name"] == "Dashboard 1" for d in items)
def test_create_get_update_delete_dashboard(client):
create = client.post(
"/api/dashboards/create",
json={"name": "Project Alpha"},
)
assert create.status_code == 200
dashboard_id = create.json()["id"]
fetched = client.get(f"/api/dashboards/{dashboard_id}")
assert fetched.status_code == 200
assert fetched.json()["name"] == "Project Alpha"
# Renaming flips auto_named → False (per dashboards.py:275).
update = client.put(
f"/api/dashboards/{dashboard_id}",
json={"name": "Project Beta"},
)
assert update.status_code == 200
body = update.json()
assert body["name"] == "Project Beta"
assert body["auto_named"] is False
deleted = client.delete(f"/api/dashboards/{dashboard_id}")
assert deleted.status_code == 200
gone = client.get(f"/api/dashboards/{dashboard_id}")
assert gone.status_code == 404
def test_dashboard_layout_round_trip(client):
"""PUT layout with a sticky note and assert it survives a re-fetch."""
create = client.post("/api/dashboards/create", json={"name": "L"})
dashboard_id = create.json()["id"]
layout = {
"cards": {},
"view_cards": {},
"browser_cards": {},
"notes": {
"n1": {
"note_id": "n1",
"x": 100,
"y": 200,
"width": 240,
"height": 200,
"content": "test note",
"color": "yellow",
}
},
"expanded_session_ids": [],
}
resp = client.put(
f"/api/dashboards/{dashboard_id}",
json={"layout": layout},
)
assert resp.status_code == 200
refetched = client.get(f"/api/dashboards/{dashboard_id}")
assert refetched.json()["layout"]["notes"]["n1"]["content"] == "test note"
def test_duplicate_dashboard(client):
create = client.post("/api/dashboards/create", json={"name": "Source"})
dashboard_id = create.json()["id"]
dup = client.post(f"/api/dashboards/{dashboard_id}/duplicate")
assert dup.status_code == 200
body = dup.json()
assert body["id"] != dashboard_id
assert body["name"] == "Source (copy)"
def test_delete_dashboard_cascades_to_sessions(client):
"""Sessions tagged with dashboard_id must be removed on delete.
Drops a fake session file straight onto SESSIONS_DIR (no agent
spawn needed) and verifies the cascade in `delete_dashboard`.
"""
from backend.config.paths import SESSIONS_DIR
create = client.post("/api/dashboards/create", json={"name": "Cascade"})
dashboard_id = create.json()["id"]
os.makedirs(SESSIONS_DIR, exist_ok=True)
session_id = "fake-session-cascade"
session_path = os.path.join(SESSIONS_DIR, f"{session_id}.json")
with open(session_path, "w") as f:
json.dump(
{
"id": session_id,
"name": "junk",
"dashboard_id": dashboard_id,
"messages": [],
"branches": {},
"active_branch_id": "main",
"created_at": "2026-01-01T00:00:00",
},
f,
)
assert os.path.exists(session_path)
deleted = client.delete(f"/api/dashboards/{dashboard_id}")
assert deleted.status_code == 200
assert not os.path.exists(session_path), (
"session file tagged with deleted dashboard should be removed"
)
def test_seed_demo_creates_session(client):
from backend.config.paths import SESSIONS_DIR
create = client.post("/api/dashboards/create", json={"name": "Demo"})
dashboard_id = create.json()["id"]
resp = client.post(f"/api/dashboards/{dashboard_id}/seed-demo")
assert resp.status_code == 200
session_id = resp.json()["session_id"]
session_path = os.path.join(SESSIONS_DIR, f"{session_id}.json")
assert os.path.exists(session_path)
with open(session_path) as f:
data = json.load(f)
assert data["dashboard_id"] == dashboard_id
assert len(data["messages"]) == 2 # canned welcome conversation
+35
View File
@@ -0,0 +1,35 @@
"""Health endpoint contract.
The Electron main process polls /api/health/check before it has the
auth token (the HTTP port is up before main.js calls loadAuthToken()).
Two invariants this test pins down:
1. The path is auth-exempt — Electron's pre-token poll succeeds.
2. The response is plain-text "OK" — required by AWS ALB health
checks and (more practically) Electron's boot handshake which
reads the body literally instead of parsing JSON.
"""
from __future__ import annotations
def test_health_check_returns_plain_ok(client):
resp = client.get("/api/health/check")
assert resp.status_code == 200
assert resp.text == "OK"
assert resp.headers["content-type"].startswith("text/plain")
def test_health_check_does_not_require_auth(app, tmp_data_dirs):
"""Hit /api/health/check WITHOUT the bearer header.
Uses a fresh TestClient (the shared `client` fixture pre-injects
auth headers, which would mask a regression in the exempt-list).
"""
from fastapi.testclient import TestClient
with TestClient(app) as tc:
resp = tc.get("/api/health/check")
assert resp.status_code == 200
assert resp.text == "OK"
+105
View File
@@ -0,0 +1,105 @@
"""CRUD smoke for /api/modes.
Modes are the per-agent system-prompt + tool-allowlist presets. The
five built-ins (`agent`, `ask`, `plan`, `view-builder`, `skill-builder`)
are seeded from `BUILTIN_MODES` on lifespan start; user-defined modes
live alongside them on disk under `MODES_DIR`.
Tests:
- list returns the seeded built-ins
- create/get/update/delete round-trip a custom mode
- reset on a built-in restores defaults
- delete on a built-in returns 403 (per the explicit guard in modes.py)
"""
from __future__ import annotations
BUILTIN_IDS = {"agent", "ask", "plan", "view-builder", "skill-builder"}
def test_list_returns_builtin_modes(client):
resp = client.get("/api/modes/list")
assert resp.status_code == 200
payload = resp.json()
assert "modes" in payload and "builtin_defaults" in payload
seen_ids = {m["id"] for m in payload["modes"]}
assert BUILTIN_IDS.issubset(seen_ids), (
f"expected built-ins {BUILTIN_IDS}, saw {seen_ids}"
)
# builtin_defaults is the source of truth the frontend uses to
# render "Reset to defaults" — must match exactly.
assert set(payload["builtin_defaults"].keys()) == BUILTIN_IDS
def test_get_specific_builtin_mode(client):
resp = client.get("/api/modes/agent")
assert resp.status_code == 200
body = resp.json()
assert body["id"] == "agent"
assert body["is_builtin"] is True
def test_create_update_delete_custom_mode(client):
create = client.post(
"/api/modes/create",
json={
"name": "Test Mode",
"description": "smoke",
"system_prompt": "You are a test.",
"tools": ["Read", "Grep"],
},
)
assert create.status_code == 200, create.text
mode_id = create.json()["mode"]["id"]
assert mode_id not in BUILTIN_IDS
fetched = client.get(f"/api/modes/{mode_id}")
assert fetched.status_code == 200
assert fetched.json()["name"] == "Test Mode"
assert fetched.json()["is_builtin"] is False
update = client.put(
f"/api/modes/{mode_id}",
json={"description": "smoke v2"},
)
assert update.status_code == 200
assert update.json()["mode"]["description"] == "smoke v2"
assert update.json()["mode"]["name"] == "Test Mode" # unchanged
deleted = client.delete(f"/api/modes/{mode_id}")
assert deleted.status_code == 200
gone = client.get(f"/api/modes/{mode_id}")
assert gone.status_code == 404
def test_cannot_delete_builtin_mode(client):
"""Built-ins are sticky — a stray UI delete must not nuke them."""
resp = client.delete("/api/modes/agent")
assert resp.status_code == 403
def test_reset_builtin_mode_restores_defaults(client):
"""Mutate `agent` then reset; system_prompt should snap back."""
update = client.put(
"/api/modes/agent",
json={"system_prompt": "MUTATED FOR TEST"},
)
assert update.status_code == 200
assert update.json()["mode"]["system_prompt"] == "MUTATED FOR TEST"
reset = client.post("/api/modes/agent/reset")
assert reset.status_code == 200
assert reset.json()["mode"]["system_prompt"] != "MUTATED FOR TEST"
def test_reset_only_works_on_builtins(client):
"""Custom (non-builtin) modes can't be reset — there's no default."""
create = client.post("/api/modes/create", json={"name": "X"})
mode_id = create.json()["mode"]["id"]
resp = client.post(f"/api/modes/{mode_id}/reset")
assert resp.status_code == 400
+91
View File
@@ -0,0 +1,91 @@
"""Smoke tests for /api/settings.
Settings are the single AppSettings document persisted at
`<DATA_ROOT>/settings/settings.json`. The PUT handler also fans out to
9Router sync (mocked in conftest) and PostHog analytics, neither of
which we exercise here.
Tests:
- GET /api/settings returns defaults including the canned system prompt
- PUT /api/settings round-trips a value and survives `load_settings()` reload
- default-system-prompt + reset-system-prompt
- browse-directories on a tmp dir
"""
from __future__ import annotations
import os
def test_get_returns_defaults(client):
resp = client.get("/api/settings")
assert resp.status_code == 200
body = resp.json()
assert body["default_model"] == "sonnet"
assert body["default_mode"] == "agent"
assert body["default_system_prompt"] # populated from DEFAULT_SYSTEM_PROMPT
def test_put_round_trips_value(client):
"""PUT a sentinel theme; GET must echo it back, AND the on-disk
record must reflect it (catches accidental write-only-in-memory
regressions in `save_settings_async`)."""
current = client.get("/api/settings").json()
current["theme"] = "light"
put = client.put("/api/settings", json=current)
assert put.status_code == 200
assert put.json()["settings"]["theme"] == "light"
refetched = client.get("/api/settings").json()
assert refetched["theme"] == "light"
# And confirm the persistence layer (not just the in-memory cache).
from backend.apps.settings.settings import load_settings
assert load_settings().theme == "light"
def test_default_system_prompt_returns_constant(client):
from backend.apps.settings.models import DEFAULT_SYSTEM_PROMPT
resp = client.get("/api/settings/default-system-prompt")
assert resp.status_code == 200
assert resp.json()["default_system_prompt"] == DEFAULT_SYSTEM_PROMPT
def test_reset_system_prompt(client):
"""Mutate then reset; the prompt must snap back to DEFAULT_SYSTEM_PROMPT."""
from backend.apps.settings.models import DEFAULT_SYSTEM_PROMPT
current = client.get("/api/settings").json()
current["default_system_prompt"] = "MUTATED"
client.put("/api/settings", json=current)
resp = client.post("/api/settings/reset-system-prompt")
assert resp.status_code == 200
assert resp.json()["settings"]["default_system_prompt"] == DEFAULT_SYSTEM_PROMPT
def test_browse_directories_lists_tmp(client, tmp_path):
"""Drop a known file/dir into pytest's tmp_path and assert it shows up."""
(tmp_path / "subdir").mkdir()
(tmp_path / "hello.txt").write_text("hi")
resp = client.get(
"/api/settings/browse-directories",
params={"path": str(tmp_path)},
)
assert resp.status_code == 200
body = resp.json()
assert body["current"] == os.path.abspath(str(tmp_path))
assert "subdir" in body["directories"]
assert "hello.txt" in body["files"]
def test_browse_directories_404_on_missing_path(client, tmp_path):
resp = client.get(
"/api/settings/browse-directories",
params={"path": str(tmp_path / "does-not-exist")},
)
assert resp.status_code == 404
+87
View File
@@ -0,0 +1,87 @@
"""Smoke tests for /api/skills.
Skills are SKILL.md files synced into `~/.claude/skills`. Tests MUST
NEVER touch the user's real skills dir, so this whole module relies on
the `patched_skills_dir` fixture.
Tests:
- empty list on a fresh dir
- dropping a SKILL.md into the patched dir surfaces in /list
- create / get / update / delete round-trip
- workspace/seed writes SKILL.md and meta.json
"""
from __future__ import annotations
import json
import os
def test_list_empty_on_fresh_dir(client, patched_skills_dir):
resp = client.get("/api/skills/list")
assert resp.status_code == 200
assert resp.json() == {"skills": []}
def test_list_picks_up_dropped_skill(client, patched_skills_dir):
"""Filesystem-driven discovery — dropping a .md file is enough."""
skill_path = os.path.join(patched_skills_dir, "test-skill.md")
with open(skill_path, "w") as f:
f.write("# Test Skill\n\nBody content.\n")
resp = client.get("/api/skills/list")
assert resp.status_code == 200
skills = resp.json()["skills"]
assert any(s["id"] == "test-skill" for s in skills)
def test_create_get_update_delete_skill(client, patched_skills_dir):
create = client.post(
"/api/skills/create",
json={
"name": "Smoke",
"description": "smoke skill",
"content": "---\nname: smoke\ndescription: a smoke skill\n---\n\nBody.",
},
)
assert create.status_code == 200, create.text
skill_id = create.json()["skill"]["id"]
fetched = client.get(f"/api/skills/{skill_id}")
assert fetched.status_code == 200
assert fetched.json()["name"] == "Smoke"
update = client.put(
f"/api/skills/{skill_id}",
json={"description": "smoke v2"},
)
assert update.status_code == 200
assert update.json()["skill"]["description"] == "smoke v2"
deleted = client.delete(f"/api/skills/{skill_id}")
assert deleted.status_code == 200
assert not os.path.exists(os.path.join(patched_skills_dir, f"{skill_id}.md"))
def test_workspace_seed_writes_files(client, patched_skills_dir):
"""seed_skill_workspace writes SKILL.md + meta.json under
SKILLS_WORKSPACE_DIR (patched at import time via OPENSWARM_DATA_DIR)."""
from backend.config.paths import SKILLS_WORKSPACE_DIR
workspace_id = "test-workspace"
resp = client.post(
"/api/skills/workspace/seed",
json={
"workspace_id": workspace_id,
"skill_content": "---\nname: x\n---\nbody",
"meta": {"name": "X", "description": "y"},
},
)
assert resp.status_code == 200
folder = os.path.join(SKILLS_WORKSPACE_DIR, workspace_id)
assert os.path.isfile(os.path.join(folder, "SKILL.md"))
with open(os.path.join(folder, "meta.json")) as f:
meta = json.load(f)
assert meta["name"] == "X"
+106
View File
@@ -0,0 +1,106 @@
"""Smoke tests for /api/tools.
Tools are MCP server definitions (stdio/HTTP/SSE) plus their connection
state. Built-ins (Read/Edit/Write/etc.) are defined statically in
`BUILTIN_TOOLS`; user-installed MCPs are persisted as JSON under
TOOLS_DIR.
OAuth flows + MCP discovery require external services and are out of
scope for this pass — see plan.
Tests:
- GET /api/tools/builtin returns the static built-in list
- GET /api/tools/list returns user-installed defs (empty by default)
- GET/PUT /api/tools/builtin/permissions round-trips
- create / get / update / delete CRUD
"""
from __future__ import annotations
def test_list_builtin_tools(client):
"""The static BUILTIN_TOOLS list — must include the always-loaded core."""
resp = client.get("/api/tools/builtin")
assert resp.status_code == 200
names = {t["name"] for t in resp.json()["tools"]}
# Sanity check on the core tools every agent gets by default.
assert {"Read", "Edit", "Write", "Bash", "Grep", "Glob"}.issubset(names)
def test_list_user_tools_empty_by_default(client):
resp = client.get("/api/tools/list")
assert resp.status_code == 200
assert resp.json() == {"tools": []}
def test_create_get_update_delete_tool(client):
create = client.post(
"/api/tools/create",
json={
"name": "TestMCP",
"description": "smoke MCP server",
"command": "echo hello",
"mcp_config": {
"type": "stdio",
"command": "echo",
"args": ["hello"],
},
"auth_type": "none",
"auth_status": "none",
},
)
assert create.status_code == 200, create.text
tool_id = create.json()["tool"]["id"]
assert tool_id
fetched = client.get(f"/api/tools/{tool_id}")
assert fetched.status_code == 200
assert fetched.json()["name"] == "TestMCP"
update = client.put(
f"/api/tools/{tool_id}",
json={"description": "updated"},
)
assert update.status_code == 200
assert update.json()["tool"]["description"] == "updated"
assert update.json()["tool"]["name"] == "TestMCP" # unchanged
listed = client.get("/api/tools/list").json()["tools"]
assert any(t["id"] == tool_id for t in listed)
deleted = client.delete(f"/api/tools/{tool_id}")
assert deleted.status_code == 200
assert all(t["id"] != tool_id for t in client.get("/api/tools/list").json()["tools"])
def test_builtin_permissions_round_trip(client):
"""The user can override per-tool default permissions for built-ins.
The PUT handler is allowlist-gated: only known tool names + valid
policies (`always_allow`/`ask`/`deny`) survive the round-trip. The
body shape is `{"permissions": {...}}` per `update_builtin_permissions`.
"""
update = client.put(
"/api/tools/builtin/permissions",
json={"permissions": {"Bash": "ask"}},
)
assert update.status_code == 200
refetched = client.get("/api/tools/builtin/permissions").json()["permissions"]
assert refetched["Bash"] == "ask"
def test_builtin_permissions_rejects_unknown_tool_or_policy(client):
"""Garbage input is silently dropped, not 4xx'd. Verify it doesn't
poison the persisted map."""
resp = client.put(
"/api/tools/builtin/permissions",
json={"permissions": {
"BogusTool": "ask", # unknown tool name
"Bash": "shrug", # unknown policy
}},
)
assert resp.status_code == 200
perms = resp.json()["permissions"]
assert "BogusTool" not in perms
assert perms.get("Bash") != "shrug"
+59
View File
@@ -0,0 +1,59 @@
"""App-level smoke test.
Goal: catch the failure mode where adding a new sub-app silently breaks
imports, or a refactor accidentally drops a router from the
`MainApp([...])` registration list in `backend/main.py`. Hits no business
logic — just verifies the FastAPI app boots and its OpenAPI surface
contains the routes we expect every release to ship.
"""
from __future__ import annotations
def test_app_module_imports():
"""Importing backend.main must not raise.
A regression in any sub-app module would surface here as an
ImportError during collection. Cheap canary worth keeping
independent of the other tests.
"""
from backend.main import app
assert app is not None
def test_openapi_lists_expected_routes(client):
"""Each long-lived sub-app's flagship route must appear in /openapi.json.
These are intentionally one-per-router so adding/removing an
individual endpoint doesn't churn the test, but accidentally
dropping an entire sub-app from `MainApp([...])` breaks loudly.
"""
resp = client.get("/openapi.json")
assert resp.status_code == 200
paths = set(resp.json()["paths"].keys())
expected = {
"/api/health/check",
"/api/modes/list",
"/api/dashboards/list",
"/api/agents/sessions",
"/api/settings",
"/api/tools/list",
"/api/skills/list",
"/api/outputs/list",
}
missing = expected - paths
assert not missing, f"Routes missing from /openapi.json: {missing}"
def test_app_has_cors_and_auth_middleware(app):
"""Defensive: regression-guard the security posture of the app.
The CORS + auth middlewares are added unconditionally in main.py.
If a refactor ever drops one, this test catches it before it ships.
"""
middleware_names = [m.cls.__name__ for m in app.user_middleware]
assert any("CORS" in n for n in middleware_names), (
f"CORSMiddleware missing from app — saw {middleware_names}"
)
+9
View File
@@ -0,0 +1,9 @@
[pytest]
# Pytest invoked from project root (see scripts/test.sh and the GitHub
# Actions workflow). Keeping the ini here makes that the rootdir so
# `from backend.apps...` imports resolve without sys.path tweaks.
testpaths = backend/tests
asyncio_mode = auto
filterwarnings =
ignore::DeprecationWarning
ignore::PendingDeprecationWarning