Files
microsoft--magentic-ui/tests/test_input_response_files.py
wehub-resource-sync 25576b0be6
Checks (magui2.0) / python-lint (push) Failing after 1s
Checks (magui2.0) / python-test (push) Failing after 0s
Checks (magui2.0) / frontend-lint (push) Failing after 0s
CodeQL Advanced / Analyze (actions) (push) Failing after 1s
Checks (magui2.0) / python-format (push) Failing after 1s
CodeQL Advanced / Analyze (python) (push) Failing after 0s
Checks (magui2.0) / python-pyright (push) Failing after 1s
Checks (magui2.0) / frontend-format (push) Failing after 1s
Checks (magui2.0) / frontend-typecheck (push) Failing after 0s
Checks (magui2.0) / frontend-test (push) Failing after 2s
CodeQL Advanced / Analyze (javascript-typescript) (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:24:32 +08:00

356 lines
14 KiB
Python

"""Tests for handle_input_response with mid-session file uploads (issue #291).
Covers:
- Files passed with input_response are validated, registered with the
team manager via add_uploaded_files, and metadata is persisted on the
saved/broadcast user message.
- Files passed alongside an approval-pending input are ignored with a warning.
- Plain text-only input_response (no files) keeps existing behavior unchanged.
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from magentic_ui.backend.web.managers.connection import WebSocketManager
def _make_ws_manager_with_team(
team_manager: MagicMock,
run: Any | None = None,
) -> WebSocketManager:
"""Build a WebSocketManager wired with a single team manager mock.
Patches the I/O surface (`_send_message`, `_save_message`, `_get_run`,
`_set_active_if_awaiting`, `_save_approval_response`) so we can assert
on how `handle_input_response` orchestrates them without touching a
real DB or socket.
"""
mgr = WebSocketManager.__new__(WebSocketManager)
mgr._team_managers = {1: team_manager}
mgr._send_message = AsyncMock()
mgr._save_message = AsyncMock()
mgr._save_approval_response = AsyncMock()
mgr._set_active_if_awaiting = AsyncMock()
mgr._get_run = AsyncMock(return_value=run)
return mgr
@pytest.mark.asyncio
async def test_input_response_with_files_registers_and_augments() -> None:
"""Files trigger add_uploaded_files registration, construct_task
augmentation from the SERVER-VALIDATED safe refs (not raw client
payload), and attached_files metadata on the saved message."""
team = MagicMock()
team.has_pending_approval = False
team.has_pending_continuation = False
# add_uploaded_files returns the server-validated safe refs in the
# construct_task input shape — handle_input_response uses these
# (not the raw client payload) to build attached_files_json so a
# malicious client can't smuggle cross-tenant paths into chat metadata.
team.add_uploaded_files = MagicMock(
return_value=[
{
"name": "data.csv",
"path": "files/user/u1/s1/1/data.csv",
"type": "file",
"uploaded": True,
}
]
)
team.provide_input = MagicMock()
run_obj = MagicMock(id=1, user_id="u1", session_id="s1")
mgr = _make_ws_manager_with_team(team, run=run_obj)
files = [
{"name": "data.csv", "path": "files/user/u1/s1/1/data.csv", "uploaded": True},
]
await mgr.handle_input_response(1, "please use this", files=files)
# add_uploaded_files called with the raw client refs (filtered to dicts)
team.add_uploaded_files.assert_called_once()
call_args = team.add_uploaded_files.call_args
raw_refs = call_args.args[0]
assert [f["name"] for f in raw_refs] == ["data.csv"]
assert call_args.kwargs.get("run") is run_obj
# provide_input got the augmented response (with "Attached file:" line),
# built from the server-validated safe refs.
team.provide_input.assert_called_once()
augmented = team.provide_input.call_args.args[0]
assert "please use this" in augmented
assert "Attached file: data.csv" in augmented
# Broadcast: clean content + attached_files metadata (built from safe refs)
mgr._send_message.assert_awaited_once()
broadcast_payload = mgr._send_message.await_args.args[1]
assert broadcast_payload["type"] == "message"
assert broadcast_payload["data"]["content"] == "please use this"
assert broadcast_payload["data"]["source"] == "user"
metadata = broadcast_payload["data"]["metadata"]
parsed = json.loads(metadata["attached_files"])
assert parsed[0]["name"] == "data.csv"
assert parsed[0]["uploaded"] is True
# Saved message: same shape + type marker
mgr._save_message.assert_awaited_once()
save_payload = mgr._save_message.await_args.args[1]
assert save_payload["type"] == "user_message"
assert save_payload["content"] == "please use this"
assert (
json.loads(save_payload["metadata"]["attached_files"])[0]["name"] == "data.csv"
)
mgr._set_active_if_awaiting.assert_awaited_once_with(1)
@pytest.mark.asyncio
async def test_input_response_no_files_emits_empty_attached_files() -> None:
"""Without files, message is still broadcast/saved with
metadata.attached_files = '[]' so the wire format matches the
start-task path. provide_input receives the raw response."""
team = MagicMock()
team.has_pending_approval = False
team.has_pending_continuation = False
team.provide_input = MagicMock()
team.add_uploaded_files = MagicMock()
mgr = _make_ws_manager_with_team(team)
await mgr.handle_input_response(1, "just text")
team.add_uploaded_files.assert_not_called()
team.provide_input.assert_called_once_with("just text")
broadcast_payload = mgr._send_message.await_args.args[1]
assert broadcast_payload["data"]["content"] == "just text"
# Always emit attached_files metadata ("[]" when no attachments) so
# the wire format is uniform across start-task and input-response.
assert json.loads(broadcast_payload["data"]["metadata"]["attached_files"]) == []
save_payload = mgr._save_message.await_args.args[1]
assert save_payload["content"] == "just text"
assert json.loads(save_payload["metadata"]["attached_files"]) == []
@pytest.mark.asyncio
async def test_input_response_empty_files_list_emits_empty_attached_files() -> None:
"""An empty/no-valid-entries list shouldn't trigger add_uploaded_files,
but the message still gets metadata.attached_files = '[]'."""
team = MagicMock()
team.has_pending_approval = False
team.has_pending_continuation = False
team.provide_input = MagicMock()
team.add_uploaded_files = MagicMock()
mgr = _make_ws_manager_with_team(team)
await mgr.handle_input_response(1, "hi", files=[])
team.add_uploaded_files.assert_not_called()
team.provide_input.assert_called_once_with("hi")
save_payload = mgr._save_message.await_args.args[1]
assert json.loads(save_payload["metadata"]["attached_files"]) == []
@pytest.mark.asyncio
async def test_input_response_with_approval_pending_ignores_files(caplog) -> None:
"""When the team manager is awaiting an approval, file attachments
are dropped (with a warning) and the response is treated as an NL
approval."""
team = MagicMock()
team.has_pending_approval = True
team.has_pending_continuation = False
team.provide_input = MagicMock()
team.add_uploaded_files = MagicMock()
mgr = _make_ws_manager_with_team(team)
await mgr.handle_input_response(
1,
"yes",
files=[{"name": "x.csv", "path": "files/user/u/s/1/x.csv"}],
)
# Files are ignored — add_uploaded_files NOT called
team.add_uploaded_files.assert_not_called()
# Approval path: _save_approval_response is called, regular send/save are not
mgr._save_approval_response.assert_awaited_once()
mgr._send_message.assert_not_called()
mgr._save_message.assert_not_called()
# provide_input still gets the raw response
team.provide_input.assert_called_once_with("yes")
@pytest.mark.asyncio
async def test_input_response_invalid_file_entries_filtered() -> None:
"""Non-dict entries in the files list are filtered out before reaching
add_uploaded_files."""
team = MagicMock()
team.has_pending_approval = False
team.has_pending_continuation = False
team.provide_input = MagicMock()
team.add_uploaded_files = MagicMock(return_value=[])
run_obj = MagicMock(id=1, user_id="u1", session_id="s1")
mgr = _make_ws_manager_with_team(team, run=run_obj)
await mgr.handle_input_response(
1,
"use these",
files=[
"not-a-dict", # type: ignore[list-item]
{"name": "a.csv", "path": "files/user/u1/s1/1/a.csv", "uploaded": True},
42, # type: ignore[list-item]
],
)
team.add_uploaded_files.assert_called_once()
forwarded = team.add_uploaded_files.call_args.args[0]
assert [f["name"] for f in forwarded] == ["a.csv"]
@pytest.mark.asyncio
async def test_input_response_unknown_run_logs_warning(caplog) -> None:
"""If the team manager is gone, handle_input_response should log a
warning and not raise."""
mgr = WebSocketManager.__new__(WebSocketManager)
mgr._team_managers = {} # no team manager for this run
mgr._send_message = AsyncMock()
mgr._save_message = AsyncMock()
mgr._save_approval_response = AsyncMock()
mgr._set_active_if_awaiting = AsyncMock()
mgr._get_run = AsyncMock(return_value=None)
await mgr.handle_input_response(99, "hello")
mgr._send_message.assert_not_called()
mgr._save_message.assert_not_called()
mgr._set_active_if_awaiting.assert_not_called()
@pytest.mark.asyncio
async def test_input_response_files_dropped_when_run_missing_in_db() -> None:
"""Copilot review on PR #600: if `_get_run` returns None we must NOT
fall back to the wider `app_dir/files/user` validation scope (which
would let cross-run paths slip through). Drop attachments entirely,
keep `attached_files` metadata as "[]", but still send the text reply
and unblock the agent.
"""
team = MagicMock()
team.has_pending_approval = False
team.has_pending_continuation = False
team.provide_input = MagicMock()
team.add_uploaded_files = MagicMock()
mgr = _make_ws_manager_with_team(team, run=None) # _get_run -> None
await mgr.handle_input_response(
1,
"still send my reply",
files=[
{"name": "evil.csv", "path": "files/user/u/s/r/evil.csv", "uploaded": True}
],
)
# add_uploaded_files must NOT be called when run lookup fails
team.add_uploaded_files.assert_not_called()
# Text reply still unblocks the agent
team.provide_input.assert_called_once_with("still send my reply")
# attached_files stays as "[]" — no leaked metadata
save_payload = mgr._save_message.await_args.args[1]
assert json.loads(save_payload["metadata"]["attached_files"]) == []
broadcast_payload = mgr._send_message.await_args.args[1]
assert json.loads(broadcast_payload["data"]["metadata"]["attached_files"]) == []
@pytest.mark.asyncio
async def test_input_response_uses_safe_refs_not_raw_client_payload() -> None:
"""Copilot review on PR #600: the persisted/broadcast attached_files
metadata must come from the SERVER-validated safe refs returned by
add_uploaded_files, NOT the raw client payload. This prevents a
malicious client from sending a path pointing to another run's files
and having the chat render a clickable chip linking to it.
"""
team = MagicMock()
team.has_pending_approval = False
team.has_pending_continuation = False
team.provide_input = MagicMock()
# Client sends two refs; server-side validation only accepts the
# first (the second targets another run's directory). The mock
# mirrors what real add_uploaded_files would do.
team.add_uploaded_files = MagicMock(
return_value=[
{
"name": "ok.csv",
"path": "files/user/u1/s1/1/ok.csv",
"type": "file",
"uploaded": True,
}
]
)
run_obj = MagicMock(id=1, user_id="u1", session_id="s1")
mgr = _make_ws_manager_with_team(team, run=run_obj)
raw_files = [
{"name": "ok.csv", "path": "files/user/u1/s1/1/ok.csv", "uploaded": True},
{
"name": "secret.csv",
"path": "files/user/other/s/r/secret.csv",
"uploaded": True,
},
]
await mgr.handle_input_response(1, "use these", files=raw_files)
# add_uploaded_files received the full client payload (it does its
# own per-entry validation internally).
team.add_uploaded_files.assert_called_once()
forwarded = team.add_uploaded_files.call_args.args[0]
assert len(forwarded) == 2
# But the broadcast/saved metadata only contains the safe ref. The
# cross-tenant `secret.csv` path NEVER reaches the wire.
save_payload = mgr._save_message.await_args.args[1]
parsed = json.loads(save_payload["metadata"]["attached_files"])
assert [f["name"] for f in parsed] == ["ok.csv"]
assert all("other" not in f.get("path", "") for f in parsed)
# Same check on the broadcast payload
broadcast_payload = mgr._send_message.await_args.args[1]
broadcast_parsed = json.loads(
broadcast_payload["data"]["metadata"]["attached_files"]
)
assert [f["name"] for f in broadcast_parsed] == ["ok.csv"]
# Agent prompt augmentation only mentions the safe file
augmented = team.provide_input.call_args.args[0]
assert "Attached file: ok.csv" in augmented
assert "secret.csv" not in augmented
@pytest.mark.asyncio
async def test_input_response_no_safe_refs_keeps_attached_files_empty() -> None:
"""If add_uploaded_files validates everything to nothing (e.g. all
paths fail validation), attached_files stays "[]" and provide_input
receives the raw response (no augmentation).
"""
team = MagicMock()
team.has_pending_approval = False
team.has_pending_continuation = False
team.provide_input = MagicMock()
team.add_uploaded_files = MagicMock(return_value=[]) # all rejected
run_obj = MagicMock(id=1, user_id="u1", session_id="s1")
mgr = _make_ws_manager_with_team(team, run=run_obj)
await mgr.handle_input_response(
1,
"hello",
files=[
{"name": "bad", "path": "../../etc/passwd", "uploaded": True},
],
)
team.add_uploaded_files.assert_called_once()
team.provide_input.assert_called_once_with("hello") # NOT augmented
save_payload = mgr._save_message.await_args.args[1]
assert json.loads(save_payload["metadata"]["attached_files"]) == []