627 lines
21 KiB
Python
627 lines
21 KiB
Python
"""
|
|
Integration tests for ``GET /v1/hosts/{id}/filesystem`` and
|
|
``GET /v1/hosts/{id}/filesystem/{path}``.
|
|
|
|
Wires up a real host tunnel + REST router pair, drives a fake host
|
|
that auto-replies to ``host.list_dir`` frames, and exercises the
|
|
endpoint's contract end-to-end. The REST shape mirrors the existing
|
|
session-scoped filesystem endpoint
|
|
(``GET /v1/sessions/{id}/resources/environments/default/filesystem``)
|
|
so the Web UI's existing tree component can be reused with a different
|
|
URL prefix.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import AsyncIterator
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from asgiref.testing import ApplicationCommunicator
|
|
from fastapi import FastAPI
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from omnigent.host.frames import (
|
|
HostHelloFrame,
|
|
HostListDirFrame,
|
|
HostListDirResultFrame,
|
|
decode_host_frame,
|
|
encode_host_frame,
|
|
)
|
|
from omnigent.server.host_registry import HostRegistry
|
|
from omnigent.server.routes.host_tunnel import create_host_tunnel_router
|
|
from omnigent.server.routes.hosts import create_hosts_router
|
|
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
|
SqlAlchemyConversationStore,
|
|
)
|
|
from omnigent.stores.host_store import HostStore
|
|
|
|
# Interim: any test using the ``fs_setup`` mock host tunnel can flake
|
|
# with a 409 "host is offline" under parallel CI load (mock-WS starved
|
|
# and deregistered). Module-wide because a worksteal change spread the
|
|
# flake beyond ``root_forwards_tilde``. Tests are
|
|
# sub-second; remove once the liveness race is fixed.
|
|
pytestmark = [
|
|
pytest.mark.asyncio,
|
|
pytest.mark.flaky(reruns=2, reruns_delay=1),
|
|
]
|
|
|
|
_HOST_ID = "host_fs_test"
|
|
_HOST_NAME = "fs-test-laptop"
|
|
|
|
|
|
def _websocket_scope(path: str) -> dict[str, object]:
|
|
"""Build a minimal ASGI WebSocket scope.
|
|
|
|
:param path: WebSocket path, e.g. ``"/v1/hosts/X/tunnel"``.
|
|
:returns: ASGI scope dict.
|
|
"""
|
|
return {
|
|
"type": "websocket",
|
|
"asgi": {"version": "3.0"},
|
|
"scheme": "ws",
|
|
"path": path,
|
|
"raw_path": path.encode("ascii"),
|
|
"query_string": b"",
|
|
"headers": [],
|
|
"client": ("127.0.0.1", 50000),
|
|
"server": ("testserver", 80),
|
|
"subprotocols": [],
|
|
}
|
|
|
|
|
|
def _hello_text(name: str = _HOST_NAME) -> str:
|
|
"""Encode a hello frame for tests.
|
|
|
|
:param name: Host name reported in the hello frame.
|
|
:returns: JSON-encoded hello frame.
|
|
"""
|
|
return encode_host_frame(
|
|
HostHelloFrame(
|
|
version="0.1.0-test",
|
|
frame_protocol_version=1,
|
|
name=name,
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def fs_app(
|
|
db_uri: str,
|
|
) -> tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore]:
|
|
"""
|
|
App with host tunnel + REST routes for filesystem-browse tests.
|
|
|
|
:param db_uri: SQLite URI fixture.
|
|
:returns: (app, registry, host_store, conv_store).
|
|
"""
|
|
registry = HostRegistry()
|
|
host_store = HostStore(db_uri)
|
|
conv_store = SqlAlchemyConversationStore(db_uri)
|
|
app = FastAPI()
|
|
app.include_router(
|
|
create_host_tunnel_router(registry, host_store),
|
|
prefix="/v1",
|
|
)
|
|
app.include_router(
|
|
create_hosts_router(registry, host_store, conv_store),
|
|
prefix="/v1",
|
|
)
|
|
return app, registry, host_store, conv_store
|
|
|
|
|
|
@pytest.fixture()
|
|
async def fs_setup(
|
|
fs_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore],
|
|
) -> AsyncIterator[
|
|
tuple[
|
|
FastAPI,
|
|
HostRegistry,
|
|
ApplicationCommunicator,
|
|
dict[str, dict[str, Any]],
|
|
asyncio.Task[None],
|
|
]
|
|
]:
|
|
"""
|
|
Connect a mock host and start an auto-replier for list_dir frames.
|
|
|
|
Tests register fake replies in ``replies`` (path → reply dict)
|
|
before calling the REST endpoint. The auto-replier consumes
|
|
frames the route layer pushes through the registry, decodes
|
|
them, and resolves the matching pending future — mirroring
|
|
what host_tunnel.py does in production.
|
|
|
|
:param fs_app: The fixture above.
|
|
:returns: Async iterator yielding the wired-up state.
|
|
"""
|
|
app, registry, _hs, _cs = fs_app
|
|
path = f"/v1/hosts/{_HOST_ID}/tunnel"
|
|
comm = ApplicationCommunicator(app, _websocket_scope(path))
|
|
await comm.send_input({"type": "websocket.connect"})
|
|
accepted = await comm.receive_output(timeout=1.0)
|
|
assert accepted["type"] == "websocket.accept"
|
|
await comm.send_input({"type": "websocket.receive", "text": _hello_text()})
|
|
while registry.get(_HOST_ID) is None:
|
|
await asyncio.sleep(0.01)
|
|
|
|
conn = registry.get(_HOST_ID)
|
|
assert conn is not None
|
|
replies: dict[str, dict[str, Any]] = {}
|
|
stop_drain = asyncio.Event()
|
|
|
|
async def _drain() -> None:
|
|
"""Drain outbound WS frames from the communicator and reply.
|
|
|
|
The route enqueues frames on ``conn.outbound_queue``; the
|
|
host tunnel's ``_sender_loop`` then forwards them to the
|
|
WebSocket as ``websocket.send`` events. We pop those
|
|
events from the communicator's output queue, decode each
|
|
list_dir frame, and feed the configured reply back as a
|
|
``websocket.receive`` event — which the route's receive
|
|
loop turns into a resolved future.
|
|
|
|
:returns: None when ``stop_drain`` is set or no events
|
|
arrive within the per-iteration timeout.
|
|
"""
|
|
while not stop_drain.is_set():
|
|
try:
|
|
output = await comm.receive_output(timeout=0.5)
|
|
except asyncio.TimeoutError:
|
|
continue
|
|
if output.get("type") != "websocket.send":
|
|
continue
|
|
text = output.get("text")
|
|
if not isinstance(text, str):
|
|
continue
|
|
frame = decode_host_frame(text)
|
|
if not isinstance(frame, HostListDirFrame):
|
|
continue
|
|
reply = replies.get(frame.path)
|
|
if reply is None:
|
|
# Default reply: path missing. Tests must register
|
|
# a reply for every path they expect to be queried.
|
|
reply_frame = HostListDirResultFrame(
|
|
request_id=frame.request_id,
|
|
status="ok",
|
|
error="path does not exist",
|
|
)
|
|
else:
|
|
reply_frame = HostListDirResultFrame(
|
|
request_id=frame.request_id,
|
|
status=reply.get("status", "ok"),
|
|
entries=reply.get("entries", []),
|
|
has_more=reply.get("has_more", False),
|
|
error=reply.get("error"),
|
|
)
|
|
await comm.send_input(
|
|
{
|
|
"type": "websocket.receive",
|
|
"text": encode_host_frame(reply_frame),
|
|
}
|
|
)
|
|
|
|
drain_task = asyncio.create_task(_drain())
|
|
try:
|
|
yield app, registry, comm, replies, drain_task
|
|
finally:
|
|
stop_drain.set()
|
|
try:
|
|
await asyncio.wait_for(drain_task, timeout=1.0)
|
|
except asyncio.TimeoutError:
|
|
drain_task.cancel()
|
|
|
|
|
|
# ── Happy path ──────────────────────────────────────────
|
|
|
|
|
|
async def test_list_filesystem_returns_paginated_entries(
|
|
fs_setup: tuple[
|
|
FastAPI,
|
|
HostRegistry,
|
|
ApplicationCommunicator,
|
|
dict[str, dict[str, Any]],
|
|
asyncio.Task[None],
|
|
],
|
|
) -> None:
|
|
"""
|
|
Verify the endpoint returns the runner-compatible response shape:
|
|
``{"object": "list", "data": [...], "has_more": bool}``.
|
|
|
|
Without this match, the Web UI's existing
|
|
``fetchWorkspaceDirectory`` hook would fail to parse the
|
|
response (different field names) and the picker would render
|
|
no entries.
|
|
"""
|
|
from omnigent.host.frames import HostListDirEntry
|
|
|
|
app, _reg, _comm, replies, _drain = fs_setup
|
|
replies["/Users/corey/projects"] = {
|
|
"entries": [
|
|
HostListDirEntry(
|
|
name="src",
|
|
path="/Users/corey/projects/src",
|
|
type="directory",
|
|
bytes=None,
|
|
modified_at=1779980000,
|
|
),
|
|
HostListDirEntry(
|
|
name="README.md",
|
|
path="/Users/corey/projects/README.md",
|
|
type="file",
|
|
bytes=42,
|
|
modified_at=1779980100,
|
|
),
|
|
],
|
|
"has_more": False,
|
|
}
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.get(f"/v1/hosts/{_HOST_ID}/filesystem/Users/corey/projects")
|
|
assert resp.status_code == 200, resp.text
|
|
payload = resp.json()
|
|
assert payload["object"] == "list"
|
|
assert payload["has_more"] is False
|
|
names = [entry["name"] for entry in payload["data"]]
|
|
assert names == ["src", "README.md"]
|
|
# Type field present and correct so the Web UI can pick the
|
|
# right icon.
|
|
types = [entry["type"] for entry in payload["data"]]
|
|
assert types == ["directory", "file"]
|
|
|
|
|
|
async def test_list_filesystem_root_forwards_tilde(
|
|
fs_setup: tuple[
|
|
FastAPI,
|
|
HostRegistry,
|
|
ApplicationCommunicator,
|
|
dict[str, dict[str, Any]],
|
|
asyncio.Task[None],
|
|
],
|
|
) -> None:
|
|
"""
|
|
Verify that the empty-path endpoint forwards ``~`` to the host.
|
|
|
|
Per ``designs/SESSION_WORKSPACE_SELECTION.md``: the host is the
|
|
source of truth for ``~``; the server passes tildes through
|
|
unchanged. The fixture's reply table is keyed by the path the
|
|
server sent, so a ``~`` key matching a successful response
|
|
proves the forward.
|
|
"""
|
|
from omnigent.host.frames import HostListDirEntry
|
|
|
|
app, _reg, _comm, replies, _drain = fs_setup
|
|
replies["~"] = {
|
|
"entries": [
|
|
HostListDirEntry(
|
|
name="projects",
|
|
path="/Users/corey/projects",
|
|
type="directory",
|
|
bytes=None,
|
|
modified_at=1779980200,
|
|
),
|
|
],
|
|
}
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
# No trailing path → server forwards ~.
|
|
resp = await client.get(f"/v1/hosts/{_HOST_ID}/filesystem")
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["data"][0]["name"] == "projects"
|
|
|
|
|
|
async def test_list_filesystem_tilde_path_forwards_unchanged(
|
|
fs_setup: tuple[
|
|
FastAPI,
|
|
HostRegistry,
|
|
ApplicationCommunicator,
|
|
dict[str, dict[str, Any]],
|
|
asyncio.Task[None],
|
|
],
|
|
) -> None:
|
|
"""
|
|
Verify that ``~/projects`` in the URL reaches the host as
|
|
``~/projects`` (not URL-decoded into something else, not
|
|
server-expanded).
|
|
|
|
Pairs with the previous test to fully pin the tilde-forwarding
|
|
contract — covering both the empty-path-defaults-to-~ case
|
|
and the explicit-tilde-in-path case.
|
|
"""
|
|
from omnigent.host.frames import HostListDirEntry
|
|
|
|
app, _reg, _comm, replies, _drain = fs_setup
|
|
replies["~/projects"] = {
|
|
"entries": [
|
|
HostListDirEntry(
|
|
name="myapp",
|
|
path="/Users/corey/projects/myapp",
|
|
type="directory",
|
|
bytes=None,
|
|
modified_at=1779980300,
|
|
),
|
|
],
|
|
}
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.get(f"/v1/hosts/{_HOST_ID}/filesystem/~/projects")
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["data"][0]["name"] == "myapp"
|
|
|
|
|
|
# ── Error paths ─────────────────────────────────────────
|
|
|
|
|
|
async def test_list_filesystem_unknown_host_returns_404(
|
|
fs_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore],
|
|
) -> None:
|
|
"""
|
|
Verify a request for a host that doesn't exist returns 404.
|
|
|
|
The route must look the host up in the host_store first; an
|
|
unauthenticated probe for a non-existent host shouldn't reveal
|
|
other hosts' existence either.
|
|
"""
|
|
app, _reg, _hs, _cs = fs_app
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.get("/v1/hosts/host_does_not_exist/filesystem")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
async def test_list_filesystem_offline_host_returns_409(
|
|
fs_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore],
|
|
) -> None:
|
|
"""
|
|
Verify a request for a host whose tunnel is closed returns 409.
|
|
|
|
The host record exists in the DB but is not in the registry
|
|
— list_dir requires a live tunnel so the only sensible
|
|
response is "host is offline" (409 Conflict, mirroring the
|
|
launch endpoint).
|
|
"""
|
|
app, _reg, host_store, _cs = fs_app
|
|
# Persist the host record but never register a tunnel.
|
|
host_store.upsert_on_connect(
|
|
host_id="host_offline",
|
|
name="offline-host",
|
|
owner="local",
|
|
)
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.get("/v1/hosts/host_offline/filesystem")
|
|
assert resp.status_code == 409
|
|
|
|
|
|
async def test_list_filesystem_missing_path_returns_404(
|
|
fs_setup: tuple[
|
|
FastAPI,
|
|
HostRegistry,
|
|
ApplicationCommunicator,
|
|
dict[str, dict[str, Any]],
|
|
asyncio.Task[None],
|
|
],
|
|
) -> None:
|
|
"""
|
|
Verify that browsing a non-existent path on the host returns 404.
|
|
|
|
The host returns ``status: "ok", error: "path does not exist"``;
|
|
the route must distinguish that from a successful empty
|
|
listing and surface it as 404 so the Web UI can render a
|
|
"not found" state.
|
|
"""
|
|
app, _reg, _comm, _replies, _drain = fs_setup
|
|
# No reply registered for this path → fixture returns the
|
|
# default "path does not exist" reply.
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.get(f"/v1/hosts/{_HOST_ID}/filesystem/Users/corey/missing")
|
|
assert resp.status_code == 404
|
|
assert "path does not exist" in resp.text
|
|
|
|
|
|
async def test_list_filesystem_host_io_failure_returns_502(
|
|
fs_setup: tuple[
|
|
FastAPI,
|
|
HostRegistry,
|
|
ApplicationCommunicator,
|
|
dict[str, dict[str, Any]],
|
|
asyncio.Task[None],
|
|
],
|
|
) -> None:
|
|
"""
|
|
Verify ``status: "failed"`` from the host surfaces as 502.
|
|
|
|
Distinguishes legitimate "missing path" (404) from
|
|
server-side I/O errors (502 Bad Gateway). Without this, the
|
|
Web UI couldn't tell whether to retry or surface a permanent
|
|
error.
|
|
"""
|
|
app, _reg, _comm, replies, _drain = fs_setup
|
|
replies["/Users/corey/io_fail"] = {
|
|
"status": "failed",
|
|
"error": "scandir failed: I/O error",
|
|
}
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.get(f"/v1/hosts/{_HOST_ID}/filesystem/Users/corey/io_fail")
|
|
assert resp.status_code == 502
|
|
|
|
|
|
async def test_list_filesystem_nul_byte_in_path_returns_400(
|
|
fs_setup: tuple[
|
|
FastAPI,
|
|
HostRegistry,
|
|
ApplicationCommunicator,
|
|
dict[str, dict[str, Any]],
|
|
asyncio.Task[None],
|
|
],
|
|
) -> None:
|
|
"""
|
|
Verify NUL byte in path is rejected with 400 before reaching
|
|
the host.
|
|
|
|
A NUL byte would never make it through ``os.scandir`` cleanly
|
|
on the host, but rejecting it at the route layer is cheaper
|
|
and surfaces a clearer error than a generic OSError.
|
|
"""
|
|
app, _reg, _comm, _replies, _drain = fs_setup
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.get(f"/v1/hosts/{_HOST_ID}/filesystem/foo%00bar")
|
|
assert resp.status_code == 400
|
|
|
|
|
|
# ── Auth / multi-user ───────────────────────────────────
|
|
|
|
|
|
async def test_list_filesystem_owner_check_blocks_other_users(
|
|
fs_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore],
|
|
) -> None:
|
|
"""
|
|
Verify the owner check returns 403 when an authenticated caller
|
|
is not the host owner.
|
|
|
|
``designs/SESSION_WORKSPACE_SELECTION.md`` "Security surface":
|
|
the endpoint exposes the entire host filesystem to the host
|
|
owner, and v1 deliberately does not extend it to non-owner
|
|
users. This test pins that contract: a host owned by alice,
|
|
accessed with bob's identity → 403.
|
|
"""
|
|
from omnigent.server.auth import AuthProvider
|
|
|
|
_app, _reg, host_store, conv_store = fs_app
|
|
# Re-mount routes with an auth provider that returns the
|
|
# X-Test-User header. Mirrors the pattern in test_hosts_api.py.
|
|
|
|
class _Stub(AuthProvider):
|
|
"""Stub auth provider that reads X-Test-User from request.
|
|
|
|
:returns: User id from the header, or ``None``.
|
|
"""
|
|
|
|
def get_user_id(self, request: Any) -> str | None:
|
|
"""Extract user id from the X-Test-User header.
|
|
|
|
:param request: FastAPI Request or WebSocket.
|
|
:returns: Header value or ``None``.
|
|
"""
|
|
return request.headers.get("X-Test-User")
|
|
|
|
auth = _Stub()
|
|
auth_app = FastAPI()
|
|
registry = HostRegistry()
|
|
auth_app.include_router(
|
|
create_host_tunnel_router(registry, host_store, auth_provider=auth),
|
|
prefix="/v1",
|
|
)
|
|
auth_app.include_router(
|
|
create_hosts_router(
|
|
registry,
|
|
host_store,
|
|
conv_store,
|
|
auth_provider=auth,
|
|
),
|
|
prefix="/v1",
|
|
)
|
|
|
|
# Persist a host owned by alice.
|
|
host_store.upsert_on_connect(
|
|
host_id="host_alice",
|
|
name="alice-laptop",
|
|
owner="alice@example.com",
|
|
)
|
|
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=auth_app), base_url="http://test"
|
|
) as client:
|
|
resp = await client.get(
|
|
"/v1/hosts/host_alice/filesystem",
|
|
headers={"X-Test-User": "bob@example.com"},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
# ── Pagination ──────────────────────────────────────────
|
|
|
|
|
|
async def test_list_filesystem_forwards_pagination_params(
|
|
fs_setup: tuple[
|
|
FastAPI,
|
|
HostRegistry,
|
|
ApplicationCommunicator,
|
|
dict[str, dict[str, Any]],
|
|
asyncio.Task[None],
|
|
],
|
|
) -> None:
|
|
"""
|
|
Verify the ``limit`` / ``after`` / ``before`` query params are
|
|
forwarded to the host.list_dir frame.
|
|
|
|
Capture the frame the route sent and assert its pagination
|
|
fields match the request. Without this forwarding, the Web
|
|
UI's "next page" / "prev page" buttons would silently always
|
|
return the first page.
|
|
"""
|
|
from omnigent.host.frames import HostListDirEntry
|
|
|
|
app, registry, _comm, replies, _drain = fs_setup
|
|
|
|
# Capture the most recent list_dir frame at the registry
|
|
# level so the assertion sees the actual server output.
|
|
captured: dict[str, Any] = {}
|
|
original_send = registry.send_text
|
|
|
|
def _capturing_send(conn: Any, text: str) -> None:
|
|
"""Wrap send_text to capture list_dir frames.
|
|
|
|
:param conn: Host connection.
|
|
:param text: Outbound JSON frame text.
|
|
"""
|
|
frame = decode_host_frame(text)
|
|
if isinstance(frame, HostListDirFrame):
|
|
captured["limit"] = frame.limit
|
|
captured["after"] = frame.after
|
|
captured["before"] = frame.before
|
|
original_send(conn, text)
|
|
|
|
registry.send_text = _capturing_send # type: ignore[method-assign]
|
|
replies["/foo"] = {
|
|
"entries": [
|
|
HostListDirEntry(
|
|
name="x",
|
|
path="/foo/x",
|
|
type="file",
|
|
bytes=1,
|
|
modified_at=1,
|
|
),
|
|
],
|
|
"has_more": False,
|
|
}
|
|
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.get(
|
|
f"/v1/hosts/{_HOST_ID}/filesystem/foo",
|
|
params={"limit": 5, "after": "/foo/m"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert captured == {"limit": 5, "after": "/foo/m", "before": None}
|
|
|
|
|
|
async def test_list_filesystem_limit_above_max_rejected(
|
|
fs_setup: tuple[
|
|
FastAPI,
|
|
HostRegistry,
|
|
ApplicationCommunicator,
|
|
dict[str, dict[str, Any]],
|
|
asyncio.Task[None],
|
|
],
|
|
) -> None:
|
|
"""
|
|
Verify ``limit`` above the configured max is rejected with 422.
|
|
|
|
Without an upper bound, a client could request a page of
|
|
millions of entries — the host would happily attempt the
|
|
listing and the JSON response could exhaust memory.
|
|
"""
|
|
app, _reg, _comm, _replies, _drain = fs_setup
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.get(
|
|
f"/v1/hosts/{_HOST_ID}/filesystem",
|
|
params={"limit": 100_000},
|
|
)
|
|
# FastAPI returns 422 for failed Query validation.
|
|
assert resp.status_code == 422
|