Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
1239 lines
39 KiB
Python
1239 lines
39 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from collections.abc import Awaitable, Callable
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from fastapi.testclient import TestClient
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
from cloud.internal_api.models import AssignmentModel
|
|
from core.models import Task
|
|
from device.manager import DeviceManager
|
|
from host_agent.client import HostAgentAPIError, HostTaskSubmissionUnknownError
|
|
from host_agent.config import HostAgentConfig
|
|
from host_agent.history import ConsoleHistoryStore
|
|
from host_agent.identity import HostIdentityStore
|
|
from host_agent.local_account import LocalAccountStore
|
|
from host_agent.mcp_lock import McpBusyTracker
|
|
from host_agent.mcp_token import McpTokenStore
|
|
from host_agent.status import AgentStatusTracker
|
|
from host_agent.web.app import SESSION_COOKIE_NAME, create_console_app
|
|
from host_agent.web.auth import SessionManager
|
|
from host_agent.web.mcp import build_mcp_server
|
|
from storage.device_config import DeviceConfigStore
|
|
from storage.task_metadata import TaskMetadataStore
|
|
|
|
CSRF_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"')
|
|
|
|
TaskSubmissionCallable = Callable[[str, str | None], Awaitable[str]]
|
|
TaskCancellationCallable = Callable[[str], Awaitable[Any]]
|
|
|
|
|
|
def _build_client(
|
|
tmp_path,
|
|
*,
|
|
create_account: bool = True,
|
|
submit_self_task: TaskSubmissionCallable | None = None,
|
|
cancel_task: TaskCancellationCallable | None = None,
|
|
include_metadata_store: bool = True,
|
|
mcp_server: FastMCP | None = None,
|
|
mcp_token_store: McpTokenStore | None = None,
|
|
mcp_busy_tracker: McpBusyTracker | None = None,
|
|
) -> tuple[TestClient, dict]:
|
|
config = HostAgentConfig(
|
|
control_plane_url="https://control.example",
|
|
host_id="host-a",
|
|
token="secret",
|
|
enrollment_managed=False,
|
|
console_session_ttl_seconds=3600.0,
|
|
)
|
|
manager = DeviceManager()
|
|
config_store = DeviceConfigStore(tmp_path / "devices.sqlite3")
|
|
local_account_store = LocalAccountStore(tmp_path / "host_local_account.json")
|
|
if create_account:
|
|
local_account_store.create("operator", "correct horse battery staple")
|
|
identity_store = HostIdentityStore(tmp_path / "host_identity.json")
|
|
history_store = ConsoleHistoryStore(tmp_path / "history.sqlite3")
|
|
status_tracker = AgentStatusTracker()
|
|
session_manager = SessionManager(ttl_seconds=3600.0)
|
|
metadata_store: TaskMetadataStore | None = None
|
|
if include_metadata_store:
|
|
metadata_store = TaskMetadataStore(db_path=tmp_path / "task_metadata.sqlite3")
|
|
|
|
app = create_console_app(
|
|
config=config,
|
|
manager=manager,
|
|
config_store=config_store,
|
|
local_account_store=local_account_store,
|
|
identity_store=identity_store,
|
|
history_store=history_store,
|
|
status_tracker=status_tracker,
|
|
session_manager=session_manager,
|
|
enrollment_client=None,
|
|
submit_self_task=submit_self_task,
|
|
cancel_task=cancel_task,
|
|
metadata_store=metadata_store,
|
|
mcp_server=mcp_server,
|
|
mcp_token_store=mcp_token_store,
|
|
mcp_busy_tracker=mcp_busy_tracker,
|
|
)
|
|
client = TestClient(app)
|
|
context = {
|
|
"manager": manager,
|
|
"config_store": config_store,
|
|
"local_account_store": local_account_store,
|
|
"history_store": history_store,
|
|
"session_manager": session_manager,
|
|
"status_tracker": status_tracker,
|
|
"metadata_store": metadata_store,
|
|
}
|
|
return client, context
|
|
|
|
|
|
def _login(
|
|
client: TestClient,
|
|
*,
|
|
username: str = "operator",
|
|
password: str = "correct horse battery staple",
|
|
) -> str:
|
|
response = client.post("/login", data={"username": username, "password": password})
|
|
assert response.status_code == 200
|
|
match = CSRF_PATTERN.search(response.text)
|
|
assert match is not None
|
|
return match.group(1)
|
|
|
|
|
|
def test_unauthenticated_get_root_redirects_to_login(tmp_path) -> None:
|
|
client, _ = _build_client(tmp_path)
|
|
|
|
response = client.get("/", follow_redirects=False)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == "/login"
|
|
|
|
|
|
def test_login_with_no_account_shows_setup_message_and_rejects_post(tmp_path) -> None:
|
|
client, context = _build_client(tmp_path, create_account=False)
|
|
|
|
get_response = client.get("/login")
|
|
assert "device-host-agent setup" in get_response.text
|
|
|
|
post_response = client.post(
|
|
"/login", data={"username": "operator", "password": "anything"}
|
|
)
|
|
|
|
assert "device-host-agent setup" in post_response.text
|
|
assert SESSION_COOKIE_NAME not in client.cookies
|
|
|
|
|
|
def test_login_with_wrong_password_fails_and_sets_no_cookie(tmp_path) -> None:
|
|
client, _ = _build_client(tmp_path)
|
|
|
|
response = client.post("/login", data={"username": "operator", "password": "wrong"})
|
|
|
|
assert response.status_code == 200
|
|
assert "Invalid username or password" in response.text
|
|
assert SESSION_COOKIE_NAME not in client.cookies
|
|
|
|
|
|
def test_login_with_correct_password_sets_cookie_and_dashboard_succeeds(
|
|
tmp_path,
|
|
) -> None:
|
|
client, _ = _build_client(tmp_path)
|
|
|
|
response = client.post(
|
|
"/login",
|
|
data={"username": "operator", "password": "correct horse battery staple"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert SESSION_COOKIE_NAME in client.cookies
|
|
assert "Status" in response.text
|
|
assert "control.example" in response.text
|
|
|
|
|
|
def test_connected_device_without_running_task_shows_connected_not_busy(
|
|
tmp_path,
|
|
) -> None:
|
|
client, context = _build_client(tmp_path)
|
|
_login(client)
|
|
context["manager"].register_device(
|
|
"device-a",
|
|
lambda: object(), # type: ignore[arg-type,return-value]
|
|
status="busy",
|
|
)
|
|
|
|
dashboard_response = client.get("/")
|
|
api_response = client.get("/api/status")
|
|
|
|
assert "connected" in dashboard_response.text
|
|
assert "<td>busy</td>" not in dashboard_response.text
|
|
assert api_response.json()["devices"][0]["status"] == "connected"
|
|
|
|
|
|
def test_device_running_current_assignment_still_shows_busy(tmp_path) -> None:
|
|
client, context = _build_client(tmp_path)
|
|
_login(client)
|
|
context["manager"].register_device(
|
|
"device-a",
|
|
lambda: object(), # type: ignore[arg-type,return-value]
|
|
status="busy",
|
|
)
|
|
context["status_tracker"].mark_assignment_started(
|
|
AssignmentModel(
|
|
task_id="task-a",
|
|
attempt=1,
|
|
lease_id="lease-a",
|
|
lease_expires_at=datetime(2026, 7, 12, tzinfo=UTC),
|
|
host_id="host-a",
|
|
device_id="device-a",
|
|
goal="open settings",
|
|
)
|
|
)
|
|
|
|
dashboard_response = client.get("/")
|
|
api_response = client.get("/api/status")
|
|
|
|
assert "<td>busy</td>" in dashboard_response.text
|
|
assert api_response.json()["devices"][0]["status"] == "busy"
|
|
|
|
|
|
def test_mutating_post_without_csrf_token_is_rejected_and_makes_no_change(
|
|
tmp_path,
|
|
) -> None:
|
|
client, context = _build_client(tmp_path)
|
|
_login(client)
|
|
|
|
response = client.post(
|
|
"/devices/save",
|
|
data={
|
|
"device_id": "device-a",
|
|
"driver_type": "wda",
|
|
"name": "Lab iPhone",
|
|
"connection_info": "{}",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert context["config_store"].get("device-a") is None
|
|
assert context["manager"].list_devices() == []
|
|
|
|
|
|
def test_add_device_appears_in_devices_page_and_manager(tmp_path) -> None:
|
|
client, context = _build_client(tmp_path)
|
|
csrf_token = _login(client)
|
|
|
|
response = client.post(
|
|
"/devices/save",
|
|
data={
|
|
"device_id": "device-a",
|
|
"driver_type": "wda",
|
|
"name": "Lab iPhone",
|
|
"connection_info": "{}",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert "device-a" in response.text
|
|
assert context["config_store"].get("device-a") is not None
|
|
assert [device.id for device in context["manager"].list_devices()] == ["device-a"]
|
|
|
|
|
|
def test_devices_page_captures_screenshot_only_when_button_endpoint_is_called(
|
|
tmp_path,
|
|
) -> None:
|
|
class ScreenshotDriver:
|
|
def __init__(self) -> None:
|
|
self.capture_count = 0
|
|
|
|
def connect(self) -> None:
|
|
pass
|
|
|
|
def disconnect(self) -> None:
|
|
pass
|
|
|
|
def screenshot(self) -> bytes:
|
|
self.capture_count += 1
|
|
return b"fake-png"
|
|
|
|
driver = ScreenshotDriver()
|
|
client, context = _build_client(tmp_path)
|
|
context["config_store"].add(
|
|
device_id="device-a",
|
|
name="Lab iPhone",
|
|
driver_type="wda",
|
|
connection_info={},
|
|
)
|
|
context["manager"].register_device("device-a", lambda: driver)
|
|
context["manager"].connect("device-a")
|
|
csrf_token = _login(client)
|
|
|
|
page = client.get("/devices")
|
|
assert page.status_code == 200
|
|
assert 'class="screenshot-button"' in page.text
|
|
assert 'data-device-id="device-a"' in page.text
|
|
assert driver.capture_count == 0
|
|
|
|
response = client.post(
|
|
"/api/devices/device-a/screenshot",
|
|
headers={"X-CSRF-Token": csrf_token},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.content == b"fake-png"
|
|
assert response.headers["content-type"] == "image/png"
|
|
assert response.headers["cache-control"] == "no-store"
|
|
assert driver.capture_count == 1
|
|
|
|
|
|
def test_device_screenshot_requires_csrf_and_connected_device(tmp_path) -> None:
|
|
class ScreenshotDriver:
|
|
def connect(self) -> None:
|
|
pass
|
|
|
|
def disconnect(self) -> None:
|
|
pass
|
|
|
|
def screenshot(self) -> bytes:
|
|
return b"fake-png"
|
|
|
|
client, context = _build_client(tmp_path)
|
|
context["manager"].register_device("device-a", ScreenshotDriver)
|
|
csrf_token = _login(client)
|
|
|
|
missing_csrf = client.post("/api/devices/device-a/screenshot")
|
|
assert missing_csrf.status_code == 403
|
|
|
|
offline = client.post(
|
|
"/api/devices/device-a/screenshot",
|
|
headers={"X-CSRF-Token": csrf_token},
|
|
)
|
|
assert offline.status_code == 503
|
|
|
|
|
|
def test_device_connection_test_connects_checks_health_and_disconnects(
|
|
tmp_path, monkeypatch
|
|
) -> None:
|
|
events: list[str] = []
|
|
|
|
class ProbeDriver:
|
|
def connect(self) -> None:
|
|
events.append("connect")
|
|
|
|
def health_check(self) -> None:
|
|
events.append("health")
|
|
|
|
def disconnect(self) -> None:
|
|
events.append("disconnect")
|
|
|
|
def factory(driver_type, connection_info):
|
|
assert driver_type == "wda"
|
|
assert connection_info["udid"] == "ios-udid"
|
|
return ProbeDriver
|
|
|
|
monkeypatch.setattr("host_agent.web.app.build_driver_factory", factory)
|
|
client, context = _build_client(tmp_path)
|
|
csrf_token = _login(client)
|
|
|
|
response = client.post(
|
|
"/api/devices/test-connection",
|
|
json={"driver_type": "wda", "connection_info": {"udid": "ios-udid"}},
|
|
headers={"X-CSRF-Token": csrf_token},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"ok": True, "driver_type": "wda"}
|
|
assert events == ["connect", "health", "disconnect"]
|
|
assert context["config_store"].list() == []
|
|
|
|
|
|
def test_ios_discovery_returns_connected_devices_and_unique_ports(
|
|
tmp_path, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
"host_agent.web.app.discover_connected_ios_devices",
|
|
lambda: [
|
|
{
|
|
"udid": "ios-new",
|
|
"name": "New iPhone",
|
|
"model": "iPhone 15",
|
|
"os_version": "18.0",
|
|
"transport": "wired",
|
|
}
|
|
],
|
|
)
|
|
client, context = _build_client(tmp_path)
|
|
context["config_store"].add(
|
|
device_id="existing",
|
|
driver_type="wda",
|
|
connection_info={"udid": "ios-old", "wda_local_port": 8100, "mjpegServerPort": 9100},
|
|
)
|
|
_login(client)
|
|
|
|
response = client.get("/api/devices/discover-ios")
|
|
|
|
assert response.status_code == 200
|
|
device = response.json()["devices"][0]
|
|
assert device["udid"] == "ios-new"
|
|
assert device["configured"] is False
|
|
assert device["suggested_wda_port"] == 8101
|
|
assert device["suggested_mjpeg_port"] == 9101
|
|
|
|
|
|
def test_remove_device_unregisters_from_manager(tmp_path) -> None:
|
|
client, context = _build_client(tmp_path)
|
|
csrf_token = _login(client)
|
|
client.post(
|
|
"/devices/save",
|
|
data={
|
|
"device_id": "device-a",
|
|
"driver_type": "wda",
|
|
"name": "Lab iPhone",
|
|
"connection_info": "{}",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
)
|
|
assert context["config_store"].get("device-a") is not None
|
|
|
|
response = client.post(
|
|
"/devices/remove",
|
|
data={"device_id": "device-a", "csrf_token": csrf_token},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert context["config_store"].get("device-a") is None
|
|
assert context["manager"].list_devices() == []
|
|
|
|
|
|
def test_change_password_wrong_current_fails_old_password_still_works(tmp_path) -> None:
|
|
client, context = _build_client(tmp_path)
|
|
csrf_token = _login(client)
|
|
|
|
response = client.post(
|
|
"/account",
|
|
data={
|
|
"current_password": "wrong",
|
|
"new_password": "new password",
|
|
"confirm_password": "new password",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "incorrect" in response.text
|
|
account_store: LocalAccountStore = context["local_account_store"]
|
|
account = account_store.load()
|
|
assert account is not None
|
|
assert account_store.verify(account, "correct horse battery staple") is True
|
|
|
|
|
|
def test_change_password_correct_succeeds_old_password_no_longer_works(
|
|
tmp_path,
|
|
) -> None:
|
|
client, context = _build_client(tmp_path)
|
|
csrf_token = _login(client)
|
|
|
|
response = client.post(
|
|
"/account",
|
|
data={
|
|
"current_password": "correct horse battery staple",
|
|
"new_password": "new password",
|
|
"confirm_password": "new password",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert "Password updated" in response.text
|
|
account_store: LocalAccountStore = context["local_account_store"]
|
|
account = account_store.load()
|
|
assert account is not None
|
|
assert account_store.verify(account, "new password") is True
|
|
assert account_store.verify(account, "correct horse battery staple") is False
|
|
|
|
|
|
def test_history_reflects_entries_written_beforehand(tmp_path) -> None:
|
|
client, context = _build_client(tmp_path)
|
|
context["history_store"].record_heartbeat(device_count=3)
|
|
_login(client)
|
|
|
|
response = client.get("/history")
|
|
|
|
assert response.status_code == 200
|
|
assert "heartbeat: 3 devices" in response.text
|
|
|
|
|
|
def test_logout_invalidates_session_so_subsequent_request_redirects_to_login(
|
|
tmp_path,
|
|
) -> None:
|
|
client, _ = _build_client(tmp_path)
|
|
csrf_token = _login(client)
|
|
|
|
logout_response = client.post(
|
|
"/logout", data={"csrf_token": csrf_token}, follow_redirects=False
|
|
)
|
|
assert logout_response.status_code == 303
|
|
assert logout_response.headers["location"] == "/login"
|
|
|
|
response = client.get("/", follow_redirects=False)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == "/login"
|
|
|
|
|
|
def _make_submission_recorder(
|
|
*,
|
|
task_id: str = "task-cloud-1",
|
|
raise_api_error: HostAgentAPIError | None = None,
|
|
raise_unknown: bool = False,
|
|
history_store: ConsoleHistoryStore | None = None,
|
|
history_fail: bool = False,
|
|
) -> tuple[TaskSubmissionCallable, dict]:
|
|
captured: dict = {}
|
|
|
|
async def submit(goal: str, device_id: str | None) -> str:
|
|
captured["goal"] = goal
|
|
captured["device_id"] = device_id
|
|
if raise_unknown:
|
|
raise HostTaskSubmissionUnknownError("transport failure")
|
|
if raise_api_error is not None:
|
|
raise raise_api_error
|
|
return task_id
|
|
|
|
async def record(*, task_id: str, device_id: str | None) -> None:
|
|
captured.setdefault("history_calls", []).append(
|
|
{"task_id": task_id, "device_id": device_id}
|
|
)
|
|
if history_fail:
|
|
raise RuntimeError("history store unavailable")
|
|
|
|
if history_store is not None:
|
|
original = history_store.record_task_submission
|
|
|
|
def _proxy(*, task_id: str, device_id: str | None) -> None:
|
|
try:
|
|
asyncio_run(record(task_id=task_id, device_id=device_id))
|
|
except RuntimeError:
|
|
pass
|
|
return original(task_id=task_id, device_id=device_id)
|
|
|
|
history_store.record_task_submission = _proxy # type: ignore[method-assign]
|
|
return submit, captured
|
|
|
|
|
|
def asyncio_run(coro):
|
|
import asyncio
|
|
|
|
return (
|
|
asyncio.get_event_loop().run_until_complete(coro)
|
|
if asyncio.get_event_loop().is_running()
|
|
else asyncio.run(coro)
|
|
)
|
|
|
|
|
|
def test_tasks_page_renders_submission_form_with_device_options(tmp_path) -> None:
|
|
submit, _ = _make_submission_recorder()
|
|
client, context = _build_client(tmp_path, submit_self_task=submit)
|
|
context["manager"].register_device(
|
|
"device-runtime-a",
|
|
lambda: object(), # type: ignore[arg-type,return-value]
|
|
name="Lab iPhone",
|
|
)
|
|
_login(client)
|
|
|
|
response = client.get("/tasks")
|
|
|
|
assert response.status_code == 200
|
|
assert "Submit task to current Host" in response.text
|
|
assert 'action="/tasks/submit"' in response.text
|
|
assert "Automatic (let Host choose" in response.text
|
|
assert "Lab iPhone" in response.text
|
|
|
|
|
|
def test_authenticated_automatic_submission_invokes_submit_with_none_device(
|
|
tmp_path,
|
|
) -> None:
|
|
submit, captured = _make_submission_recorder(task_id="task-cloud-auto")
|
|
client, context = _build_client(tmp_path, submit_self_task=submit)
|
|
context["manager"].register_device(
|
|
"device-runtime-a",
|
|
lambda: object(), # type: ignore[arg-type,return-value]
|
|
name="Lab iPhone",
|
|
)
|
|
csrf_token = _login(client)
|
|
|
|
response = client.post(
|
|
"/tasks/submit",
|
|
data={
|
|
"goal": "open settings",
|
|
"device_id": "__automatic__",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == "/tasks?submitted=1&task_id=task-cloud-auto"
|
|
assert captured == {"goal": "open settings", "device_id": None}
|
|
|
|
|
|
def test_authenticated_explicit_device_submission_uses_runtime_id(tmp_path) -> None:
|
|
submit, captured = _make_submission_recorder(task_id="task-cloud-explicit")
|
|
client, context = _build_client(tmp_path, submit_self_task=submit)
|
|
context["manager"].register_device(
|
|
"device-cloud-a",
|
|
lambda: object(), # type: ignore[arg-type,return-value]
|
|
name="Cloud iPhone",
|
|
)
|
|
csrf_token = _login(client)
|
|
|
|
response = client.post(
|
|
"/tasks/submit",
|
|
data={
|
|
"goal": "open mail",
|
|
"device_id": "device-cloud-a",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert (
|
|
response.headers["location"] == "/tasks?submitted=1&task_id=task-cloud-explicit"
|
|
)
|
|
assert captured == {"goal": "open mail", "device_id": "device-cloud-a"}
|
|
|
|
|
|
def test_redirected_tasks_page_renders_task_id_confirmation(tmp_path) -> None:
|
|
submit, _ = _make_submission_recorder()
|
|
client, _ = _build_client(tmp_path, submit_self_task=submit)
|
|
_login(client)
|
|
|
|
response = client.get("/tasks?submitted=1&task_id=task-cloud-1")
|
|
|
|
assert response.status_code == 200
|
|
assert "task-cloud-1" in response.text
|
|
assert "Cloud task ID" in response.text
|
|
|
|
|
|
def test_confirmed_submission_records_history_audit(tmp_path) -> None:
|
|
submit, _ = _make_submission_recorder(task_id="task-cloud-audit")
|
|
client, context = _build_client(tmp_path, submit_self_task=submit)
|
|
context["manager"].register_device(
|
|
"device-cloud-a",
|
|
lambda: object(), # type: ignore[arg-type,return-value]
|
|
name="Cloud iPhone",
|
|
)
|
|
csrf_token = _login(client)
|
|
|
|
client.post(
|
|
"/tasks/submit",
|
|
data={
|
|
"goal": "audit me",
|
|
"device_id": "device-cloud-a",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
entries = context["history_store"].list_recent()
|
|
assert entries[0]["kind"] == "task_submission"
|
|
assert entries[0]["detail"] == {
|
|
"task_id": "task-cloud-audit",
|
|
"device_id": "device-cloud-a",
|
|
}
|
|
|
|
|
|
def test_unauthenticated_submission_redirects_to_login_without_calling_client(
|
|
tmp_path,
|
|
) -> None:
|
|
submit, captured = _make_submission_recorder()
|
|
client, _ = _build_client(tmp_path, submit_self_task=submit)
|
|
|
|
response = client.post(
|
|
"/tasks/submit",
|
|
data={"goal": "open settings", "device_id": "__automatic__"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == "/login"
|
|
assert captured == {}
|
|
|
|
|
|
def test_csrf_invalid_submission_is_rejected(tmp_path) -> None:
|
|
submit, captured = _make_submission_recorder()
|
|
client, _ = _build_client(tmp_path, submit_self_task=submit)
|
|
_login(client)
|
|
|
|
response = client.post(
|
|
"/tasks/submit",
|
|
data={
|
|
"goal": "open settings",
|
|
"device_id": "__automatic__",
|
|
"csrf_token": "wrong-token",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert captured == {}
|
|
|
|
|
|
def test_blank_goal_rejected_before_client_invocation(tmp_path) -> None:
|
|
submit, captured = _make_submission_recorder()
|
|
client, context = _build_client(tmp_path, submit_self_task=submit)
|
|
context["manager"].register_device(
|
|
"device-runtime-a",
|
|
lambda: object(), # type: ignore[arg-type,return-value]
|
|
name="Lab iPhone",
|
|
)
|
|
csrf_token = _login(client)
|
|
|
|
response = client.post(
|
|
"/tasks/submit",
|
|
data={
|
|
"goal": " ",
|
|
"device_id": "__automatic__",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "Goal cannot be empty" in response.text
|
|
assert captured == {}
|
|
|
|
|
|
def test_stale_device_selection_rejected_before_client_invocation(tmp_path) -> None:
|
|
submit, captured = _make_submission_recorder()
|
|
client, context = _build_client(tmp_path, submit_self_task=submit)
|
|
context["manager"].register_device(
|
|
"device-runtime-a",
|
|
lambda: object(), # type: ignore[arg-type,return-value],
|
|
name="Lab iPhone",
|
|
)
|
|
csrf_token = _login(client)
|
|
|
|
response = client.post(
|
|
"/tasks/submit",
|
|
data={
|
|
"goal": "open settings",
|
|
"device_id": "device-that-was-removed",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "no longer registered" in response.text
|
|
assert captured == {}
|
|
|
|
|
|
def test_cloud_definitive_rejection_renders_safe_error_without_calling_history(
|
|
tmp_path,
|
|
) -> None:
|
|
submit, captured = _make_submission_recorder(
|
|
raise_api_error=HostAgentAPIError(403, "Host self-submission is disabled"),
|
|
)
|
|
client, context = _build_client(tmp_path, submit_self_task=submit)
|
|
context["manager"].register_device(
|
|
"device-runtime-a",
|
|
lambda: object(), # type: ignore[arg-type,return-value]
|
|
name="Lab iPhone",
|
|
)
|
|
csrf_token = _login(client)
|
|
|
|
response = client.post(
|
|
"/tasks/submit",
|
|
data={
|
|
"goal": "open settings",
|
|
"device_id": "__automatic__",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 502
|
|
assert "Host self-submission is disabled" in response.text
|
|
entries = context["history_store"].list_recent()
|
|
assert all(entry["kind"] != "task_submission" for entry in entries)
|
|
assert captured == {"goal": "open settings", "device_id": None}
|
|
|
|
|
|
def test_transport_uncertain_response_redirects_to_unknown_outcome(
|
|
tmp_path,
|
|
) -> None:
|
|
submit, captured = _make_submission_recorder(raise_unknown=True)
|
|
client, context = _build_client(tmp_path, submit_self_task=submit)
|
|
context["manager"].register_device(
|
|
"device-runtime-a",
|
|
lambda: object(), # type: ignore[arg-type,return-value]
|
|
name="Lab iPhone",
|
|
)
|
|
csrf_token = _login(client)
|
|
|
|
response = client.post(
|
|
"/tasks/submit",
|
|
data={
|
|
"goal": "open settings",
|
|
"device_id": "__automatic__",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == "/tasks?outcome=unknown"
|
|
entries = context["history_store"].list_recent()
|
|
assert all(entry["kind"] != "task_submission" for entry in entries)
|
|
assert captured == {"goal": "open settings", "device_id": None}
|
|
|
|
|
|
def test_unknown_outcome_query_shows_safe_message(tmp_path) -> None:
|
|
submit, _ = _make_submission_recorder()
|
|
client, _ = _build_client(tmp_path, submit_self_task=submit)
|
|
_login(client)
|
|
|
|
response = client.get("/tasks?outcome=unknown")
|
|
|
|
assert response.status_code == 200
|
|
assert "Submission outcome is unknown" in response.text
|
|
assert "Check the Cloud console" in response.text
|
|
|
|
|
|
def test_submission_form_absent_when_client_missing(tmp_path) -> None:
|
|
client, _ = _build_client(tmp_path, submit_self_task=None)
|
|
_login(client)
|
|
|
|
response = client.get("/tasks")
|
|
|
|
assert response.status_code == 200
|
|
assert 'action="/tasks/submit"' not in response.text
|
|
assert "submission client is not available yet" in response.text
|
|
|
|
|
|
def test_submit_when_client_missing_returns_503_without_history(tmp_path) -> None:
|
|
client, context = _build_client(tmp_path, submit_self_task=None)
|
|
context["manager"].register_device(
|
|
"device-runtime-a",
|
|
lambda: object(), # type: ignore[arg-type,return-value]
|
|
name="Lab iPhone",
|
|
)
|
|
csrf_token = _login(client)
|
|
|
|
response = client.post(
|
|
"/tasks/submit",
|
|
data={
|
|
"goal": "open settings",
|
|
"device_id": "__automatic__",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 503
|
|
entries = context["history_store"].list_recent()
|
|
assert all(entry["kind"] != "task_submission" for entry in entries)
|
|
|
|
|
|
def test_audit_failure_does_not_break_successful_submission(tmp_path) -> None:
|
|
submit, _ = _make_submission_recorder(
|
|
task_id="task-cloud-audit-fail",
|
|
history_store=None,
|
|
)
|
|
client, context = _build_client(tmp_path, submit_self_task=submit)
|
|
|
|
def boom(*, task_id: str, device_id: str | None) -> None:
|
|
raise RuntimeError("audit DB offline")
|
|
|
|
context["history_store"].record_task_submission = boom # type: ignore[method-assign]
|
|
context["manager"].register_device(
|
|
"device-runtime-a",
|
|
lambda: object(), # type: ignore[arg-type,return-value]
|
|
name="Lab iPhone",
|
|
)
|
|
csrf_token = _login(client)
|
|
|
|
response = client.post(
|
|
"/tasks/submit",
|
|
data={
|
|
"goal": "open settings",
|
|
"device_id": "__automatic__",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert (
|
|
response.headers["location"]
|
|
== "/tasks?submitted=1&task_id=task-cloud-audit-fail"
|
|
)
|
|
|
|
|
|
def test_failed_submission_never_writes_successful_audit(tmp_path) -> None:
|
|
submit, _ = _make_submission_recorder(
|
|
raise_api_error=HostAgentAPIError(422, "ownership mismatch"),
|
|
)
|
|
client, context = _build_client(tmp_path, submit_self_task=submit)
|
|
context["manager"].register_device(
|
|
"device-runtime-a",
|
|
lambda: object(), # type: ignore[arg-type,return-value]
|
|
name="Lab iPhone",
|
|
)
|
|
csrf_token = _login(client)
|
|
|
|
client.post(
|
|
"/tasks/submit",
|
|
data={
|
|
"goal": "open settings",
|
|
"device_id": "device-runtime-a",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
)
|
|
|
|
entries = context["history_store"].list_recent()
|
|
assert all(entry["kind"] != "task_submission" for entry in entries)
|
|
|
|
|
|
def test_tasks_page_autoescapes_goal_device_and_error_text(tmp_path) -> None:
|
|
submit, _ = _make_submission_recorder(
|
|
raise_api_error=HostAgentAPIError(403, "<img src=x onerror=alert(1)>"),
|
|
)
|
|
client, context = _build_client(tmp_path, submit_self_task=submit)
|
|
context["manager"].register_device(
|
|
"<script>alert('x')</script>",
|
|
lambda: object(), # type: ignore[arg-type,return-value]
|
|
name='"><img src=x onerror=alert(1)>',
|
|
)
|
|
csrf_token = _login(client)
|
|
|
|
response = client.post(
|
|
"/tasks/submit",
|
|
data={
|
|
"goal": "<script>alert('x')</script>",
|
|
"device_id": "<script>alert('x')</script>",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 502
|
|
html = response.text
|
|
assert "<script>alert('x')</script>" in html
|
|
assert "<img src=x onerror=alert(1)>" not in html
|
|
assert "<script>alert('x')</script>" not in html
|
|
assert "<script>alert" in html
|
|
assert "alert(1)" in html # auto-escaped as text, not executable
|
|
|
|
|
|
def test_submitted_redirect_does_not_include_goal_text(tmp_path) -> None:
|
|
submit, _ = _make_submission_recorder(task_id="task-cloud-clean")
|
|
client, context = _build_client(tmp_path, submit_self_task=submit)
|
|
context["manager"].register_device(
|
|
"device-runtime-a",
|
|
lambda: object(), # type: ignore[arg-type,return-value],
|
|
name="Lab iPhone",
|
|
)
|
|
csrf_token = _login(client)
|
|
secret_goal = "rotate bearer-token-deadbeef-very-secret-12345"
|
|
|
|
response = client.post(
|
|
"/tasks/submit",
|
|
data={
|
|
"goal": secret_goal,
|
|
"device_id": "__automatic__",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert secret_goal not in response.headers["location"]
|
|
assert "bearer-token-deadbeef" not in response.headers["location"]
|
|
follow = client.get(response.headers["location"])
|
|
assert secret_goal not in follow.text
|
|
|
|
|
|
def _make_cancellation_recorder(
|
|
*,
|
|
raise_api_error: HostAgentAPIError | None = None,
|
|
) -> tuple[TaskCancellationCallable, dict]:
|
|
captured: dict = {}
|
|
|
|
async def cancel(task_id: str) -> None:
|
|
captured["task_id"] = task_id
|
|
if raise_api_error is not None:
|
|
raise raise_api_error
|
|
|
|
return cancel, captured
|
|
|
|
|
|
def _seed_local_task(
|
|
metadata_store: TaskMetadataStore,
|
|
*,
|
|
status: str = "running",
|
|
source_task_id: str | None = "cloud-task-1",
|
|
) -> str:
|
|
task = Task(goal="open settings", device_id="dev-1", status=status)
|
|
metadata_store.create_task(task, source_task_id=source_task_id, source_attempt=1)
|
|
return task.id
|
|
|
|
|
|
def test_task_detail_page_shows_cancel_button_for_non_terminal_task(
|
|
tmp_path,
|
|
) -> None:
|
|
cancel, _ = _make_cancellation_recorder()
|
|
client, context = _build_client(tmp_path, cancel_task=cancel)
|
|
execution_id = _seed_local_task(context["metadata_store"], status="running")
|
|
_login(client)
|
|
|
|
response = client.get(f"/tasks/{execution_id}")
|
|
|
|
assert response.status_code == 200
|
|
assert f'action="/tasks/{execution_id}/cancel"' in response.text
|
|
assert "Cancel task" in response.text
|
|
|
|
|
|
def test_task_detail_page_hides_cancel_button_for_terminal_task(tmp_path) -> None:
|
|
cancel, _ = _make_cancellation_recorder()
|
|
client, context = _build_client(tmp_path, cancel_task=cancel)
|
|
execution_id = _seed_local_task(context["metadata_store"], status="completed")
|
|
_login(client)
|
|
|
|
response = client.get(f"/tasks/{execution_id}")
|
|
|
|
assert response.status_code == 200
|
|
assert f'action="/tasks/{execution_id}/cancel"' not in response.text
|
|
|
|
|
|
def test_task_detail_page_hides_cancel_button_when_client_unavailable(
|
|
tmp_path,
|
|
) -> None:
|
|
client, context = _build_client(tmp_path, cancel_task=None)
|
|
execution_id = _seed_local_task(context["metadata_store"], status="running")
|
|
_login(client)
|
|
|
|
response = client.get(f"/tasks/{execution_id}")
|
|
|
|
assert response.status_code == 200
|
|
assert f'action="/tasks/{execution_id}/cancel"' not in response.text
|
|
|
|
|
|
def test_cancel_task_success_calls_client_with_cloud_task_id_and_redirects(
|
|
tmp_path,
|
|
) -> None:
|
|
cancel, captured = _make_cancellation_recorder()
|
|
client, context = _build_client(tmp_path, cancel_task=cancel)
|
|
execution_id = _seed_local_task(
|
|
context["metadata_store"], status="running", source_task_id="cloud-task-99"
|
|
)
|
|
csrf_token = _login(client)
|
|
|
|
response = client.post(
|
|
f"/tasks/{execution_id}/cancel",
|
|
data={"csrf_token": csrf_token},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == f"/tasks/{execution_id}?cancelled=1"
|
|
assert captured == {"task_id": "cloud-task-99"}
|
|
|
|
follow = client.get(response.headers["location"])
|
|
assert "cancel-notice" in follow.text
|
|
|
|
|
|
def test_cancel_task_client_error_redirects_with_cancel_error(tmp_path) -> None:
|
|
cancel, _ = _make_cancellation_recorder(
|
|
raise_api_error=HostAgentAPIError(502, "control plane unavailable")
|
|
)
|
|
client, context = _build_client(tmp_path, cancel_task=cancel)
|
|
execution_id = _seed_local_task(context["metadata_store"], status="running")
|
|
csrf_token = _login(client)
|
|
|
|
response = client.post(
|
|
f"/tasks/{execution_id}/cancel",
|
|
data={"csrf_token": csrf_token},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == f"/tasks/{execution_id}?cancel_error=1"
|
|
|
|
follow = client.get(response.headers["location"])
|
|
assert "cancel-error" in follow.text
|
|
|
|
|
|
def test_cancel_task_unknown_execution_id_returns_404(tmp_path) -> None:
|
|
cancel, captured = _make_cancellation_recorder()
|
|
client, _ = _build_client(tmp_path, cancel_task=cancel)
|
|
csrf_token = _login(client)
|
|
|
|
response = client.post(
|
|
"/tasks/does-not-exist/cancel",
|
|
data={"csrf_token": csrf_token},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert captured == {}
|
|
|
|
|
|
def test_unauthenticated_cancel_redirects_to_login_without_calling_client(
|
|
tmp_path,
|
|
) -> None:
|
|
cancel, captured = _make_cancellation_recorder()
|
|
client, context = _build_client(tmp_path, cancel_task=cancel)
|
|
execution_id = _seed_local_task(context["metadata_store"], status="running")
|
|
|
|
response = client.post(
|
|
f"/tasks/{execution_id}/cancel",
|
|
data={},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == "/login"
|
|
assert captured == {}
|
|
|
|
|
|
def test_cancel_task_without_csrf_token_is_rejected(tmp_path) -> None:
|
|
cancel, captured = _make_cancellation_recorder()
|
|
client, context = _build_client(tmp_path, cancel_task=cancel)
|
|
execution_id = _seed_local_task(context["metadata_store"], status="running")
|
|
_login(client)
|
|
|
|
response = client.post(
|
|
f"/tasks/{execution_id}/cancel",
|
|
data={"csrf_token": "wrong-token"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert captured == {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MCP mount + /api/status fields + dashboard row
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_mcp_components(tmp_path) -> tuple[FastMCP, McpTokenStore, McpBusyTracker]:
|
|
manager = DeviceManager()
|
|
status_tracker = AgentStatusTracker()
|
|
tracker = McpBusyTracker()
|
|
token_store = McpTokenStore(tmp_path / "host_mcp_token.json")
|
|
server = build_mcp_server(
|
|
manager=manager,
|
|
mcp_busy_tracker=tracker,
|
|
status_tracker=status_tracker,
|
|
)
|
|
return server, token_store, tracker
|
|
|
|
|
|
def test_console_app_mounts_mcp_when_all_components_provided(tmp_path) -> None:
|
|
server, token_store, tracker = _build_mcp_components(tmp_path)
|
|
client, _ = _build_client(
|
|
tmp_path,
|
|
mcp_server=server,
|
|
mcp_token_store=token_store,
|
|
mcp_busy_tracker=tracker,
|
|
)
|
|
# Without auth, the bearer middleware should respond 401 — not 404.
|
|
resp = client.post("/mcp/", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
|
|
assert resp.status_code != 404
|
|
|
|
|
|
def test_console_app_does_not_mount_mcp_when_components_missing(tmp_path) -> None:
|
|
client, _ = _build_client(tmp_path)
|
|
resp = client.post("/mcp/", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_api_status_includes_mcp_busy_devices(tmp_path) -> None:
|
|
server, token_store, tracker = _build_mcp_components(tmp_path)
|
|
# Acquire a lease without going through HTTP — tracker exposes a direct API.
|
|
tracker.acquire("phone-1", "test-session")
|
|
client, _ = _build_client(
|
|
tmp_path,
|
|
mcp_server=server,
|
|
mcp_token_store=token_store,
|
|
mcp_busy_tracker=tracker,
|
|
)
|
|
_login(client)
|
|
|
|
response = client.get("/api/status")
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert "mcp_busy_devices" in body
|
|
assert "phone-1" in body["mcp_busy_devices"]
|
|
assert body["mcp_endpoint"] == "/mcp"
|
|
|
|
|
|
def test_api_status_omits_mcp_fields_when_components_missing(tmp_path) -> None:
|
|
client, _ = _build_client(tmp_path)
|
|
_login(client)
|
|
|
|
response = client.get("/api/status")
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["mcp_busy_devices"] == []
|
|
assert body["mcp_endpoint"] is None
|
|
|
|
|
|
def test_dashboard_renders_mcp_status_row(tmp_path) -> None:
|
|
server, token_store, tracker = _build_mcp_components(tmp_path)
|
|
tracker.acquire("phone-1", "test-session")
|
|
client, _ = _build_client(
|
|
tmp_path,
|
|
mcp_server=server,
|
|
mcp_token_store=token_store,
|
|
mcp_busy_tracker=tracker,
|
|
)
|
|
_login(client)
|
|
|
|
response = client.get("/")
|
|
assert response.status_code == 200
|
|
text = response.text
|
|
assert "<td>MCP</td>" in text
|
|
assert "/mcp" in text
|
|
assert "phone-1" in text
|
|
|
|
|
|
def test_dashboard_renders_mcp_not_configured_when_components_missing(
|
|
tmp_path,
|
|
) -> None:
|
|
client, _ = _build_client(tmp_path)
|
|
_login(client)
|
|
|
|
response = client.get("/")
|
|
assert response.status_code == 200
|
|
text = response.text
|
|
assert "<td>MCP</td>" in text
|
|
assert "not configured" in text
|
|
|
|
|
|
def test_mcp_endpoint_unauthorized_without_bearer_token(tmp_path) -> None:
|
|
server, token_store, tracker = _build_mcp_components(tmp_path)
|
|
client, _ = _build_client(
|
|
tmp_path,
|
|
mcp_server=server,
|
|
mcp_token_store=token_store,
|
|
mcp_busy_tracker=tracker,
|
|
)
|
|
resp = client.post("/mcp/", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_mcp_endpoint_rejects_invalid_bearer_token(tmp_path) -> None:
|
|
server, token_store, tracker = _build_mcp_components(tmp_path)
|
|
client, _ = _build_client(
|
|
tmp_path,
|
|
mcp_server=server,
|
|
mcp_token_store=token_store,
|
|
mcp_busy_tracker=tracker,
|
|
)
|
|
resp = client.post(
|
|
"/mcp/",
|
|
headers={"Authorization": "Bearer not-the-real-token"},
|
|
json={"jsonrpc": "2.0", "method": "ping", "id": 1},
|
|
)
|
|
assert resp.status_code == 401
|