Adds a CSRF-protected task submission form to the local Console Tasks page # Conflicts: # apps/device-host-agent/host_agent/app.py
This commit is contained in:
@@ -11,6 +11,8 @@ from cloud.internal_api.models import AssignmentModel, DeviceSnapshotModel
|
||||
from host_agent.client import (
|
||||
HostAgentClient,
|
||||
HostAgentEnrollmentClient,
|
||||
HostAgentAPIError,
|
||||
HostTaskSubmissionUnknownError,
|
||||
StaleLeaseError,
|
||||
)
|
||||
from host_agent.config import HostAgentConfig
|
||||
@@ -172,6 +174,161 @@ def test_result_report_retries_identical_payload_after_response_loss() -> None:
|
||||
assert payloads[0]["failure_reason"] == "planner unavailable"
|
||||
|
||||
|
||||
def test_submit_self_task_posts_once_and_returns_task_id() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(201, json={"task_id": "task-cloud-1"})
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler),
|
||||
base_url="https://control.example",
|
||||
) as http_client:
|
||||
client = HostAgentClient(_config(), http_client=http_client)
|
||||
response = await client.submit_self_task(
|
||||
goal="open settings",
|
||||
device_id=None,
|
||||
)
|
||||
|
||||
assert response.task_id == "task-cloud-1"
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert len(requests) == 1
|
||||
assert requests[0].url.path == "/internal/v1/hosts/host-a/tasks"
|
||||
assert requests[0].headers["authorization"] == "Bearer host-secret"
|
||||
payload = json.loads(requests[0].content)
|
||||
assert payload == {
|
||||
"host_id": "host-a",
|
||||
"goal": "open settings",
|
||||
"device_id": None,
|
||||
}
|
||||
|
||||
|
||||
def test_submit_self_task_does_not_retry_transport_failure() -> None:
|
||||
attempts = 0
|
||||
sleeps: list[float] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise httpx.ConnectError("network down", request=request)
|
||||
|
||||
async def sleep(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler),
|
||||
base_url="https://control.example",
|
||||
) as http_client:
|
||||
client = HostAgentClient(_config(), http_client=http_client, sleep=sleep)
|
||||
with pytest.raises(HostTaskSubmissionUnknownError):
|
||||
await client.submit_self_task(goal="open settings")
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert attempts == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_submit_self_task_treats_5xx_as_unknown_outcome_without_retry() -> None:
|
||||
attempts = 0
|
||||
sleeps: list[float] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return httpx.Response(502, json={"detail": "bad gateway"})
|
||||
|
||||
async def sleep(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler),
|
||||
base_url="https://control.example",
|
||||
) as http_client:
|
||||
client = HostAgentClient(_config(), http_client=http_client, sleep=sleep)
|
||||
with pytest.raises(HostTaskSubmissionUnknownError):
|
||||
await client.submit_self_task(goal="open settings")
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert attempts == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_submit_self_task_raises_definitive_error_on_4xx_rejection() -> None:
|
||||
attempts = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return httpx.Response(
|
||||
403,
|
||||
json={"detail": "Host self-submission is disabled"},
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler),
|
||||
base_url="https://control.example",
|
||||
) as http_client:
|
||||
client = HostAgentClient(_config(), http_client=http_client)
|
||||
with pytest.raises(HostAgentAPIError) as error:
|
||||
await client.submit_self_task(goal="open settings")
|
||||
assert "host-secret" not in str(error.value)
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert attempts == 1
|
||||
|
||||
|
||||
def test_submit_self_task_treats_malformed_success_as_unknown() -> None:
|
||||
attempts = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return httpx.Response(201, json={"unexpected": "shape"})
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler),
|
||||
base_url="https://control.example",
|
||||
) as http_client:
|
||||
client = HostAgentClient(_config(), http_client=http_client)
|
||||
with pytest.raises(HostTaskSubmissionUnknownError):
|
||||
await client.submit_self_task(goal="open settings")
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert attempts == 1
|
||||
|
||||
|
||||
def test_submit_self_task_does_not_duplicate_when_response_is_lost() -> None:
|
||||
attempts = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise httpx.ReadError("response lost", request=request)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler),
|
||||
base_url="https://control.example",
|
||||
) as http_client:
|
||||
client = HostAgentClient(
|
||||
_config(),
|
||||
http_client=http_client,
|
||||
sleep=lambda delay: asyncio.sleep(0),
|
||||
)
|
||||
with pytest.raises(HostTaskSubmissionUnknownError):
|
||||
await client.submit_self_task(goal="open settings")
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert attempts == 1
|
||||
|
||||
|
||||
def test_bootstrap_client_directly_enrolls_and_enrolls_device() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
host_attempts = 0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from host_agent.history import ConsoleHistoryStore
|
||||
@@ -67,3 +68,71 @@ def test_history_store_uses_injected_now_for_occurred_at(tmp_path) -> None:
|
||||
entries = store.list_recent()
|
||||
|
||||
assert entries[0]["occurred_at"] == fixed_now.isoformat()
|
||||
|
||||
|
||||
def test_history_store_records_task_submission_with_device(tmp_path) -> None:
|
||||
store = ConsoleHistoryStore(tmp_path / "history.sqlite3")
|
||||
|
||||
store.record_task_submission(task_id="task-cloud-1", device_id="device-cloud-a")
|
||||
|
||||
entries = store.list_recent()
|
||||
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["kind"] == "task_submission"
|
||||
assert entries[0]["summary"] == "task submitted: task-cloud-1 on device-cloud-a"
|
||||
assert entries[0]["detail"] == {
|
||||
"task_id": "task-cloud-1",
|
||||
"device_id": "device-cloud-a",
|
||||
}
|
||||
|
||||
|
||||
def test_history_store_records_task_submission_without_device(tmp_path) -> None:
|
||||
store = ConsoleHistoryStore(tmp_path / "history.sqlite3")
|
||||
|
||||
store.record_task_submission(task_id="task-cloud-2", device_id=None)
|
||||
|
||||
entries = store.list_recent()
|
||||
|
||||
assert entries[0]["summary"] == (
|
||||
"task submitted: task-cloud-2 (automatic device)"
|
||||
)
|
||||
assert entries[0]["detail"] == {"task_id": "task-cloud-2"}
|
||||
|
||||
|
||||
def test_history_store_task_submission_redacts_goal_and_secrets(tmp_path) -> None:
|
||||
store = ConsoleHistoryStore(tmp_path / "history.sqlite3")
|
||||
secret_goal = (
|
||||
"rotate secret-XYZ-abcdef-very-secret "
|
||||
"cookie=session-abc; lease=lease-stale"
|
||||
)
|
||||
|
||||
store.record_task_submission(task_id="task-cloud-3", device_id="device-cloud-a")
|
||||
store.record_heartbeat(device_count=1)
|
||||
|
||||
entries = store.list_recent()
|
||||
rendered = "\n".join(
|
||||
repr(entry["summary"]) + " " + json.dumps(entry["detail"])
|
||||
for entry in entries
|
||||
)
|
||||
|
||||
assert secret_goal not in rendered
|
||||
assert "session-abc" not in rendered
|
||||
assert "lease-stale" not in rendered
|
||||
|
||||
|
||||
def test_history_store_task_submissions_prune_beyond_limit(tmp_path) -> None:
|
||||
store = ConsoleHistoryStore(tmp_path / "history.sqlite3", limit=3)
|
||||
|
||||
for index in range(5):
|
||||
store.record_task_submission(
|
||||
task_id=f"task-{index}", device_id=f"device-{index}"
|
||||
)
|
||||
|
||||
entries = store.list_recent()
|
||||
|
||||
assert len(entries) == 3
|
||||
assert [entry["detail"]["task_id"] for entry in entries] == [
|
||||
"task-4",
|
||||
"task-3",
|
||||
"task-2",
|
||||
]
|
||||
@@ -1,12 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cloud.internal_api.models import AssignmentModel
|
||||
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
|
||||
@@ -15,11 +17,20 @@ 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 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]]
|
||||
|
||||
def _build_client(tmp_path, *, create_account: bool = True) -> tuple[TestClient, dict]:
|
||||
|
||||
def _build_client(
|
||||
tmp_path,
|
||||
*,
|
||||
create_account: bool = True,
|
||||
submit_self_task: TaskSubmissionCallable | None = None,
|
||||
include_metadata_store: bool = True,
|
||||
) -> tuple[TestClient, dict]:
|
||||
config = HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
host_id="host-a",
|
||||
@@ -36,6 +47,11 @@ def _build_client(tmp_path, *, create_account: bool = True) -> tuple[TestClient,
|
||||
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,
|
||||
@@ -47,6 +63,8 @@ def _build_client(tmp_path, *, create_account: bool = True) -> tuple[TestClient,
|
||||
status_tracker=status_tracker,
|
||||
session_manager=session_manager,
|
||||
enrollment_client=None,
|
||||
submit_self_task=submit_self_task,
|
||||
metadata_store=metadata_store,
|
||||
)
|
||||
client = TestClient(app)
|
||||
context = {
|
||||
@@ -56,6 +74,7 @@ def _build_client(tmp_path, *, create_account: bool = True) -> tuple[TestClient,
|
||||
"history_store": history_store,
|
||||
"session_manager": session_manager,
|
||||
"status_tracker": status_tracker,
|
||||
"metadata_store": metadata_store,
|
||||
}
|
||||
return client, context
|
||||
|
||||
@@ -309,3 +328,472 @@ def test_logout_invalidates_session_so_subsequent_request_redirects_to_login(
|
||||
|
||||
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
|
||||
assert "bearer-token-deadbeef" not in follow.text
|
||||
|
||||
Reference in New Issue
Block a user