Add Phase 3 remote read-only file operations
Introduce dedicated remote file facade for /Clients paths, add agent read/download endpoints, enable remote view/properties/download/image preview in the web UI, and keep remote write operations disabled.
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
from backend.app.api.errors import AppError
|
||||
from backend.app.dependencies import get_browse_service, get_remote_file_service
|
||||
from backend.app.db.remote_client_repository import RemoteClientRepository
|
||||
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
||||
from backend.app.main import app
|
||||
from backend.app.security.path_guard import PathGuard
|
||||
from backend.app.services.browse_service import BrowseService
|
||||
from backend.app.services.remote_client_service import RemoteClientService
|
||||
from backend.app.services.remote_file_service import RemoteFileService
|
||||
|
||||
|
||||
PNG_1X1 = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
|
||||
)
|
||||
|
||||
|
||||
class _StubRemoteFileService(RemoteFileService):
|
||||
def __init__(
|
||||
self,
|
||||
remote_client_service: RemoteClientService,
|
||||
*,
|
||||
payloads: dict[tuple[str, str, str, str], dict],
|
||||
streams: dict[tuple[str, str, str], dict],
|
||||
failing_client_ids: set[str],
|
||||
):
|
||||
super().__init__(
|
||||
remote_client_service=remote_client_service,
|
||||
agent_auth_header="Authorization",
|
||||
agent_auth_scheme="Bearer",
|
||||
agent_auth_token="agent-secret",
|
||||
)
|
||||
self._payloads = payloads
|
||||
self._streams = streams
|
||||
self._failing_client_ids = failing_client_ids
|
||||
|
||||
def _request_json(self, *, client, endpoint_path: str, params: dict[str, str]) -> dict:
|
||||
if client.client_id in self._failing_client_ids:
|
||||
raise AppError(
|
||||
code="remote_client_unreachable",
|
||||
message=f"Remote client '{client.display_name}' is unreachable",
|
||||
status_code=502,
|
||||
details={"client_id": client.client_id, "endpoint": client.endpoint},
|
||||
)
|
||||
return self._payloads[(client.client_id, endpoint_path, params["share"], params.get("path", ""))]
|
||||
|
||||
def prepare_download(self, paths: list[str]) -> dict:
|
||||
resolved = self._resolve_remote_path(paths[0])
|
||||
item = self._stream_item(resolved.client.client_id, resolved.share_key, resolved.relative_path, resolved.name)
|
||||
return {
|
||||
"content": self._bytes_iter(item["content"]),
|
||||
"headers": {"Content-Disposition": item["headers"]["content-disposition"]},
|
||||
"content_type": item["headers"]["content-type"],
|
||||
}
|
||||
|
||||
def prepare_image_stream(self, path: str) -> dict:
|
||||
resolved = self._resolve_remote_path(path)
|
||||
item = self._stream_item(resolved.client.client_id, resolved.share_key, resolved.relative_path, resolved.name)
|
||||
return {
|
||||
"content": self._bytes_iter(item["content"]),
|
||||
"headers": {"Content-Length": item["headers"]["content-length"]},
|
||||
"content_type": item["headers"]["content-type"],
|
||||
}
|
||||
|
||||
def _stream_item(self, client_id: str, share_key: str, relative_path: str, default_name: str) -> dict:
|
||||
if client_id in self._failing_client_ids:
|
||||
raise AppError(
|
||||
code="remote_client_unreachable",
|
||||
message=f"Remote client '{default_name}' is unreachable",
|
||||
status_code=502,
|
||||
details={"client_id": client_id},
|
||||
)
|
||||
return self._streams[(client_id, share_key, relative_path)]
|
||||
|
||||
@staticmethod
|
||||
async def _bytes_iter(payload: bytes):
|
||||
yield payload
|
||||
|
||||
|
||||
class RemoteFileOpsApiGoldenTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.volumes_root = Path(self.temp_dir.name) / "Volumes"
|
||||
self.volumes_root.mkdir(parents=True, exist_ok=True)
|
||||
self.storage_root = self.volumes_root / "8TB"
|
||||
self.storage_root.mkdir(parents=True, exist_ok=True)
|
||||
local_file = self.storage_root / "local.txt"
|
||||
local_file.write_text("local", encoding="utf-8")
|
||||
mtime = 1710000000
|
||||
os.utime(local_file, (mtime, mtime))
|
||||
|
||||
repository = RemoteClientRepository(str(Path(self.temp_dir.name) / "remote-clients.db"))
|
||||
now_iso = "2026-03-26T12:00:00Z"
|
||||
repository.upsert_client(
|
||||
client_id="client-123",
|
||||
display_name="Jan MacBook",
|
||||
platform="macos",
|
||||
agent_version="1.1.0",
|
||||
endpoint="http://agent.test",
|
||||
shares=[{"key": "downloads", "label": "Downloads"}],
|
||||
now_iso=now_iso,
|
||||
)
|
||||
repository.upsert_client(
|
||||
client_id="broken-client",
|
||||
display_name="Offline iMac",
|
||||
platform="macos",
|
||||
agent_version="1.1.0",
|
||||
endpoint="http://broken.test",
|
||||
shares=[{"key": "downloads", "label": "Downloads"}],
|
||||
now_iso=now_iso,
|
||||
)
|
||||
remote_client_service = RemoteClientService(
|
||||
repository=repository,
|
||||
registration_token="secret-token",
|
||||
offline_timeout_seconds=60,
|
||||
now=lambda: datetime(2026, 3, 26, 12, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
remote_file_service = _StubRemoteFileService(
|
||||
remote_client_service,
|
||||
payloads={
|
||||
(
|
||||
"client-123",
|
||||
"/api/info",
|
||||
"downloads",
|
||||
"notes.md",
|
||||
): {
|
||||
"name": "notes.md",
|
||||
"kind": "file",
|
||||
"size": 13,
|
||||
"modified": "2026-03-26T12:00:00Z",
|
||||
"content_type": "text/markdown",
|
||||
"extension": ".md",
|
||||
"width": None,
|
||||
"height": None,
|
||||
"owner": None,
|
||||
"group": None,
|
||||
},
|
||||
(
|
||||
"client-123",
|
||||
"/api/read",
|
||||
"downloads",
|
||||
"notes.md",
|
||||
): {
|
||||
"name": "notes.md",
|
||||
"content_type": "text/markdown",
|
||||
"encoding": "utf-8",
|
||||
"truncated": False,
|
||||
"size": 13,
|
||||
"modified": "2026-03-26T12:00:00Z",
|
||||
"content": "# title\nhello",
|
||||
},
|
||||
},
|
||||
streams={
|
||||
(
|
||||
"client-123",
|
||||
"downloads",
|
||||
"notes.md",
|
||||
): {
|
||||
"headers": {
|
||||
"content-type": "text/markdown; charset=utf-8",
|
||||
"content-disposition": 'attachment; filename="notes.md"',
|
||||
"content-length": "13",
|
||||
},
|
||||
"content": b"# title\nhello",
|
||||
},
|
||||
(
|
||||
"client-123",
|
||||
"downloads",
|
||||
"pixel.png",
|
||||
): {
|
||||
"headers": {
|
||||
"content-type": "image/png",
|
||||
"content-disposition": 'attachment; filename="pixel.png"',
|
||||
"content-length": str(len(PNG_1X1)),
|
||||
},
|
||||
"content": PNG_1X1,
|
||||
},
|
||||
},
|
||||
failing_client_ids={"broken-client"},
|
||||
)
|
||||
browse_service = BrowseService(
|
||||
path_guard=PathGuard({"storage1": str(self.storage_root)}),
|
||||
filesystem=FilesystemAdapter(),
|
||||
)
|
||||
|
||||
async def _override_remote_file_service() -> RemoteFileService:
|
||||
return remote_file_service
|
||||
|
||||
async def _override_browse_service() -> BrowseService:
|
||||
return browse_service
|
||||
|
||||
app.dependency_overrides[get_remote_file_service] = _override_remote_file_service
|
||||
app.dependency_overrides[get_browse_service] = _override_browse_service
|
||||
|
||||
def tearDown(self) -> None:
|
||||
app.dependency_overrides.clear()
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def _request(self, method: str, url: str, *, params: dict | list[tuple[str, str]] | None = None) -> httpx.Response:
|
||||
async def _run() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
return await client.request(method, url, params=params)
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
def test_remote_info_view_image_and_download_work(self) -> None:
|
||||
info_response = self._request("GET", "/api/files/info", params={"path": "/Clients/client-123/downloads/notes.md"})
|
||||
self.assertEqual(info_response.status_code, 200)
|
||||
self.assertEqual(
|
||||
info_response.json(),
|
||||
{
|
||||
"name": "notes.md",
|
||||
"path": "/Clients/client-123/downloads/notes.md",
|
||||
"type": "file",
|
||||
"size": 13,
|
||||
"modified": "2026-03-26T12:00:00Z",
|
||||
"root": "/Clients/client-123/downloads",
|
||||
"extension": ".md",
|
||||
"content_type": "text/markdown",
|
||||
"owner": None,
|
||||
"group": None,
|
||||
"width": None,
|
||||
"height": None,
|
||||
},
|
||||
)
|
||||
|
||||
view_response = self._request("GET", "/api/files/view", params={"path": "/Clients/client-123/downloads/notes.md"})
|
||||
self.assertEqual(view_response.status_code, 200)
|
||||
self.assertEqual(view_response.json()["content"], "# title\nhello")
|
||||
self.assertEqual(view_response.json()["content_type"], "text/markdown")
|
||||
|
||||
image_response = self._request("GET", "/api/files/image", params={"path": "/Clients/client-123/downloads/pixel.png"})
|
||||
self.assertEqual(image_response.status_code, 200)
|
||||
self.assertEqual(image_response.headers.get("content-type"), "image/png")
|
||||
self.assertEqual(image_response.content, PNG_1X1)
|
||||
|
||||
download_response = self._request("GET", "/api/files/download", params=[("path", "/Clients/client-123/downloads/notes.md")])
|
||||
self.assertEqual(download_response.status_code, 200)
|
||||
self.assertEqual(download_response.content, b"# title\nhello")
|
||||
self.assertIn('attachment; filename="notes.md"', download_response.headers.get("content-disposition", ""))
|
||||
|
||||
def test_remote_failure_stays_local_and_volumes_behavior_is_unchanged(self) -> None:
|
||||
failed_response = self._request("GET", "/api/files/info", params={"path": "/Clients/broken-client/downloads/notes.md"})
|
||||
self.assertEqual(failed_response.status_code, 502)
|
||||
self.assertEqual(failed_response.json()["error"]["code"], "remote_client_unreachable")
|
||||
|
||||
volumes_response = self._request("GET", "/api/browse", params={"path": "/Volumes/8TB"})
|
||||
self.assertEqual(volumes_response.status_code, 200)
|
||||
self.assertEqual(volumes_response.json()["path"], "/Volumes/8TB")
|
||||
self.assertEqual([item["name"] for item in volumes_response.json()["files"]], ["local.txt"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user