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:
kodi
2026-03-27 15:16:01 +01:00
parent 2fa4a0b291
commit 9778dc6c33
10 changed files with 1011 additions and 29 deletions
+18 -2
View File
@@ -5,10 +5,11 @@ from fastapi.responses import StreamingResponse
from starlette.background import BackgroundTask
from backend.app.api.schemas import ArchivePrepareRequest, DeleteRequest, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, TaskCreateResponse, TaskDetailResponse, UploadResponse, ViewResponse
from backend.app.dependencies import get_archive_download_task_service, get_delete_task_service, get_file_ops_service
from backend.app.dependencies import get_archive_download_task_service, get_delete_task_service, get_file_ops_service, get_remote_file_service
from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService
from backend.app.services.delete_task_service import DeleteTaskService
from backend.app.services.file_ops_service import FileOpsService
from backend.app.services.remote_file_service import RemoteFileService
router = APIRouter(prefix="/files")
@@ -54,7 +55,10 @@ async def view(
path: str,
for_edit: bool = False,
service: FileOpsService = Depends(get_file_ops_service),
remote_service: RemoteFileService = Depends(get_remote_file_service),
) -> ViewResponse:
if remote_service.handles_path(path):
return remote_service.view(path=path, for_edit=for_edit)
return service.view(path=path, for_edit=for_edit)
@@ -62,7 +66,10 @@ async def view(
async def info(
path: str,
service: FileOpsService = Depends(get_file_ops_service),
remote_service: RemoteFileService = Depends(get_remote_file_service),
) -> FileInfoResponse:
if remote_service.handles_path(path):
return remote_service.info(path=path)
return service.info(path=path)
@@ -70,8 +77,9 @@ async def info(
async def download(
path: list[str] = Query(...),
service: FileOpsService = Depends(get_file_ops_service),
remote_service: RemoteFileService = Depends(get_remote_file_service),
) -> StreamingResponse:
prepared = service.prepare_download(paths=path)
prepared = remote_service.prepare_download(paths=path) if any(remote_service.handles_path(item) for item in path) else service.prepare_download(paths=path)
response = StreamingResponse(
prepared["content"],
headers=prepared["headers"],
@@ -143,7 +151,15 @@ async def pdf(
async def image(
path: str,
service: FileOpsService = Depends(get_file_ops_service),
remote_service: RemoteFileService = Depends(get_remote_file_service),
) -> StreamingResponse:
if remote_service.handles_path(path):
prepared = remote_service.prepare_image_stream(path=path)
return StreamingResponse(
prepared["content"],
headers=prepared["headers"],
media_type=prepared["content_type"],
)
prepared = service.prepare_image_stream(path=path)
return StreamingResponse(
prepared["content"],
+11
View File
@@ -22,6 +22,7 @@ from backend.app.services.history_service import HistoryService
from backend.app.services.move_task_service import MoveTaskService
from backend.app.services.remote_browse_service import RemoteBrowseService
from backend.app.services.remote_client_service import RemoteClientService
from backend.app.services.remote_file_service import RemoteFileService
from backend.app.services.search_service import SearchService
from backend.app.services.settings_service import SettingsService
from backend.app.services.task_service import TaskService
@@ -187,3 +188,13 @@ async def get_remote_browse_service() -> RemoteBrowseService:
agent_auth_scheme=settings.remote_client_agent_auth_scheme,
agent_auth_token=settings.remote_client_agent_auth_token,
)
async def get_remote_file_service() -> RemoteFileService:
settings: Settings = get_settings()
return RemoteFileService(
remote_client_service=await get_remote_client_service(),
agent_auth_header=settings.remote_client_agent_auth_header,
agent_auth_scheme=settings.remote_client_agent_auth_scheme,
agent_auth_token=settings.remote_client_agent_auth_token,
)
@@ -0,0 +1,432 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import PurePosixPath
from urllib.parse import urlencode
import httpx
from backend.app.api.errors import AppError
from backend.app.api.schemas import FileInfoResponse, RemoteClientItem, ViewResponse
from backend.app.services.remote_browse_service import RemoteBrowseService
from backend.app.services.remote_client_service import RemoteClientService
REMOTE_TEXT_PREVIEW_MAX_BYTES = 256 * 1024
REMOTE_AGENT_TIMEOUT_SECONDS = 2.0
REMOTE_DOWNLOAD_READ_TIMEOUT_SECONDS = 5.0
REMOTE_STREAM_CHUNK_BYTES = 64 * 1024
TEXT_CONTENT_TYPES = {
".txt": "text/plain",
".log": "text/plain",
".conf": "text/plain",
".ini": "text/plain",
".cfg": "text/plain",
".md": "text/markdown",
".yml": "text/yaml",
".yaml": "text/yaml",
".json": "application/json",
".js": "text/javascript",
".py": "text/x-python",
".css": "text/css",
".html": "text/html",
}
SPECIAL_TEXT_FILENAMES = {
"dockerfile": "text/plain",
"containerfile": "text/plain",
}
IMAGE_CONTENT_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".avif": "image/avif",
}
@dataclass(frozen=True)
class RemoteResolvedPath:
raw_path: str
client: RemoteClientItem
share_key: str
relative_path: str
name: str
root_path: str
class RemoteFileService:
def __init__(
self,
remote_client_service: RemoteClientService,
agent_auth_header: str,
agent_auth_scheme: str,
agent_auth_token: str,
agent_timeout_seconds: float = REMOTE_AGENT_TIMEOUT_SECONDS,
text_preview_max_bytes: int = REMOTE_TEXT_PREVIEW_MAX_BYTES,
download_read_timeout_seconds: float = REMOTE_DOWNLOAD_READ_TIMEOUT_SECONDS,
stream_chunk_bytes: int = REMOTE_STREAM_CHUNK_BYTES,
):
self._remote_client_service = remote_client_service
self._agent_auth_header = (agent_auth_header or "Authorization").strip() or "Authorization"
self._agent_auth_scheme = (agent_auth_scheme or "Bearer").strip() or "Bearer"
self._agent_auth_token = (agent_auth_token or "").strip()
self._agent_timeout_seconds = max(0.1, float(agent_timeout_seconds))
self._text_preview_max_bytes = max(1024, int(text_preview_max_bytes))
self._download_read_timeout_seconds = max(0.1, float(download_read_timeout_seconds))
self._stream_chunk_bytes = max(4096, int(stream_chunk_bytes))
def handles_path(self, path: str) -> bool:
return RemoteBrowseService.handles_path(path)
def info(self, path: str) -> FileInfoResponse:
resolved = self._resolve_remote_path(path, allow_share_root=True)
payload = self._request_json(
client=resolved.client,
endpoint_path="/api/info",
params={"share": resolved.share_key, "path": resolved.relative_path},
)
kind = str(payload.get("kind", "")).strip()
if kind not in {"file", "directory"}:
raise self._invalid_agent_payload(resolved.client, "Remote file info response was invalid")
extension = str(payload.get("extension", "") or "").strip() or PurePosixPath(resolved.name).suffix.lower() or None
return FileInfoResponse(
name=str(payload.get("name", resolved.name)).strip() or resolved.name,
path=resolved.raw_path,
type=kind,
size=self._normalize_optional_int(payload.get("size")),
modified=str(payload.get("modified", "")).strip(),
root=resolved.root_path,
extension=extension,
content_type=self._normalize_optional_string(payload.get("content_type")),
owner=self._normalize_optional_string(payload.get("owner")),
group=self._normalize_optional_string(payload.get("group")),
width=self._normalize_optional_int(payload.get("width")),
height=self._normalize_optional_int(payload.get("height")),
)
def view(self, path: str, *, for_edit: bool = False) -> ViewResponse:
if for_edit:
raise AppError(
code="unsupported_type",
message="Remote files are not supported for edit",
status_code=409,
details={"path": path},
)
resolved = self._resolve_remote_path(path)
payload = self._request_json(
client=resolved.client,
endpoint_path="/api/read",
params={
"share": resolved.share_key,
"path": resolved.relative_path,
"max_bytes": str(self._text_preview_max_bytes),
},
)
content = str(payload.get("content", ""))
if len(content.encode("utf-8")) > self._text_preview_max_bytes:
raise self._invalid_agent_payload(resolved.client, "Remote text preview exceeded the configured limit")
return ViewResponse(
path=resolved.raw_path,
name=str(payload.get("name", resolved.name)).strip() or resolved.name,
content_type=str(payload.get("content_type", self._content_type_for_name(resolved.name) or "text/plain")).strip(),
encoding=str(payload.get("encoding", "utf-8")).strip() or "utf-8",
truncated=bool(payload.get("truncated", False)),
size=max(0, int(payload.get("size", 0))),
modified=str(payload.get("modified", "")).strip(),
content=content,
)
def prepare_download(self, paths: list[str]) -> dict:
if len(paths) != 1:
raise AppError(
code="invalid_request",
message="Remote downloads support exactly one file per request",
status_code=400,
)
resolved = self._resolve_remote_path(paths[0])
stream = self._open_stream(
client=resolved.client,
endpoint_path="/api/download",
params={"share": resolved.share_key, "path": resolved.relative_path},
)
content_disposition = stream.headers.get("content-disposition") or f'attachment; filename="{resolved.name}"'
headers = {"Content-Disposition": content_disposition}
if stream.headers.get("content-length"):
headers["Content-Length"] = stream.headers["content-length"]
return {
"content": self._iter_remote_stream(stream),
"headers": headers,
"content_type": stream.headers.get("content-type", "application/octet-stream"),
}
def prepare_image_stream(self, path: str) -> dict:
resolved = self._resolve_remote_path(path)
content_type = self._image_content_type_for_name(resolved.name)
if content_type is None:
raise AppError(
code="unsupported_type",
message="File type is not supported for image viewing",
status_code=409,
details={"path": path},
)
stream = self._open_stream(
client=resolved.client,
endpoint_path="/api/download",
params={"share": resolved.share_key, "path": resolved.relative_path},
)
headers: dict[str, str] = {}
if stream.headers.get("content-length"):
headers["Content-Length"] = stream.headers["content-length"]
return {
"content": self._iter_remote_stream(stream),
"headers": headers,
"content_type": content_type,
}
def _resolve_remote_path(self, path: str, *, allow_share_root: bool = False) -> RemoteResolvedPath:
normalized = (path or "").strip().rstrip("/")
if not self.handles_path(normalized):
raise AppError(
code="invalid_request",
message="Remote path must be under /Clients",
status_code=400,
details={"path": path},
)
parts = normalized[len(RemoteBrowseService.ROOT_PATH) + 1 :].split("/") if normalized != RemoteBrowseService.ROOT_PATH else []
min_parts = 2 if allow_share_root else 3
if len(parts) < min_parts:
raise AppError(
code="type_conflict",
message="Remote path must reference a file or directory inside a share",
status_code=409,
details={"path": path},
)
client = self._remote_client_service.get_client(parts[0])
if client.status != "online":
raise AppError(
code="remote_client_unavailable",
message=f"Remote client '{client.display_name}' is offline",
status_code=503,
details={"client_id": client.client_id, "status": client.status},
)
share_key = parts[1]
if not any(share.key == share_key for share in client.shares):
raise AppError(
code="path_not_found",
message="Remote share was not found",
status_code=404,
details={"client_id": client.client_id, "share_key": share_key},
)
relative_path = "/".join(parts[2:])
if not relative_path and not allow_share_root:
raise AppError(
code="type_conflict",
message="Remote file operation requires a path inside the share",
status_code=409,
details={"path": path},
)
name = parts[-1]
if allow_share_root and len(parts) == 2:
share = next((item for item in client.shares if item.key == share_key), None)
if share is not None:
name = share.label
return RemoteResolvedPath(
raw_path=normalized,
client=client,
share_key=share_key,
relative_path=relative_path,
name=name,
root_path=f"{RemoteBrowseService.ROOT_PATH}/{client.client_id}/{share_key}",
)
def _request_json(self, *, client: RemoteClientItem, endpoint_path: str, params: dict[str, str]) -> dict:
url = self._build_url(client.endpoint, endpoint_path, params)
timeout = httpx.Timeout(self._agent_timeout_seconds, connect=self._agent_timeout_seconds)
try:
with httpx.Client(timeout=timeout, headers=self._auth_headers()) as client_http:
response = client_http.get(url)
except httpx.TimeoutException as exc:
raise self._timeout_error(client) from exc
except httpx.HTTPError as exc:
raise self._unreachable_error(client) from exc
self._raise_for_agent_error(client=client, response=response)
try:
payload = response.json()
except ValueError as exc:
raise self._invalid_agent_payload(client, "Remote client returned invalid JSON") from exc
if not isinstance(payload, dict):
raise self._invalid_agent_payload(client, "Remote client returned an invalid response")
return payload
def _open_stream(self, *, client: RemoteClientItem, endpoint_path: str, params: dict[str, str]) -> httpx.Response:
url = self._build_url(client.endpoint, endpoint_path, params)
timeout = httpx.Timeout(
connect=self._agent_timeout_seconds,
read=self._download_read_timeout_seconds,
write=self._agent_timeout_seconds,
pool=self._agent_timeout_seconds,
)
client_http = httpx.Client(timeout=timeout, headers=self._auth_headers())
try:
response = client_http.stream("GET", url)
response.__enter__()
except httpx.TimeoutException as exc:
client_http.close()
raise self._timeout_error(client) from exc
except httpx.HTTPError as exc:
client_http.close()
raise self._unreachable_error(client) from exc
try:
self._raise_for_agent_error(client=client, response=response)
except Exception:
response.close()
client_http.close()
raise
response.extensions["remote_client_http_client"] = client_http
return response
def _iter_remote_stream(self, response: httpx.Response):
client_http = response.extensions.get("remote_client_http_client")
try:
for chunk in response.iter_bytes(chunk_size=self._stream_chunk_bytes):
if chunk:
yield chunk
finally:
response.close()
if client_http is not None:
client_http.close()
def _raise_for_agent_error(self, *, client: RemoteClientItem, response: httpx.Response) -> None:
if response.status_code < 400:
return
code = None
message = None
detail_payload = None
try:
payload = response.json()
except ValueError:
payload = None
if isinstance(payload, dict):
detail = payload.get("detail")
if isinstance(detail, dict):
detail_payload = detail
code = self._normalize_optional_string(detail.get("code"))
message = self._normalize_optional_string(detail.get("message"))
elif isinstance(detail, str):
message = detail.strip() or None
if response.status_code == 400:
raise AppError(
code=code or "invalid_request",
message=message or "Remote request was rejected",
status_code=400,
details={"client_id": client.client_id},
)
if response.status_code == 403:
agent_code = code or "forbidden"
if agent_code == "invalid_agent_token":
raise AppError(
code="remote_client_forbidden",
message=f"Remote client '{client.display_name}' rejected authentication",
status_code=502,
details={"client_id": client.client_id, "endpoint": client.endpoint},
)
raise AppError(
code=agent_code,
message=message or "Remote access was denied",
status_code=403,
details={"client_id": client.client_id},
)
if response.status_code == 404:
raise AppError(
code=code or "path_not_found",
message=message or "Remote path was not found",
status_code=404,
details={"client_id": client.client_id},
)
if response.status_code == 409:
raise AppError(
code=code or "type_conflict",
message=message or "Remote file operation could not be completed",
status_code=409,
details={"client_id": client.client_id},
)
raise AppError(
code="remote_client_error",
message=message or f"Remote client '{client.display_name}' request failed",
status_code=502,
details={
"client_id": client.client_id,
"endpoint": client.endpoint,
"status_code": str(response.status_code),
"agent_code": code or "",
"agent_detail": str(detail_payload or ""),
},
)
def _auth_headers(self) -> dict[str, str]:
if not self._agent_auth_token:
raise AppError(
code="remote_client_agent_auth_not_configured",
message="Remote client agent auth token is not configured",
status_code=503,
)
return {self._agent_auth_header: f"{self._agent_auth_scheme} {self._agent_auth_token}"}
@staticmethod
def _build_url(endpoint: str, endpoint_path: str, params: dict[str, str]) -> str:
return f"{endpoint.rstrip('/')}{endpoint_path}?{urlencode(params)}"
@staticmethod
def _timeout_error(client: RemoteClientItem) -> AppError:
return AppError(
code="remote_client_timeout",
message=f"Remote client '{client.display_name}' timed out",
status_code=504,
details={"client_id": client.client_id, "endpoint": client.endpoint},
)
@staticmethod
def _unreachable_error(client: RemoteClientItem) -> AppError:
return 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},
)
@staticmethod
def _invalid_agent_payload(client: RemoteClientItem, message: str) -> AppError:
return AppError(
code="remote_client_error",
message=message,
status_code=502,
details={"client_id": client.client_id, "endpoint": client.endpoint},
)
@staticmethod
def _normalize_optional_string(value) -> str | None:
normalized = str(value).strip() if value is not None else ""
return normalized or None
@staticmethod
def _normalize_optional_int(value) -> int | None:
if value is None or value == "":
return None
try:
return max(0, int(value))
except (TypeError, ValueError):
return None
@staticmethod
def _content_type_for_name(name: str) -> str | None:
special_name = SPECIAL_TEXT_FILENAMES.get((name or "").lower())
if special_name:
return special_name
return TEXT_CONTENT_TYPES.get(PurePosixPath(name).suffix.lower())
@staticmethod
def _image_content_type_for_name(name: str) -> str | None:
return IMAGE_CONTENT_TYPES.get(PurePosixPath(name).suffix.lower())
Binary file not shown.
@@ -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()