304 lines
9.6 KiB
Python
304 lines
9.6 KiB
Python
"""
|
|
Integration tests for ``POST /v1/hosts/{id}/directories``.
|
|
|
|
Wires up a real host tunnel + REST router pair, drives a fake host
|
|
that auto-replies to ``host.create_dir`` frames, and exercises the
|
|
endpoint's contract end-to-end. Mirrors the structure of
|
|
``test_hosts_filesystem.py`` (the browse endpoint) — the create-folder
|
|
action shares the same owner-scoped, host-forwarded design.
|
|
"""
|
|
|
|
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 (
|
|
HostCreateDirFrame,
|
|
HostCreateDirResultFrame,
|
|
HostHelloFrame,
|
|
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
|
|
|
|
# Same liveness-race flake guard as test_hosts_filesystem.py: the mock
|
|
# WS host can be starved + deregistered under parallel CI load. Tests
|
|
# are sub-second; rerun rather than fail.
|
|
pytestmark = [
|
|
pytest.mark.asyncio,
|
|
pytest.mark.flaky(reruns=2, reruns_delay=1),
|
|
]
|
|
|
|
_HOST_ID = "host_mkdir_test"
|
|
_HOST_NAME = "mkdir-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 mkdir_app(
|
|
db_uri: str,
|
|
) -> tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore]:
|
|
"""
|
|
App with host tunnel + REST routes for create-directory 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 mkdir_setup(
|
|
mkdir_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 create_dir frames.
|
|
|
|
Tests register fake replies in ``replies`` (path → reply dict)
|
|
before calling the REST endpoint. The auto-replier consumes the
|
|
``host.create_dir`` frames the route pushes through the registry,
|
|
decodes them, and feeds the configured result back — mirroring what
|
|
``host_tunnel.py`` does in production. An unregistered path defaults
|
|
to a successful create echoing the requested path.
|
|
|
|
:param mkdir_app: The fixture above.
|
|
:returns: Async iterator yielding the wired-up state.
|
|
"""
|
|
app, registry, _hs, _cs = mkdir_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 and reply to create_dir frames.
|
|
|
|
: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, HostCreateDirFrame):
|
|
continue
|
|
reply = replies.get(frame.path)
|
|
if reply is None:
|
|
# Default: success, echoing the requested path (the host
|
|
# would return the created absolute path).
|
|
reply_frame = HostCreateDirResultFrame(
|
|
request_id=frame.request_id,
|
|
status="ok",
|
|
path=frame.path,
|
|
)
|
|
else:
|
|
reply_frame = HostCreateDirResultFrame(
|
|
request_id=frame.request_id,
|
|
status=reply.get("status", "ok"),
|
|
path=reply.get("path"),
|
|
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_create_directory_returns_created_path(
|
|
mkdir_setup: tuple[
|
|
FastAPI,
|
|
HostRegistry,
|
|
ApplicationCommunicator,
|
|
dict[str, dict[str, Any]],
|
|
asyncio.Task[None],
|
|
],
|
|
) -> None:
|
|
"""
|
|
A valid create request returns the created absolute path.
|
|
|
|
This is what the picker navigates into after creating the folder,
|
|
so the path must round-trip through the endpoint intact.
|
|
"""
|
|
app, _reg, _comm, replies, _drain = mkdir_setup
|
|
target = "/Users/corey/projects/new-app"
|
|
replies[target] = {"status": "ok", "path": target}
|
|
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.post(
|
|
f"/v1/hosts/{_HOST_ID}/directories",
|
|
json={"path": target},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["object"] == "directory"
|
|
assert body["path"] == target
|
|
|
|
|
|
async def test_create_directory_already_exists_returns_409(
|
|
mkdir_setup: tuple[
|
|
FastAPI,
|
|
HostRegistry,
|
|
ApplicationCommunicator,
|
|
dict[str, dict[str, Any]],
|
|
asyncio.Task[None],
|
|
],
|
|
) -> None:
|
|
"""
|
|
An "already exists" host result maps to 409 with the message.
|
|
|
|
The picker shows this inline so the user knows the name is taken
|
|
rather than seeing a generic failure.
|
|
"""
|
|
app, _reg, _comm, replies, _drain = mkdir_setup
|
|
target = "/Users/corey/projects/dup"
|
|
replies[target] = {"status": "ok", "error": "directory already exists"}
|
|
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.post(
|
|
f"/v1/hosts/{_HOST_ID}/directories",
|
|
json={"path": target},
|
|
)
|
|
|
|
assert resp.status_code == 409
|
|
assert "already exists" in resp.json()["detail"]
|
|
|
|
|
|
async def test_create_directory_relative_path_rejected(
|
|
mkdir_setup: tuple[
|
|
FastAPI,
|
|
HostRegistry,
|
|
ApplicationCommunicator,
|
|
dict[str, dict[str, Any]],
|
|
asyncio.Task[None],
|
|
],
|
|
) -> None:
|
|
"""
|
|
A relative path is rejected with 400 before reaching the host.
|
|
|
|
The host needs a path it can resolve on its own; a relative path
|
|
has no stable meaning across host process cwds.
|
|
"""
|
|
app, _reg, _comm, _replies, _drain = mkdir_setup
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.post(
|
|
f"/v1/hosts/{_HOST_ID}/directories",
|
|
json={"path": "relative/dir"},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
|
|
|
|
async def test_create_directory_unknown_host_returns_404(
|
|
mkdir_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore],
|
|
) -> None:
|
|
"""
|
|
Creating under an unknown host returns 404 (don't leak existence).
|
|
"""
|
|
app, _reg, _hs, _cs = mkdir_app
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/v1/hosts/host_does_not_exist/directories",
|
|
json={"path": "/tmp/x"},
|
|
)
|
|
|
|
assert resp.status_code == 404
|