Files
microsoft--magentic-ui/tests/test_file_upload_tracking.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

1096 lines
44 KiB
Python

"""Tests for file upload and generated file tracking.
Covers:
- sanitize_filename: path traversal prevention
- construct_task: task string augmentation with file references
- get_modified_files: timestamp field in returned dicts
- _file_to_ws: file dict conversion to WebSocket format
- file_generated_props: message schema factory
- _detect_changed_files: created/modified detection (incl. issue #567)
"""
from __future__ import annotations
import json
import os
import tempfile
import time
import pytest
from magentic_ui.backend.utils.utils import (
ModifiedFileInfo,
construct_task,
find_available_filename,
get_modified_files,
sanitize_filename,
)
from magentic_ui.agents.message_schemas import (
file_generated_props,
)
from magentic_ui.backend.teammanager.teammanager import (
_detect_changed_files,
_file_to_ws,
)
# =============================================================================
# sanitize_filename
# =============================================================================
class TestSanitizeFilename:
def test_simple_filename(self) -> None:
assert sanitize_filename("report.csv") == "report.csv"
def test_strips_directory_components(self) -> None:
assert sanitize_filename("../../etc/passwd") == "passwd"
def test_strips_absolute_path(self) -> None:
assert sanitize_filename("/etc/shadow") == "shadow"
def test_rejects_empty(self) -> None:
with pytest.raises(ValueError):
sanitize_filename("")
def test_rejects_dot(self) -> None:
with pytest.raises(ValueError):
sanitize_filename(".")
def test_rejects_dotdot(self) -> None:
with pytest.raises(ValueError):
sanitize_filename("..")
def test_rejects_hidden_file(self) -> None:
with pytest.raises(ValueError):
sanitize_filename(".env")
def test_rejects_hidden_after_strip(self) -> None:
with pytest.raises(ValueError):
sanitize_filename("/some/path/.secret")
def test_nested_forward_slash_path(self) -> None:
# os.path.basename strips forward-slash directory components
result = sanitize_filename("dir/subdir/report.csv")
assert result == "report.csv"
def test_filename_with_spaces(self) -> None:
assert sanitize_filename("my report.csv") == "my report.csv"
def test_filename_with_special_chars(self) -> None:
assert sanitize_filename("data (1).xlsx") == "data (1).xlsx"
def test_windows_backslash_path(self) -> None:
# Browsers may send Windows-style paths (e.g., C:\fakepath\file.txt)
result = sanitize_filename(r"C:\fakepath\report.csv")
assert result == "report.csv"
def test_windows_backslash_traversal(self) -> None:
result = sanitize_filename(r"..\..\etc\passwd")
assert result == "passwd"
def test_mixed_separators(self) -> None:
result = sanitize_filename(r"dir/subdir\file.txt")
assert result == "file.txt"
# =============================================================================
# find_available_filename
# =============================================================================
class TestFindAvailableFilename:
def test_returns_unchanged_when_no_collision(self, tmp_path) -> None:
assert find_available_filename(tmp_path, "report.txt") == "report.txt"
def test_appends_suffix_on_disk_collision(self, tmp_path) -> None:
(tmp_path / "report.txt").write_text("existing")
assert find_available_filename(tmp_path, "report.txt") == "report_1.txt"
def test_increments_suffix_until_free(self, tmp_path) -> None:
(tmp_path / "report.txt").write_text("a")
(tmp_path / "report_1.txt").write_text("b")
(tmp_path / "report_2.txt").write_text("c")
assert find_available_filename(tmp_path, "report.txt") == "report_3.txt"
def test_handles_filename_without_extension(self, tmp_path) -> None:
(tmp_path / "README").write_text("a")
assert find_available_filename(tmp_path, "README") == "README_1"
def test_preserves_compound_extension_stem(self, tmp_path) -> None:
# Matches Path.suffix behavior: only the final extension is preserved.
(tmp_path / "archive.tar.gz").write_text("a")
assert find_available_filename(tmp_path, "archive.tar.gz") == "archive.tar_1.gz"
def test_avoids_reserved_names_within_batch(self, tmp_path) -> None:
# Simulates a single multi-file upload: the first file has been
# accepted (added to ``reserved``) but not yet written to disk.
first = find_available_filename(tmp_path, "report.txt", reserved=[])
assert first == "report.txt"
second = find_available_filename(tmp_path, "report.txt", reserved=[first])
assert second == "report_1.txt"
third = find_available_filename(
tmp_path, "report.txt", reserved=[first, second]
)
assert third == "report_2.txt"
def test_reserved_combines_with_disk(self, tmp_path) -> None:
(tmp_path / "report.txt").write_text("on-disk")
# report_1.txt is already claimed earlier in the same batch.
result = find_available_filename(
tmp_path, "report.txt", reserved=["report_1.txt"]
)
assert result == "report_2.txt"
# =============================================================================
# construct_task
# =============================================================================
class TestConstructTask:
def test_no_files(self) -> None:
result = construct_task("Do something")
assert result["agent_task"] == "Do something"
assert result["attached_files"] == []
assert json.loads(result["attached_files_json"]) == []
def test_none_files(self) -> None:
result = construct_task("Do something", files=None)
assert result["agent_task"] == "Do something"
assert result["attached_files"] == []
assert json.loads(result["attached_files_json"]) == []
def test_empty_files(self) -> None:
result = construct_task("Do something", files=[])
assert result["agent_task"] == "Do something"
assert result["attached_files"] == []
assert json.loads(result["attached_files_json"]) == []
def test_uploaded_file_appended(self) -> None:
files = [{"name": "data.csv", "path": "/workspace/data.csv", "uploaded": True}]
result = construct_task("Analyze this", files=files)
assert "Attached file: data.csv" in result["agent_task"]
assert "Analyze this" in result["agent_task"]
assert [f["name"] for f in result["attached_files"]] == ["data.csv"]
attached = json.loads(result["attached_files_json"])
assert len(attached) == 1
assert attached[0]["name"] == "data.csv"
assert attached[0]["uploaded"] is True
def test_multiple_uploaded_files(self) -> None:
files = [
{"name": "a.csv", "path": "/workspace/a.csv", "uploaded": True},
{"name": "b.txt", "path": "/workspace/b.txt", "uploaded": True},
]
result = construct_task("Process files", files=files)
assert "Attached file: a.csv" in result["agent_task"]
assert "Attached file: b.txt" in result["agent_task"]
assert {f["name"] for f in result["attached_files"]} == {"a.csv", "b.txt"}
attached = json.loads(result["attached_files_json"])
assert len(attached) == 2
def test_non_uploaded_file_ignored(self) -> None:
files = [{"name": "inline.txt", "content": "abc"}]
result = construct_task("Read this", files=files)
assert result["agent_task"] == "Read this"
assert result["attached_files"] == []
assert json.loads(result["attached_files_json"]) == []
def test_uploaded_without_path_ignored(self) -> None:
files = [{"name": "orphan.csv", "uploaded": True}]
result = construct_task("Check", files=files)
assert result["agent_task"] == "Check"
assert result["attached_files"] == []
def test_path_traversal_sanitized_to_basename(self) -> None:
"""../../etc/passwd is sanitized to 'passwd' (valid basename), both files appear."""
files = [
{"name": "../../etc/passwd", "path": "/evil", "uploaded": True},
{"name": "good.csv", "path": "/workspace/good.csv", "uploaded": True},
]
result = construct_task("Do work", files=files)
assert "Attached file: passwd" in result["agent_task"]
assert "Attached file: good.csv" in result["agent_task"]
assert {f["name"] for f in result["attached_files"]} == {"passwd", "good.csv"}
def test_hidden_filename_skipped(self) -> None:
files = [
{"name": ".env", "path": "/workspace/.env", "uploaded": True},
{"name": "ok.txt", "path": "/workspace/ok.txt", "uploaded": True},
]
result = construct_task("Do work", files=files)
assert ".env" not in result["agent_task"]
assert "ok.txt" in result["agent_task"]
assert {f["name"] for f in result["attached_files"]} == {"ok.txt"}
def test_attached_files_json_has_path_and_type(self) -> None:
"""Verify attached_files_json includes path and type fields."""
files = [
{
"name": "report.pdf",
"path": "/workspace/report.pdf",
"type": "application/pdf",
"uploaded": True,
}
]
result = construct_task("Summarize", files=files)
attached = json.loads(result["attached_files_json"])
assert len(attached) == 1
assert attached[0]["name"] == "report.pdf"
assert attached[0]["type"] == "application/pdf"
assert attached[0]["path"] == "/workspace/report.pdf"
assert attached[0]["uploaded"] is True
def test_non_string_name_uses_fallback(self) -> None:
"""Non-string name (e.g., int/None) should fall back to 'unknown.file' without crashing."""
files = [
{"name": 12345, "path": "/workspace/12345", "uploaded": True},
{"name": None, "path": "/workspace/null", "uploaded": True},
{"name": "good.csv", "path": "/workspace/good.csv", "uploaded": True},
]
result = construct_task("Process", files=files)
# Non-string names get sanitized to "unknown.file" and still attached
assert "good.csv" in result["agent_task"]
assert "good.csv" in {f["name"] for f in result["attached_files"]}
# =============================================================================
# get_modified_files — timestamp field
# =============================================================================
class TestGetModifiedFiles:
def test_returns_timestamp_field(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
# Create a test file
filepath = os.path.join(tmpdir, "test.txt")
with open(filepath, "w") as f:
f.write("hello")
mtime = os.path.getmtime(filepath)
results = get_modified_files(0, time.time() + 1, source_dir=tmpdir)
assert len(results) == 1
assert results[0]["name"] == "test.txt"
assert "timestamp" in results[0]
assert results[0]["timestamp"] == mtime
def test_timestamp_is_float(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, "data.csv")
with open(filepath, "w") as f:
f.write("a,b,c")
results = get_modified_files(0, time.time() + 1, source_dir=tmpdir)
assert isinstance(results[0]["timestamp"], float)
def test_ignores_pyc_files(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
with open(os.path.join(tmpdir, "module.pyc"), "w") as f:
f.write("")
with open(os.path.join(tmpdir, "script.py"), "w") as f:
f.write("pass")
results = get_modified_files(0, time.time() + 1, source_dir=tmpdir)
names = [r["name"] for r in results]
assert "script.py" in names
assert "module.pyc" not in names
def test_file_type_detection(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
for name in ["data.csv", "image.png", "doc.pdf", "code.py"]:
with open(os.path.join(tmpdir, name), "w") as f:
f.write("")
results = get_modified_files(0, time.time() + 1, source_dir=tmpdir)
type_map = {r["name"]: r["type"] for r in results}
assert type_map["data.csv"] == "csv"
assert type_map["image.png"] == "image"
assert type_map["doc.pdf"] == "pdf"
assert type_map["code.py"] == "code"
def test_empty_directory(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
results = get_modified_files(0, time.time() + 1, source_dir=tmpdir)
assert results == []
# =============================================================================
# _file_to_ws
# =============================================================================
class TestFileToWs:
def test_converts_fields(self) -> None:
f: ModifiedFileInfo = {
"name": "report.csv",
"path": "files/user/uid/sid/rid/report.csv",
"short_path": "files/user/uid/sid/rid/report.csv",
"timestamp": 1709561234.567,
"extension": "csv",
"type": "csv",
}
result = _file_to_ws(f)
assert result == {
"name": "report.csv",
"url": "/files/user/uid/sid/rid/report.csv",
"timestamp": 1709561234.567,
"extension": "csv",
"file_type": "csv",
}
def test_url_has_leading_slash(self) -> None:
f: ModifiedFileInfo = {
"name": "x.txt",
"path": "files/user/a/b/c/x.txt",
"short_path": "files/user/a/b/c/x.txt",
"timestamp": 0.0,
"extension": "txt",
"type": "code",
}
result = _file_to_ws(f)
assert result["url"].startswith("/")
def test_type_renamed_to_file_type(self) -> None:
"""Verify 'type' → 'file_type' rename to avoid collision with WS message type."""
f: ModifiedFileInfo = {
"name": "a.py",
"path": "files/user/u/s/r/a.py",
"short_path": "files/user/u/s/r/a.py",
"timestamp": 1.0,
"extension": "py",
"type": "code",
}
result = _file_to_ws(f)
assert "file_type" in result
assert "type" not in result
# =============================================================================
# file_generated_props
# =============================================================================
class TestFileGeneratedProps:
def test_structure(self) -> None:
files = [{"name": "a.csv", "url": "/files/a.csv", "action": "created"}]
props = file_generated_props("system", files)
assert props["source"] == "system"
assert props["type"] == "file"
assert json.loads(props["files"]) == files
def test_files_is_json_string(self) -> None:
props = file_generated_props("system", [])
assert isinstance(props["files"], str)
assert json.loads(props["files"]) == []
def test_multiple_files(self) -> None:
files = [
{"name": "a.csv", "action": "created"},
{"name": "b.csv", "action": "modified"},
]
props = file_generated_props("system", files)
parsed = json.loads(props["files"])
assert len(parsed) == 2
assert parsed[0]["action"] == "created"
assert parsed[1]["action"] == "modified"
def test_dict_conversion_for_mutable_mapping(self) -> None:
"""Verify dict() conversion works."""
props = file_generated_props("system", [])
as_dict = dict(props)
assert isinstance(as_dict, dict)
assert as_dict["type"] == "file"
def test_summary_omitted_by_default(self) -> None:
"""Per-step file emits should NOT carry the summary flag."""
props = file_generated_props("system", [{"name": "x.csv"}])
assert "summary" not in props
def test_summary_set_when_flag_true(self) -> None:
"""End-of-run aggregated emit must set summary=True so the frontend
renders the 'Files created or modified' header."""
props = file_generated_props("system", [{"name": "x.csv"}], summary=True)
assert props.get("summary") is True
def test_summary_omitted_when_flag_false(self) -> None:
"""summary=False must omit the field (treated identically to default)."""
props = file_generated_props("system", [{"name": "x.csv"}], summary=False)
assert "summary" not in props
def test_uploaded_files_omitted_by_default(self) -> None:
"""Per-step emits must not carry uploaded_files."""
props = file_generated_props("system", [{"name": "x.csv"}])
assert "uploaded_files" not in props
def test_uploaded_files_serialized_on_summary(self) -> None:
"""Summary must serialize uploaded files as JSON for the frontend."""
uploaded = [
{
"name": "input.csv",
"url": "/files/user/u/s/r/input.csv",
"timestamp": 1.0,
"extension": "csv",
"file_type": "csv",
}
]
props = file_generated_props(
"system", [{"name": "out.md"}], summary=True, uploaded_files=uploaded
)
assert props.get("summary") is True
assert isinstance(props.get("uploaded_files"), str)
assert json.loads(props["uploaded_files"]) == uploaded
def test_empty_uploaded_files_omitted(self) -> None:
"""An empty uploaded_files list must not produce the field."""
props = file_generated_props(
"system", [{"name": "out.md"}], summary=True, uploaded_files=[]
)
assert "uploaded_files" not in props
# =============================================================================
# _detect_changed_files — file change detection algorithm (issue #567)
# =============================================================================
def _make_file(path: str, mtime: float, name: str | None = None) -> ModifiedFileInfo:
"""Helper to build a ModifiedFileInfo for tests."""
base = name or os.path.basename(path)
ext = base.rsplit(".", 1)[-1] if "." in base else ""
return {
"name": base,
"path": path,
"short_path": path,
"timestamp": mtime,
"extension": ext,
"type": "code",
}
class TestDetectChangedFiles:
def test_new_file_emitted_as_created(self) -> None:
known: dict[str, float] = {}
current = [_make_file("files/user/u/s/r/report.md", 100.0)]
changed = _detect_changed_files(current, known)
assert len(changed) == 1
assert changed[0]["action"] == "created"
assert changed[0]["name"] == "report.md"
# known_files updated so a second pass with same mtime is a no-op
assert known["files/user/u/s/r/report.md"] == 100.0
def test_unchanged_file_not_emitted(self) -> None:
known = {"files/user/u/s/r/data.csv": 100.0}
current = [_make_file("files/user/u/s/r/data.csv", 100.0)]
changed = _detect_changed_files(current, known)
assert changed == []
def test_modified_file_emitted(self) -> None:
known = {"files/user/u/s/r/data.csv": 100.0}
current = [_make_file("files/user/u/s/r/data.csv", 200.0)]
changed = _detect_changed_files(current, known)
assert len(changed) == 1
assert changed[0]["action"] == "modified"
assert changed[0]["name"] == "data.csv"
assert known["files/user/u/s/r/data.csv"] == 200.0
def test_uploaded_file_unchanged_not_emitted(self) -> None:
"""Issue #567: a user-uploaded file present in the initial baseline
must NOT be re-emitted as 'created' when no agent has modified it.
The baseline is built from the on-disk snapshot taken right after
upload, so the file's real path+mtime is already in known_files.
"""
# Initial snapshot taken right after the user uploaded data.csv
known: dict[str, float] = {"files/user/u/s/r/data.csv": 100.0}
# Agent runs but never modifies the uploaded file; mtime unchanged
current = [_make_file("files/user/u/s/r/data.csv", 100.0)]
changed = _detect_changed_files(current, known)
assert changed == []
def test_uploaded_file_modified_by_agent_is_emitted(self) -> None:
"""Issue #567: when an agent modifies a user-uploaded file, the
change MUST surface as a 'modified' file message so the user can
download / re-open the updated file.
Previously this was suppressed by an unconditional basename-based
skip (`if name in self.uploaded_files: continue`), leaving the user
with no visibility into edits.
"""
# Initial snapshot taken right after upload
known = {"files/user/u/s/r/companies.xlsx": 100.0}
# Agent has rewritten the spreadsheet (mtime > baseline)
current = [_make_file("files/user/u/s/r/companies.xlsx", 250.0)]
changed = _detect_changed_files(current, known)
assert len(changed) == 1
assert changed[0]["action"] == "modified"
assert changed[0]["name"] == "companies.xlsx"
# URL points to the same upload location so preview/download works
assert changed[0]["url"] == "/files/user/u/s/r/companies.xlsx"
def test_skips_tmp_code_files(self) -> None:
known: dict[str, float] = {}
current = [
_make_file("files/user/u/s/r/tmp_code_abc.py", 100.0),
_make_file("files/user/u/s/r/output.py", 100.0),
]
changed = _detect_changed_files(current, known)
names = [c["name"] for c in changed]
assert "output.py" in names
assert "tmp_code_abc.py" not in names
def test_skips_supervisord_pid(self) -> None:
known: dict[str, float] = {}
current = [
_make_file("files/user/u/s/r/supervisord.pid", 100.0),
_make_file("files/user/u/s/r/result.txt", 100.0),
]
changed = _detect_changed_files(current, known)
names = [c["name"] for c in changed]
assert "result.txt" in names
assert "supervisord.pid" not in names
def test_same_basename_different_paths_tracked_independently(self) -> None:
"""Two files with the same basename in different subdirs must
track independently — that's why detection is path-keyed."""
known: dict[str, float] = {}
current = [
_make_file("files/user/u/s/r/sub_a/data.csv", 100.0),
_make_file("files/user/u/s/r/sub_b/data.csv", 100.0),
]
changed = _detect_changed_files(current, known)
assert len(changed) == 2
urls = {c["url"] for c in changed}
assert urls == {
"/files/user/u/s/r/sub_a/data.csv",
"/files/user/u/s/r/sub_b/data.csv",
}
def test_repeated_call_after_modification_is_idempotent(self) -> None:
"""After a modification is reported once, calling again with the
same current state should not re-emit (mtime now matches known)."""
known = {"files/user/u/s/r/x.md": 100.0}
current = [_make_file("files/user/u/s/r/x.md", 200.0)]
first = _detect_changed_files(current, known)
assert len(first) == 1
assert first[0]["action"] == "modified"
second = _detect_changed_files(current, known)
assert second == []
# =============================================================================
# TeamManager.set_uploaded_file_infos — path validation and reset behavior
# =============================================================================
def _make_team_manager(app_dir):
"""Build a minimal TeamManager rooted at the given app_dir for testing."""
from magentic_ui.backend.teammanager.teammanager import TeamManager
return TeamManager(app_dir=app_dir)
class TestSetUploadedFileInfos:
"""Covers Copilot review feedback on PR #574:
- Path traversal hardening (the input ultimately comes from WS payload)
- Reset semantics so a stale uploaded list never leaks into the next task
"""
def test_accepts_valid_uploaded_path(self, tmp_path) -> None:
# Real file under app_dir/files/user/...
run_dir = tmp_path / "files" / "user" / "u1" / "s1" / "r1"
run_dir.mkdir(parents=True)
(run_dir / "data.csv").write_text("a,b\n1,2\n")
tm = _make_team_manager(tmp_path)
tm.set_uploaded_file_infos(
[{"name": "data.csv", "path": "files/user/u1/s1/r1/data.csv"}]
)
assert len(tm._uploaded_file_infos) == 1
info = tm._uploaded_file_infos[0]
assert info["name"] == "data.csv"
assert info["url"] == "/files/user/u1/s1/r1/data.csv"
assert info["extension"] == "csv"
# mtime came from the real file, not the time.time() fallback
assert info["timestamp"] == (run_dir / "data.csv").stat().st_mtime
def test_rejects_path_traversal(self, tmp_path) -> None:
"""A '..' segment must not be allowed to escape files/user."""
run_dir = tmp_path / "files" / "user" / "u" / "s" / "r"
run_dir.mkdir(parents=True)
(run_dir / "ok.csv").write_text("ok")
tm = _make_team_manager(tmp_path)
tm.set_uploaded_file_infos(
[
{"name": "evil", "path": "files/user/../../etc/passwd"},
{"name": "ok.csv", "path": "files/user/u/s/r/ok.csv"},
]
)
# Only the ok entry should remain; the traversal entry is dropped
names = [i["name"] for i in tm._uploaded_file_infos]
assert "evil" not in names
assert names == ["ok.csv"]
def test_rejects_absolute_path_outside_root(self, tmp_path) -> None:
"""An absolute filesystem path outside files/user is rejected even
when the leading slash is stripped."""
tm = _make_team_manager(tmp_path)
tm.set_uploaded_file_infos([{"name": "shadow", "path": "/etc/shadow"}])
assert tm._uploaded_file_infos == []
def test_rejects_path_outside_files_user(self, tmp_path) -> None:
"""Paths under app_dir but outside files/user are rejected so
attackers can't probe siblings (e.g. config files, db)."""
tm = _make_team_manager(tmp_path)
tm.set_uploaded_file_infos([{"name": "cfg", "path": "config.yaml"}])
assert tm._uploaded_file_infos == []
def test_skips_non_string_entries(self, tmp_path) -> None:
run_dir = tmp_path / "files" / "user" / "u" / "s" / "r"
run_dir.mkdir(parents=True)
(run_dir / "good.csv").write_text("ok")
tm = _make_team_manager(tmp_path)
tm.set_uploaded_file_infos(
[
{"name": 123, "path": "files/user/u/s/r/x.csv"},
{"name": "ok", "path": None},
{"path": "files/user/u/s/r/anon"},
{"name": "good.csv", "path": "files/user/u/s/r/good.csv"},
]
)
names = [i["name"] for i in tm._uploaded_file_infos]
assert names == ["good.csv"]
def test_missing_file_is_skipped(self, tmp_path) -> None:
"""File path passes scope validation but the file isn't on disk
— entry must be dropped (not silently passed through with a
synthetic mtime), so the agent never sees an attached_file
reference it can't open."""
# Make sure the directory exists so resolve() doesn't fail spuriously
(tmp_path / "files" / "user" / "u" / "s" / "r").mkdir(parents=True)
tm = _make_team_manager(tmp_path)
tm.set_uploaded_file_infos(
[{"name": "ghost.csv", "path": "files/user/u/s/r/ghost.csv"}]
)
assert tm._uploaded_file_infos == []
def test_empty_list_clears_previous(self, tmp_path) -> None:
"""Reset semantics — second call with [] must wipe out the list
from the first call. Otherwise the next task on the same
TeamManager would emit stale uploaded_files."""
run_dir = tmp_path / "files" / "user" / "u" / "s" / "r"
run_dir.mkdir(parents=True)
(run_dir / "first.csv").write_text("x")
tm = _make_team_manager(tmp_path)
tm.set_uploaded_file_infos(
[{"name": "first.csv", "path": "files/user/u/s/r/first.csv"}]
)
assert len(tm._uploaded_file_infos) == 1
tm.set_uploaded_file_infos([])
assert tm._uploaded_file_infos == []
def test_second_call_replaces_not_extends(self, tmp_path) -> None:
"""Second non-empty call must REPLACE, not append, the prior list."""
run_dir = tmp_path / "files" / "user" / "u" / "s" / "r"
run_dir.mkdir(parents=True)
(run_dir / "a.csv").write_text("a")
(run_dir / "b.csv").write_text("b")
tm = _make_team_manager(tmp_path)
tm.set_uploaded_file_infos(
[{"name": "a.csv", "path": "files/user/u/s/r/a.csv"}]
)
tm.set_uploaded_file_infos(
[{"name": "b.csv", "path": "files/user/u/s/r/b.csv"}]
)
names = [i["name"] for i in tm._uploaded_file_infos]
assert names == ["b.csv"]
# Lightweight Run stub: set_uploaded_file_infos only reads .user_id /
# .session_id / .id, so we don't need a real SQLModel instance.
class _RunStub:
def __init__(self, user_id: str, session_id: str, run_id: str) -> None:
self.user_id = user_id
self.session_id = session_id
self.id = run_id
class TestSetUploadedFileInfosRunScoping:
"""Covers Copilot review feedback (round 2) on PR #574:
when a `run` is passed, validation must scope to THAT run's directory
so a malicious WS payload can't echo back URLs/timestamps for files
belonging to other users / sessions / runs.
"""
def test_accepts_path_in_current_run(self, tmp_path) -> None:
run_dir = tmp_path / "files" / "user" / "u1" / "s1" / "r1"
run_dir.mkdir(parents=True)
(run_dir / "data.csv").write_text("ok")
tm = _make_team_manager(tmp_path)
run = _RunStub("u1", "s1", "r1")
tm.set_uploaded_file_infos(
[{"name": "data.csv", "path": "files/user/u1/s1/r1/data.csv"}],
run=run,
)
assert len(tm._uploaded_file_infos) == 1
assert tm._uploaded_file_infos[0]["url"] == "/files/user/u1/s1/r1/data.csv"
def test_rejects_path_in_other_run_same_session(self, tmp_path) -> None:
# Another run dir under the same session — must be rejected
other_run_dir = tmp_path / "files" / "user" / "u1" / "s1" / "r-other"
other_run_dir.mkdir(parents=True)
(other_run_dir / "secret.csv").write_text("private")
tm = _make_team_manager(tmp_path)
run = _RunStub("u1", "s1", "r1")
tm.set_uploaded_file_infos(
[{"name": "secret.csv", "path": "files/user/u1/s1/r-other/secret.csv"}],
run=run,
)
assert tm._uploaded_file_infos == []
def test_rejects_path_in_other_session(self, tmp_path) -> None:
other_run_dir = tmp_path / "files" / "user" / "u1" / "s-other" / "r-x"
other_run_dir.mkdir(parents=True)
(other_run_dir / "x.csv").write_text("x")
tm = _make_team_manager(tmp_path)
run = _RunStub("u1", "s1", "r1")
tm.set_uploaded_file_infos(
[{"name": "x.csv", "path": "files/user/u1/s-other/r-x/x.csv"}],
run=run,
)
assert tm._uploaded_file_infos == []
def test_rejects_path_in_other_user(self, tmp_path) -> None:
other_run_dir = tmp_path / "files" / "user" / "u-other" / "s" / "r"
other_run_dir.mkdir(parents=True)
(other_run_dir / "y.csv").write_text("y")
tm = _make_team_manager(tmp_path)
run = _RunStub("u1", "s1", "r1")
tm.set_uploaded_file_infos(
[{"name": "y.csv", "path": "files/user/u-other/s/r/y.csv"}],
run=run,
)
assert tm._uploaded_file_infos == []
def test_run_with_none_ids_falls_back_to_unknown_segments(self, tmp_path) -> None:
"""Run with None ids resolves to the 'unknown_*' fallback dir, so
only paths under that exact dir are accepted."""
run_dir = (
tmp_path
/ "files"
/ "user"
/ "unknown_user"
/ "unknown_session"
/ "unknown_run"
)
run_dir.mkdir(parents=True)
(run_dir / "z.csv").write_text("z")
tm = _make_team_manager(tmp_path)
run = _RunStub(None, None, None)
tm.set_uploaded_file_infos(
[
{
"name": "z.csv",
"path": "files/user/unknown_user/unknown_session/unknown_run/z.csv",
},
{"name": "elsewhere", "path": "files/user/u1/s1/r1/x.csv"},
],
run=run,
)
names = [i["name"] for i in tm._uploaded_file_infos]
assert names == ["z.csv"]
class TestSetUploadedFileInfosMetadataFromPath:
"""Covers Copilot review feedback (round 2) on PR #574: display
metadata (name, extension, file_type) must come from the resolved
on-disk basename, not the client-supplied `name`. Otherwise a payload
like `{name: "report.pdf", path: ".../data.csv"}` would advertise a
CSV URL labeled as a PDF.
"""
def test_extension_derived_from_path_basename(self, tmp_path) -> None:
run_dir = tmp_path / "files" / "user" / "u" / "s" / "r"
run_dir.mkdir(parents=True)
(run_dir / "data.csv").write_text("a,b\n1,2\n")
tm = _make_team_manager(tmp_path)
tm.set_uploaded_file_infos(
[{"name": "report.pdf", "path": "files/user/u/s/r/data.csv"}]
)
assert len(tm._uploaded_file_infos) == 1
info = tm._uploaded_file_infos[0]
# Display fields reflect the real file, not the client-supplied lie
assert info["name"] == "data.csv"
assert info["extension"] == "csv"
assert info["file_type"] == "csv"
# URL still uses the client-supplied path so the static file
# endpoint serves the right resource (validation already ensured
# it stays inside the allowed root).
assert info["url"] == "/files/user/u/s/r/data.csv"
def test_name_with_no_extension_uses_empty_extension(self, tmp_path) -> None:
run_dir = tmp_path / "files" / "user" / "u" / "s" / "r"
run_dir.mkdir(parents=True)
(run_dir / "Makefile").write_text("all:\n")
tm = _make_team_manager(tmp_path)
tm.set_uploaded_file_infos(
[{"name": "Makefile.pdf", "path": "files/user/u/s/r/Makefile"}]
)
info = tm._uploaded_file_infos[0]
assert info["name"] == "Makefile"
assert info["extension"] == ""
# =============================================================================
# TeamManager.add_uploaded_files — mid-session upload registration
# =============================================================================
class TestAddUploadedFiles:
"""Covers issue #291: mid-session uploads must be registered in the
file-tracking baseline so they aren't reported as agent-created files,
and must be appended (not replace) to the uploaded-files list shown in
the end-of-run summary.
"""
def test_appends_rather_than_replaces(self, tmp_path) -> None:
run_dir = tmp_path / "files" / "user" / "u" / "s" / "r"
run_dir.mkdir(parents=True)
(run_dir / "first.csv").write_text("first")
(run_dir / "second.csv").write_text("second")
tm = _make_team_manager(tmp_path)
# Initial upload at task start — REPLACE semantics
tm.set_uploaded_file_infos(
[{"name": "first.csv", "path": "files/user/u/s/r/first.csv"}]
)
# Mid-session upload — APPEND semantics
added = tm.add_uploaded_files(
[{"name": "second.csv", "path": "files/user/u/s/r/second.csv"}]
)
names = [i["name"] for i in tm._uploaded_file_infos]
assert names == ["first.csv", "second.csv"]
assert [i["name"] for i in added] == ["second.csv"]
def test_registers_in_known_files_baseline(self, tmp_path) -> None:
"""After add_uploaded_files, _detect_changed_files for the same path
should NOT emit a 'created' event — the file is treated as already
known."""
from magentic_ui.backend.teammanager.teammanager import (
_detect_changed_files,
)
run_dir = tmp_path / "files" / "user" / "u" / "s" / "r"
run_dir.mkdir(parents=True)
upload = run_dir / "upload.csv"
upload.write_text("a,b\n")
tm = _make_team_manager(tmp_path)
tm.add_uploaded_files(
[{"name": "upload.csv", "path": "files/user/u/s/r/upload.csv"}]
)
# Simulate a subsequent change-detection cycle: the file is on disk
# with the same mtime as when it was registered.
current_files = [
{
"path": "files/user/u/s/r/upload.csv",
"short_path": "files/user/u/s/r/upload.csv",
"name": "upload.csv",
"extension": "csv",
"type": "csv",
"timestamp": upload.stat().st_mtime,
}
]
changed = _detect_changed_files(current_files, tm._known_files)
assert changed == []
def test_subsequent_modification_emits_modified(self, tmp_path) -> None:
"""If an agent modifies an uploaded file after add_uploaded_files,
the next change-detection cycle should report it as 'modified',
not 'created'."""
from magentic_ui.backend.teammanager.teammanager import (
_detect_changed_files,
)
run_dir = tmp_path / "files" / "user" / "u" / "s" / "r"
run_dir.mkdir(parents=True)
upload = run_dir / "upload.csv"
upload.write_text("v1")
tm = _make_team_manager(tmp_path)
tm.add_uploaded_files(
[{"name": "upload.csv", "path": "files/user/u/s/r/upload.csv"}]
)
# Agent modifies the file — bump mtime past the registered baseline
registered_mtime = tm._known_files["files/user/u/s/r/upload.csv"]
current_files = [
{
"path": "files/user/u/s/r/upload.csv",
"short_path": "files/user/u/s/r/upload.csv",
"name": "upload.csv",
"extension": "csv",
"type": "csv",
"timestamp": registered_mtime + 10.0,
}
]
changed = _detect_changed_files(current_files, tm._known_files)
assert len(changed) == 1
assert changed[0]["action"] == "modified"
assert changed[0]["name"] == "upload.csv"
def test_rejects_path_traversal(self, tmp_path) -> None:
"""add_uploaded_files must reuse the same path validation as
set_uploaded_file_infos."""
run_dir = tmp_path / "files" / "user" / "u" / "s" / "r"
run_dir.mkdir(parents=True)
(run_dir / "ok.csv").write_text("ok")
tm = _make_team_manager(tmp_path)
added = tm.add_uploaded_files(
[
{"name": "evil", "path": "files/user/../../etc/passwd"},
{"name": "ok.csv", "path": "files/user/u/s/r/ok.csv"},
]
)
assert [i["name"] for i in added] == ["ok.csv"]
# _known_files should only contain the validated entry
assert "files/user/u/s/r/ok.csv" in tm._known_files
assert all("passwd" not in k and ".." not in k for k in tm._known_files)
def test_rejects_path_outside_run_dir_when_run_provided(self, tmp_path) -> None:
"""When a run is passed, paths outside that run's directory are
rejected — same scoping rules as set_uploaded_file_infos."""
other_run_dir = tmp_path / "files" / "user" / "u1" / "s1" / "r-other"
other_run_dir.mkdir(parents=True)
(other_run_dir / "secret.csv").write_text("private")
tm = _make_team_manager(tmp_path)
run = _RunStub("u1", "s1", "r1")
added = tm.add_uploaded_files(
[{"name": "secret.csv", "path": "files/user/u1/s1/r-other/secret.csv"}],
run=run,
)
assert added == []
assert tm._uploaded_file_infos == []
assert tm._known_files == {}
def test_empty_input_is_noop(self, tmp_path) -> None:
tm = _make_team_manager(tmp_path)
# Pre-seed state so we can verify nothing changes
tm._known_files = {"existing/path": 100.0}
tm._uploaded_file_infos = [{"name": "existing"}]
added = tm.add_uploaded_files([])
assert added == []
assert tm._known_files == {"existing/path": 100.0}
assert tm._uploaded_file_infos == [{"name": "existing"}]
def test_returns_safe_refs_in_construct_task_shape(self, tmp_path) -> None:
"""add_uploaded_files returns server-validated safe refs in the
construct_task input shape ({name, path, type, uploaded}).
Caller uses these — not the raw client payload — to build
attached_files_json so a malicious path never reaches the wire.
Also verifies that display fields come from the resolved on-disk
basename (not the client-supplied name), so a payload like
{name: "report.pdf", path: ".../data.csv"} can't smuggle a
misleading label into chat metadata.
"""
run_dir = tmp_path / "files" / "user" / "u" / "s" / "r"
run_dir.mkdir(parents=True)
(run_dir / "data.csv").write_text("a,b\n")
tm = _make_team_manager(tmp_path)
added = tm.add_uploaded_files(
[
# Client lies about the name — server must use the
# on-disk basename in the returned safe ref.
{
"name": "report.pdf",
"path": "files/user/u/s/r/data.csv",
"type": "application/pdf",
"uploaded": True,
},
]
)
assert len(added) == 1
ref = added[0]
# Safe ref shape (matches construct_task input)
assert set(ref.keys()) == {"name", "path", "type", "uploaded"}
# Name comes from the resolved file, not the client lie
assert ref["name"] == "data.csv"
assert ref["path"] == "files/user/u/s/r/data.csv"
assert ref["type"] == "application/pdf"
assert ref["uploaded"] is True
def test_safe_refs_filter_traversal_attempts(self, tmp_path) -> None:
"""When some entries fail validation, only the safe ones appear in
the returned safe-ref list."""
run_dir = tmp_path / "files" / "user" / "u" / "s" / "r"
run_dir.mkdir(parents=True)
(run_dir / "ok.csv").write_text("ok")
tm = _make_team_manager(tmp_path)
added = tm.add_uploaded_files(
[
{"name": "evil", "path": "files/user/../../etc/passwd"},
{"name": "ok.csv", "path": "files/user/u/s/r/ok.csv"},
]
)
assert [r["name"] for r in added] == ["ok.csv"]
# All returned refs are in the safe shape
assert all("path" in r and "uploaded" in r for r in added)
def test_non_string_type_is_coerced_to_default(self, tmp_path) -> None:
"""A malicious or buggy client may send `type` as a non-string
(object, number, list). The server-validated safe ref must
always carry a string `type` so the frontend's `String(...)`
coercion can't yield "[object Object]" and break file-type
icons."""
run_dir = tmp_path / "files" / "user" / "u" / "s" / "r"
run_dir.mkdir(parents=True)
(run_dir / "data.csv").write_text("x")
tm = _make_team_manager(tmp_path)
added = tm.add_uploaded_files(
[
{
"name": "data.csv",
"path": "files/user/u/s/r/data.csv",
"type": {"malicious": "object"},
"uploaded": True,
},
]
)
assert len(added) == 1
assert added[0]["type"] == "file"