diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..4cea8fce --- /dev/null +++ b/backend/tests/conftest.py @@ -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) diff --git a/backend/tests/test_api_agents.py b/backend/tests/test_api_agents.py new file mode 100644 index 00000000..87e04751 --- /dev/null +++ b/backend/tests/test_api_agents.py @@ -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": , "notes": }`. 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" diff --git a/backend/tests/test_api_agents_subscriptions.py b/backend/tests/test_api_agents_subscriptions.py new file mode 100644 index 00000000..cf42e037 --- /dev/null +++ b/backend/tests/test_api_agents_subscriptions.py @@ -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 diff --git a/backend/tests/test_api_dashboards.py b/backend/tests/test_api_dashboards.py new file mode 100644 index 00000000..07888783 --- /dev/null +++ b/backend/tests/test_api_dashboards.py @@ -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 diff --git a/backend/tests/test_api_health.py b/backend/tests/test_api_health.py new file mode 100644 index 00000000..d64968ff --- /dev/null +++ b/backend/tests/test_api_health.py @@ -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" diff --git a/backend/tests/test_api_modes.py b/backend/tests/test_api_modes.py new file mode 100644 index 00000000..55c4edf9 --- /dev/null +++ b/backend/tests/test_api_modes.py @@ -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 diff --git a/backend/tests/test_api_settings.py b/backend/tests/test_api_settings.py new file mode 100644 index 00000000..c9f39f6b --- /dev/null +++ b/backend/tests/test_api_settings.py @@ -0,0 +1,91 @@ +"""Smoke tests for /api/settings. + +Settings are the single AppSettings document persisted at +`/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 diff --git a/backend/tests/test_api_skills.py b/backend/tests/test_api_skills.py new file mode 100644 index 00000000..9fa98b6d --- /dev/null +++ b/backend/tests/test_api_skills.py @@ -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" diff --git a/backend/tests/test_api_tools.py b/backend/tests/test_api_tools.py new file mode 100644 index 00000000..9162176b --- /dev/null +++ b/backend/tests/test_api_tools.py @@ -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" diff --git a/backend/tests/test_app_smoke.py b/backend/tests/test_app_smoke.py new file mode 100644 index 00000000..6223e9a0 --- /dev/null +++ b/backend/tests/test_app_smoke.py @@ -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}" + ) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..0019934a --- /dev/null +++ b/pytest.ini @@ -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