210 lines
7.9 KiB
Python
210 lines
7.9 KiB
Python
import json
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
from uuid import uuid4
|
|
|
|
from core.artifact_lifecycle import artifact_subtree_clause, trash_active_artifacts
|
|
from core.artifacts import (
|
|
ArtifactPathError,
|
|
ArtifactRef,
|
|
ToolExecutionResult,
|
|
resolve_artifact_path,
|
|
)
|
|
from core.executor import ExecCtx
|
|
from core.executor_host import HostExecutor
|
|
from tools.publish_artifacts import PublishArtifactsTool
|
|
from tools.register_artifact import RegisterArtifactTool
|
|
from web.routers.files import _task_file_target
|
|
|
|
|
|
class ArtifactPathTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.tmp.name)
|
|
self.wd = self.root / "技术讨论"
|
|
self.wd.mkdir()
|
|
(self.wd / "report.pdf").write_bytes(b"pdf")
|
|
|
|
def tearDown(self) -> None:
|
|
self.tmp.cleanup()
|
|
|
|
def test_canonical_and_legacy_paths_resolve_to_same_file(self) -> None:
|
|
expected = self.wd / "report.pdf"
|
|
for raw in (
|
|
"report.pdf",
|
|
"技术讨论/report.pdf",
|
|
str(expected),
|
|
"/workspace/技术讨论/report.pdf",
|
|
):
|
|
with self.subTest(raw=raw):
|
|
actual, rel = resolve_artifact_path(
|
|
raw, working_dir=self.wd, user_root=self.root,
|
|
)
|
|
self.assertEqual(actual, expected.resolve())
|
|
self.assertEqual(rel, "report.pdf")
|
|
|
|
def test_version_two_ref_carries_stable_artifact_identity(self) -> None:
|
|
artifact_id = uuid4()
|
|
ref = ArtifactRef(
|
|
path="report.pdf",
|
|
label="最终报告",
|
|
artifact_id=artifact_id,
|
|
version=2,
|
|
).as_dict()
|
|
self.assertEqual(ref["version"], 2)
|
|
self.assertEqual(ref["artifact_id"], str(artifact_id))
|
|
|
|
def test_trash_moves_file_and_marks_lifecycle_row_deleted(self) -> None:
|
|
artifact = self.wd / "report.pdf"
|
|
row = SimpleNamespace(
|
|
current_path="技术讨论/report.pdf",
|
|
status="active",
|
|
deleted_at=None,
|
|
trash_path=None,
|
|
)
|
|
session = MagicMock()
|
|
session.execute.return_value.scalars.return_value.all.return_value = [row]
|
|
|
|
@contextmanager
|
|
def fake_scope():
|
|
yield session
|
|
|
|
with patch("core.artifact_lifecycle.session_scope", fake_scope):
|
|
count = trash_active_artifacts(
|
|
user_id=uuid4(),
|
|
user_root=self.root,
|
|
target=artifact,
|
|
)
|
|
|
|
self.assertEqual(count, 1)
|
|
self.assertFalse(artifact.exists())
|
|
self.assertEqual(row.status, "deleted")
|
|
self.assertIsNotNone(row.deleted_at)
|
|
trashed = self.root / row.trash_path
|
|
self.assertTrue(trashed.is_file())
|
|
self.assertEqual(trashed.read_bytes(), b"pdf")
|
|
|
|
def test_subtree_query_escapes_like_wildcards(self) -> None:
|
|
clause = artifact_subtree_clause("项目_100%/报告")
|
|
compiled = clause.compile().params
|
|
self.assertIn("项目\\_100\\%/报告/%", compiled.values())
|
|
|
|
def test_explicit_dot_slash_disambiguates_same_named_subdirectory(self) -> None:
|
|
nested = self.wd / "技术讨论" / "nested.html"
|
|
nested.parent.mkdir()
|
|
nested.write_text("ok", encoding="utf-8")
|
|
actual, rel = resolve_artifact_path(
|
|
"./技术讨论/nested.html", working_dir=self.wd, user_root=self.root,
|
|
)
|
|
self.assertEqual(actual, nested.resolve())
|
|
self.assertEqual(rel, "技术讨论/nested.html")
|
|
|
|
def test_escape_and_directory_are_rejected(self) -> None:
|
|
for raw in ("../outside.txt", "."):
|
|
with self.subTest(raw=raw), self.assertRaises(ArtifactPathError):
|
|
resolve_artifact_path(
|
|
raw, working_dir=self.wd, user_root=self.root,
|
|
)
|
|
|
|
def test_publish_artifacts_is_explicit_bounded_and_deduplicated(self) -> None:
|
|
tool = PublishArtifactsTool(
|
|
working_dir=self.wd,
|
|
base_dir=self.wd,
|
|
user_root=self.root,
|
|
)
|
|
result = tool.execute({"not": "a list"})
|
|
self.assertIsInstance(result, str)
|
|
published = tool.execute([
|
|
{"path": "report.pdf", "label": "最终报告"},
|
|
{"path": "./report.pdf"},
|
|
])
|
|
self.assertIsInstance(published, ToolExecutionResult)
|
|
self.assertEqual(len(published.artifacts), 1)
|
|
self.assertEqual(published.artifacts[0].path, "report.pdf")
|
|
self.assertEqual(published.artifacts[0].label, "最终报告")
|
|
|
|
executed = HostExecutor({tool.name: tool}).call_tool(
|
|
tool.name,
|
|
{"artifacts": [{"path": "report.pdf"}]},
|
|
ExecCtx(user_id="u", task_id="t", working_dir=self.wd),
|
|
)
|
|
self.assertEqual(executed.content, "[OK] published 1 artifact(s): report.pdf")
|
|
self.assertEqual(executed.artifacts[0]["path"], "report.pdf")
|
|
|
|
def test_register_artifact_returns_identity_without_publishing(self) -> None:
|
|
artifact_id = uuid4()
|
|
tool = RegisterArtifactTool(
|
|
uuid4(),
|
|
uuid4(),
|
|
working_dir=self.wd,
|
|
base_dir=self.wd,
|
|
user_root=self.root,
|
|
)
|
|
with patch(
|
|
"tools.register_artifact.register_workspace_artifact",
|
|
return_value={
|
|
"version": 2,
|
|
"artifact_id": str(artifact_id),
|
|
"scope": "working_dir",
|
|
"path": "report.pdf",
|
|
},
|
|
) as register:
|
|
result = json.loads(tool.execute("report.pdf"))
|
|
|
|
self.assertEqual(result["artifact_id"], str(artifact_id))
|
|
self.assertEqual(register.call_args.kwargs["path"], "report.pdf")
|
|
|
|
def test_register_artifact_rejects_path_escape(self) -> None:
|
|
tool = RegisterArtifactTool(
|
|
uuid4(),
|
|
uuid4(),
|
|
working_dir=self.wd,
|
|
base_dir=self.wd,
|
|
user_root=self.root,
|
|
)
|
|
self.assertIn("cannot register artifact", tool.execute("../outside.csv"))
|
|
|
|
def test_register_artifact_schema_is_distinct_from_publish(self) -> None:
|
|
self.assertEqual(RegisterArtifactTool.parameters["required"], ["path"])
|
|
self.assertNotIn("artifacts", RegisterArtifactTool.parameters["properties"])
|
|
|
|
def test_publish_path_is_strictly_task_relative_when_names_repeat(self) -> None:
|
|
nested = self.wd / "技术讨论" / "nested.html"
|
|
nested.parent.mkdir()
|
|
nested.write_text("ok", encoding="utf-8")
|
|
tool = PublishArtifactsTool(
|
|
working_dir=self.wd,
|
|
base_dir=self.wd,
|
|
user_root=self.root,
|
|
)
|
|
published = tool.execute([{"path": "技术讨论/nested.html"}])
|
|
self.assertIsInstance(published, ToolExecutionResult)
|
|
self.assertEqual(published.artifacts[0].path, "技术讨论/nested.html")
|
|
|
|
def test_legacy_card_resolution_covers_both_known_shapes_and_rename(self) -> None:
|
|
direct = self.wd / "manual.pdf"
|
|
direct.write_bytes(b"direct")
|
|
nested = self.wd / "技术讨论" / "nested.html"
|
|
nested.parent.mkdir()
|
|
nested.write_text("nested", encoding="utf-8")
|
|
|
|
# 92ac20cf shape: old user-root path already points at the correct file.
|
|
self.assertEqual(
|
|
_task_file_target(self.root, self.wd, "技术讨论/manual.pdf", True),
|
|
direct.resolve(),
|
|
)
|
|
# 9b4502aa shape: the same text was actually task-relative into a repeated dir.
|
|
self.assertEqual(
|
|
_task_file_target(self.root, self.wd, "技术讨论/nested.html", True),
|
|
nested.resolve(),
|
|
)
|
|
# After a top-level rename, dropping the obsolete first component finds the file.
|
|
self.assertEqual(
|
|
_task_file_target(self.root, self.wd, "旧目录/manual.pdf", True),
|
|
direct.resolve(),
|
|
)
|