feat: add binary file attachment and import tools for Outline LTM system (#1)
Build and Push Outline MCP Docker Image / build (push) Successful in 1m18s
Build and Push Outline MCP Docker Image / build (push) Successful in 1m18s
Implement 3 new MCP tools: - attach_file_to_document(document_id, file_path) - upload_image_to_document(image_path, document_id, alt_text) - import_file_to_outline(file_path, collection_id, parent_document_id) Security: - Restrict file access to /tmp via _validate_file_path with realpath - 50MB max file size enforced client-side - Symlink traversal blocked Technical: - Extract shared _upload_attachment() helper - Stream files to presigned URLs instead of loading into memory - Add combined lifespan to close OutlineClient on shutdown - Update CI workflow with modern action versions and PR triggers Tests: - Add 28 tests covering path validation, size limits, upload flow, error handling, symlink traversal, and multipart imports
This commit is contained in:
@@ -5,6 +5,10 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- master
|
- master
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -13,17 +17,32 @@ jobs:
|
|||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pip install uv
|
||||||
|
uv sync --extra dev
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: uv run pytest tests/ -v
|
||||||
|
|
||||||
- name: Login to Gitea Container Registry
|
- name: Login to Gitea Container Registry
|
||||||
uses: docker/login-action@v2
|
if: github.event_name == 'push'
|
||||||
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: gitea.ext.ben.io
|
registry: gitea.ext.ben.io
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.CR_PAT }}
|
password: ${{ secrets.CR_PAT }}
|
||||||
|
|
||||||
- name: Build and Push
|
- name: Build and Push
|
||||||
uses: docker/build-push-action@v4
|
if: github.event_name == 'push'
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
@@ -46,6 +46,27 @@ Params: method (required), params (JSON string)
|
|||||||
Returns: Raw API response as JSON string
|
Returns: Raw API response as JSON string
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### attach_file_to_document
|
||||||
|
Upload a local file as an attachment to an existing Outline document.
|
||||||
|
```
|
||||||
|
Params: document_id (required), file_path (required — must be under /tmp)
|
||||||
|
Returns: JSON with attachment_url, markdown embed string, name, size, content_type
|
||||||
|
```
|
||||||
|
|
||||||
|
### upload_image_to_document
|
||||||
|
Upload an image file as an attachment and return Markdown image embed syntax.
|
||||||
|
```
|
||||||
|
Params: image_path (required — must be under /tmp), document_id (required), alt_text (optional)
|
||||||
|
Returns: JSON with attachment_url, markdown image embed, name, size, content_type
|
||||||
|
```
|
||||||
|
|
||||||
|
### import_file_to_outline
|
||||||
|
Import a local file (Markdown, text, JSON, CSV) as a new Outline document.
|
||||||
|
```
|
||||||
|
Params: file_path (required — must be under /tmp), collection_id (required), parent_document_id (optional)
|
||||||
|
Returns: JSON with newly created document metadata
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Outline API Reference
|
## Outline API Reference
|
||||||
@@ -384,3 +405,23 @@ params: {"invites": [{"email": "user@example.com", "name": "User Name", "role":
|
|||||||
method: "collections.create"
|
method: "collections.create"
|
||||||
params: {"name": "Engineering", "description": "Engineering documentation", "permission": "read_write"}
|
params: {"name": "Engineering", "description": "Engineering documentation", "permission": "read_write"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Attach a file to a document
|
||||||
|
```
|
||||||
|
document_id: "document-uuid"
|
||||||
|
file_path: "/tmp/report.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upload an image to a document
|
||||||
|
```
|
||||||
|
image_path: "/tmp/screenshot.png"
|
||||||
|
document_id: "document-uuid"
|
||||||
|
alt_text: "System architecture diagram"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import a Markdown file as a new document
|
||||||
|
```
|
||||||
|
file_path: "/tmp/notes.md"
|
||||||
|
collection_id: "collection-uuid"
|
||||||
|
parent_document_id: "parent-document-uuid" # optional
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import mimetypes
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import httpx
|
import httpx
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
from starlette.routing import Route, Mount
|
from starlette.routing import Route, Mount
|
||||||
@@ -20,6 +22,29 @@ logger = logging.getLogger(__name__)
|
|||||||
OUTLINE_API_URL = os.getenv("OUTLINE_API_URL", "").rstrip("/")
|
OUTLINE_API_URL = os.getenv("OUTLINE_API_URL", "").rstrip("/")
|
||||||
OUTLINE_API_TOKEN = os.getenv("OUTLINE_API_TOKEN", "")
|
OUTLINE_API_TOKEN = os.getenv("OUTLINE_API_TOKEN", "")
|
||||||
|
|
||||||
|
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
|
||||||
|
ALLOWED_PATH_PREFIX = "/tmp"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_file_path(file_path: str) -> str:
|
||||||
|
"""Validate that file_path is within ALLOWED_PATH_PREFIX and exists."""
|
||||||
|
abs_path = os.path.abspath(file_path)
|
||||||
|
real_path = os.path.realpath(abs_path)
|
||||||
|
allowed_real = os.path.realpath(ALLOWED_PATH_PREFIX)
|
||||||
|
if not (real_path == allowed_real or real_path.startswith(allowed_real + os.sep)):
|
||||||
|
raise ValueError(f"File path must be within {ALLOWED_PATH_PREFIX}")
|
||||||
|
if not os.path.exists(real_path):
|
||||||
|
raise ValueError(f"File not found: {file_path}")
|
||||||
|
if not os.path.isfile(real_path):
|
||||||
|
raise ValueError(f"Path is not a file: {file_path}")
|
||||||
|
return real_path
|
||||||
|
|
||||||
|
|
||||||
|
def _get_content_type(file_path: str) -> str | None:
|
||||||
|
"""Guess content type from file extension."""
|
||||||
|
content_type, _ = mimetypes.guess_type(file_path)
|
||||||
|
return content_type
|
||||||
|
|
||||||
|
|
||||||
class OutlineClient:
|
class OutlineClient:
|
||||||
"""HTTP client for Outline API with authentication."""
|
"""HTTP client for Outline API with authentication."""
|
||||||
@@ -87,6 +112,61 @@ class OutlineClient:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
|
async def upload_to_presigned_url(
|
||||||
|
self, upload_url: str, file_path: str, content_type: str
|
||||||
|
) -> dict:
|
||||||
|
"""Upload file bytes to a presigned URL (e.g., S3)."""
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
response = await client.put(
|
||||||
|
upload_url,
|
||||||
|
content=f,
|
||||||
|
headers={"Content-Type": content_type},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return {"ok": True}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": f"Upload failed: {e.response.text}",
|
||||||
|
"status": e.response.status_code,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": f"Upload failed: {str(e)}"}
|
||||||
|
|
||||||
|
async def call_multipart(
|
||||||
|
self, method: str, file_path: str, fields: dict
|
||||||
|
) -> dict:
|
||||||
|
"""Execute a multipart Outline API call for file upload endpoints."""
|
||||||
|
try:
|
||||||
|
filename = os.path.basename(file_path)
|
||||||
|
content_type = _get_content_type(file_path) or "application/octet-stream"
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
files = {"file": (filename, f, content_type)}
|
||||||
|
response = await self.client.post(
|
||||||
|
f"/api/{method}", files=files, data=fields
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
error_body = e.response.text
|
||||||
|
try:
|
||||||
|
error_json = e.response.json()
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": error_json.get("message", str(e)),
|
||||||
|
"status": e.response.status_code,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": error_body or str(e),
|
||||||
|
"status": e.response.status_code,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": f"Request failed: {str(e)}"}
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
if self._client and not self._client.is_closed:
|
if self._client and not self._client.is_closed:
|
||||||
await self._client.aclose()
|
await self._client.aclose()
|
||||||
@@ -224,6 +304,159 @@ async def outline_api_call(method: str, params: Any = None) -> str:
|
|||||||
return json.dumps(result, indent=2)
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
async def _upload_attachment(document_id: str, file_path: str) -> dict:
|
||||||
|
"""Shared helper: validate file, create attachment, upload to presigned URL.
|
||||||
|
|
||||||
|
Returns a dict with keys: ok, attachment_url, name, size, content_type
|
||||||
|
or ok=False, error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
validated_path = _validate_file_path(file_path)
|
||||||
|
except ValueError as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
file_size = os.path.getsize(validated_path)
|
||||||
|
if file_size > MAX_FILE_SIZE:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": f"File exceeds 50MB limit ({file_size} bytes)",
|
||||||
|
}
|
||||||
|
|
||||||
|
content_type = _get_content_type(validated_path) or "application/octet-stream"
|
||||||
|
filename = os.path.basename(validated_path)
|
||||||
|
|
||||||
|
create_result = await outline_client.call(
|
||||||
|
"attachments.create",
|
||||||
|
{
|
||||||
|
"name": filename,
|
||||||
|
"documentId": document_id,
|
||||||
|
"size": file_size,
|
||||||
|
"contentType": content_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not create_result.get("ok"):
|
||||||
|
return create_result
|
||||||
|
|
||||||
|
data = create_result.get("data", {})
|
||||||
|
upload_url = data.get("uploadUrl")
|
||||||
|
attachment_url = data.get("url") or data.get("attachmentUrl")
|
||||||
|
|
||||||
|
if not upload_url:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "No uploadUrl returned from attachments.create",
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_result = await outline_client.upload_to_presigned_url(
|
||||||
|
upload_url, validated_path, content_type
|
||||||
|
)
|
||||||
|
|
||||||
|
if not upload_result.get("ok"):
|
||||||
|
return upload_result
|
||||||
|
|
||||||
|
if not attachment_url:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "Attachment created and uploaded, but no public URL was returned",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"attachment_url": attachment_url,
|
||||||
|
"name": filename,
|
||||||
|
"size": file_size,
|
||||||
|
"content_type": content_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def attach_file_to_document(document_id: str, file_path: str) -> str:
|
||||||
|
"""Upload a local file as an attachment to an existing Outline document.
|
||||||
|
|
||||||
|
The file must be located under /tmp. Files larger than 50MB are rejected.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: The document UUID to attach the file to
|
||||||
|
file_path: Absolute path to the file (must be under /tmp)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON string with attachment URL and Markdown ready for insertion
|
||||||
|
"""
|
||||||
|
result = await _upload_attachment(document_id, file_path)
|
||||||
|
if not result.get("ok"):
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
result["markdown"] = f"[{result['name']}]({result['attachment_url']})"
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def upload_image_to_document(
|
||||||
|
image_path: str, document_id: str, alt_text: str = None
|
||||||
|
) -> str:
|
||||||
|
"""Upload an image file as an attachment and return Markdown embed syntax.
|
||||||
|
|
||||||
|
The file must be located under /tmp. Files larger than 50MB are rejected.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Absolute path to the image (must be under /tmp)
|
||||||
|
document_id: The document UUID to attach the image to
|
||||||
|
alt_text: Optional alt text for the image (defaults to filename)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON string with image URL and Markdown embed ready for insertion
|
||||||
|
"""
|
||||||
|
result = await _upload_attachment(document_id, image_path)
|
||||||
|
if not result.get("ok"):
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
alt = alt_text or result["name"]
|
||||||
|
result["markdown"] = f""
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def import_file_to_outline(
|
||||||
|
file_path: str, collection_id: str, parent_document_id: str = None
|
||||||
|
) -> str:
|
||||||
|
"""Import a local file (Markdown, text, JSON, CSV) as a new Outline document.
|
||||||
|
|
||||||
|
The file must be located under /tmp. Files larger than 50MB are rejected.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Absolute path to the file (must be under /tmp)
|
||||||
|
collection_id: The collection UUID to create the document in
|
||||||
|
parent_document_id: Optional parent document UUID for nesting
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON string with the newly created document metadata
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
validated_path = _validate_file_path(file_path)
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"ok": False, "error": str(e)}, indent=2)
|
||||||
|
|
||||||
|
file_size = os.path.getsize(validated_path)
|
||||||
|
if file_size > MAX_FILE_SIZE:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": f"File exceeds 50MB limit ({file_size} bytes)",
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
fields = {"collectionId": collection_id}
|
||||||
|
if parent_document_id:
|
||||||
|
fields["parentDocumentId"] = parent_document_id
|
||||||
|
|
||||||
|
result = await outline_client.call_multipart(
|
||||||
|
"documents.import", validated_path, fields
|
||||||
|
)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
# --- API Reference Resource ---
|
# --- API Reference Resource ---
|
||||||
API_REFERENCE = """# Outline API Reference
|
API_REFERENCE = """# Outline API Reference
|
||||||
|
|
||||||
@@ -553,17 +786,30 @@ async def health(request):
|
|||||||
return JSONResponse(response)
|
return JSONResponse(response)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def app_lifespan(app):
|
||||||
|
"""Manage application lifespan, closing the Outline client on shutdown."""
|
||||||
|
yield
|
||||||
|
await outline_client.close()
|
||||||
|
|
||||||
|
|
||||||
# --- ASGI Application ---
|
# --- ASGI Application ---
|
||||||
def create_app():
|
def create_app():
|
||||||
"""Create the ASGI application with health check and MCP routes."""
|
"""Create the ASGI application with health check and MCP routes."""
|
||||||
mcp_app = mcp.http_app()
|
mcp_app = mcp.http_app()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def combined_lifespan(app):
|
||||||
|
async with mcp_app.lifespan(app):
|
||||||
|
async with app_lifespan(app):
|
||||||
|
yield
|
||||||
|
|
||||||
routes = [
|
routes = [
|
||||||
Route("/health", health, methods=["GET"]),
|
Route("/health", health, methods=["GET"]),
|
||||||
Mount("/", app=mcp_app),
|
Mount("/", app=mcp_app),
|
||||||
]
|
]
|
||||||
|
|
||||||
return Starlette(routes=routes, lifespan=mcp_app.lifespan)
|
return Starlette(routes=routes, lifespan=combined_lifespan)
|
||||||
|
|
||||||
|
|
||||||
# Create the app instance for uvicorn
|
# Create the app instance for uvicorn
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
# Set env vars before importing server
|
||||||
|
os.environ["OUTLINE_API_URL"] = "https://test.example.com"
|
||||||
|
os.environ["OUTLINE_API_TOKEN"] = "test-token"
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from server import (
|
||||||
|
_validate_file_path,
|
||||||
|
_get_content_type,
|
||||||
|
OutlineClient,
|
||||||
|
attach_file_to_document,
|
||||||
|
upload_image_to_document,
|
||||||
|
import_file_to_outline,
|
||||||
|
outline_client,
|
||||||
|
MAX_FILE_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateFilePath:
|
||||||
|
def test_valid_file_under_tmp(self, tmp_path):
|
||||||
|
test_file = tmp_path / "test.txt"
|
||||||
|
test_file.write_text("hello")
|
||||||
|
result = _validate_file_path(str(test_file))
|
||||||
|
assert result == str(test_file)
|
||||||
|
|
||||||
|
def test_path_outside_tmp_rejected(self):
|
||||||
|
with pytest.raises(ValueError, match="must be within"):
|
||||||
|
_validate_file_path("/etc/passwd")
|
||||||
|
|
||||||
|
def test_path_traversal_rejected(self):
|
||||||
|
with pytest.raises(ValueError, match="must be within"):
|
||||||
|
_validate_file_path("/tmp/../../etc/passwd")
|
||||||
|
|
||||||
|
def test_nonexistent_file_rejected(self, tmp_path):
|
||||||
|
with pytest.raises(ValueError, match="File not found"):
|
||||||
|
_validate_file_path(str(tmp_path / "nonexistent.txt"))
|
||||||
|
|
||||||
|
def test_directory_rejected(self, tmp_path):
|
||||||
|
with pytest.raises(ValueError, match="not a file"):
|
||||||
|
_validate_file_path(str(tmp_path))
|
||||||
|
|
||||||
|
def test_symlink_traversal_rejected(self, tmp_path):
|
||||||
|
evil_link = tmp_path / "evil"
|
||||||
|
evil_link.symlink_to("/etc/passwd")
|
||||||
|
with pytest.raises(ValueError, match="must be within"):
|
||||||
|
_validate_file_path(str(evil_link))
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetContentType:
|
||||||
|
def test_known_image_types(self):
|
||||||
|
assert _get_content_type("test.png") == "image/png"
|
||||||
|
assert _get_content_type("test.jpg") == "image/jpeg"
|
||||||
|
assert _get_content_type("test.jpeg") == "image/jpeg"
|
||||||
|
|
||||||
|
def test_known_document_types(self):
|
||||||
|
assert _get_content_type("test.pdf") == "application/pdf"
|
||||||
|
assert _get_content_type("test.md") == "text/markdown"
|
||||||
|
assert _get_content_type("test.txt") == "text/plain"
|
||||||
|
|
||||||
|
def test_unknown_type_returns_none(self):
|
||||||
|
assert _get_content_type("test.unknownxyz") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestOutlineClientUpload:
|
||||||
|
async def test_upload_to_presigned_url_success(self):
|
||||||
|
client = OutlineClient("https://api.example.com", "token")
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient") as mock_client_class:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.put = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client_class.return_value.__aenter__ = AsyncMock(
|
||||||
|
return_value=mock_client
|
||||||
|
)
|
||||||
|
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".txt", delete=False
|
||||||
|
) as f:
|
||||||
|
f.write("test content")
|
||||||
|
temp_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await client.upload_to_presigned_url(
|
||||||
|
"https://upload.example.com", temp_path, "text/plain"
|
||||||
|
)
|
||||||
|
assert result["ok"] is True
|
||||||
|
finally:
|
||||||
|
os.unlink(temp_path)
|
||||||
|
|
||||||
|
async def test_upload_to_presigned_url_http_error(self):
|
||||||
|
client = OutlineClient("https://api.example.com", "token")
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 403
|
||||||
|
mock_response.text = "Forbidden"
|
||||||
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
|
mock_response.raise_for_status.side_effect = HTTPStatusError(
|
||||||
|
"Forbidden", request=MagicMock(), response=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient") as mock_client_class:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.put = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client_class.return_value.__aenter__ = AsyncMock(
|
||||||
|
return_value=mock_client
|
||||||
|
)
|
||||||
|
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".txt", delete=False
|
||||||
|
) as f:
|
||||||
|
f.write("test content")
|
||||||
|
temp_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await client.upload_to_presigned_url(
|
||||||
|
"https://upload.example.com", temp_path, "text/plain"
|
||||||
|
)
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert result["status"] == 403
|
||||||
|
finally:
|
||||||
|
os.unlink(temp_path)
|
||||||
|
|
||||||
|
async def test_call_multipart_success(self):
|
||||||
|
client = OutlineClient("https://api.example.com", "token")
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.json = MagicMock(
|
||||||
|
return_value={"ok": True, "data": {"id": "doc-123"}}
|
||||||
|
)
|
||||||
|
mock_response.status_code = 200
|
||||||
|
|
||||||
|
client._client = MagicMock()
|
||||||
|
client._client.post = AsyncMock(return_value=mock_response)
|
||||||
|
client._client.is_closed = False
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".txt", delete=False
|
||||||
|
) as f:
|
||||||
|
f.write("test content")
|
||||||
|
temp_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await client.call_multipart(
|
||||||
|
"documents.import", temp_path, {"collectionId": "col-123"}
|
||||||
|
)
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["data"]["id"] == "doc-123"
|
||||||
|
finally:
|
||||||
|
os.unlink(temp_path)
|
||||||
|
|
||||||
|
async def test_call_multipart_http_error(self):
|
||||||
|
client = OutlineClient("https://api.example.com", "token")
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 400
|
||||||
|
mock_response.text = '{"message": "Bad request"}'
|
||||||
|
mock_response.json = MagicMock(return_value={"message": "Bad request"})
|
||||||
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
|
mock_response.raise_for_status.side_effect = HTTPStatusError(
|
||||||
|
"Bad request", request=MagicMock(), response=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client._client = MagicMock()
|
||||||
|
client._client.post = AsyncMock(return_value=mock_response)
|
||||||
|
client._client.is_closed = False
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".txt", delete=False
|
||||||
|
) as f:
|
||||||
|
f.write("test content")
|
||||||
|
temp_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await client.call_multipart(
|
||||||
|
"documents.import", temp_path, {"collectionId": "col-123"}
|
||||||
|
)
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert result["error"] == "Bad request"
|
||||||
|
assert result["status"] == 400
|
||||||
|
finally:
|
||||||
|
os.unlink(temp_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestAttachFileToDocument:
|
||||||
|
async def test_path_validation_failure(self):
|
||||||
|
result = await attach_file_to_document("doc-123", "/etc/passwd")
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed["ok"] is False
|
||||||
|
assert "must be within" in parsed["error"]
|
||||||
|
|
||||||
|
async def test_size_limit_failure(self, tmp_path, monkeypatch):
|
||||||
|
test_file = tmp_path / "large.bin"
|
||||||
|
test_file.write_bytes(b"x" * 100)
|
||||||
|
monkeypatch.setattr("server.MAX_FILE_SIZE", 50)
|
||||||
|
|
||||||
|
result = await attach_file_to_document("doc-123", str(test_file))
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed["ok"] is False
|
||||||
|
assert "exceeds 50MB" in parsed["error"]
|
||||||
|
|
||||||
|
async def test_successful_attachment(self, tmp_path, monkeypatch):
|
||||||
|
test_file = tmp_path / "test.txt"
|
||||||
|
test_file.write_text("hello world")
|
||||||
|
|
||||||
|
async def mock_call(method, params):
|
||||||
|
assert method == "attachments.create"
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"data": {
|
||||||
|
"uploadUrl": "https://presigned.example.com/upload",
|
||||||
|
"url": "https://cdn.example.com/attachment.txt",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def mock_upload(url, path, ct):
|
||||||
|
assert url == "https://presigned.example.com/upload"
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
monkeypatch.setattr(outline_client, "call", mock_call)
|
||||||
|
monkeypatch.setattr(outline_client, "upload_to_presigned_url", mock_upload)
|
||||||
|
|
||||||
|
result = await attach_file_to_document("doc-123", str(test_file))
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed["ok"] is True
|
||||||
|
assert parsed["name"] == "test.txt"
|
||||||
|
assert parsed["markdown"] == "[test.txt](https://cdn.example.com/attachment.txt)"
|
||||||
|
|
||||||
|
async def test_attachment_create_failure(self, tmp_path, monkeypatch):
|
||||||
|
test_file = tmp_path / "test.txt"
|
||||||
|
test_file.write_text("hello")
|
||||||
|
|
||||||
|
async def mock_call(method, params):
|
||||||
|
return {"ok": False, "error": "Document not found"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(outline_client, "call", mock_call)
|
||||||
|
|
||||||
|
result = await attach_file_to_document("doc-123", str(test_file))
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed["ok"] is False
|
||||||
|
assert "Document not found" in parsed["error"]
|
||||||
|
|
||||||
|
async def test_upload_failure(self, tmp_path, monkeypatch):
|
||||||
|
test_file = tmp_path / "test.txt"
|
||||||
|
test_file.write_text("hello")
|
||||||
|
|
||||||
|
async def mock_call(method, params):
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"data": {"uploadUrl": "https://presigned.example.com/upload"},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def mock_upload(url, path, ct):
|
||||||
|
return {"ok": False, "error": "Upload rejected", "status": 413}
|
||||||
|
|
||||||
|
monkeypatch.setattr(outline_client, "call", mock_call)
|
||||||
|
monkeypatch.setattr(outline_client, "upload_to_presigned_url", mock_upload)
|
||||||
|
|
||||||
|
result = await attach_file_to_document("doc-123", str(test_file))
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed["ok"] is False
|
||||||
|
assert parsed["error"] == "Upload rejected"
|
||||||
|
|
||||||
|
async def test_missing_attachment_url(self, tmp_path, monkeypatch):
|
||||||
|
"""If Outline returns uploadUrl but no public url, we should error."""
|
||||||
|
test_file = tmp_path / "test.txt"
|
||||||
|
test_file.write_text("hello")
|
||||||
|
|
||||||
|
async def mock_call(method, params):
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"data": {"uploadUrl": "https://presigned.example.com/upload"},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def mock_upload(url, path, ct):
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
monkeypatch.setattr(outline_client, "call", mock_call)
|
||||||
|
monkeypatch.setattr(outline_client, "upload_to_presigned_url", mock_upload)
|
||||||
|
|
||||||
|
result = await attach_file_to_document("doc-123", str(test_file))
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed["ok"] is False
|
||||||
|
assert "no public URL" in parsed["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestUploadImageToDocument:
|
||||||
|
async def test_path_validation_failure(self):
|
||||||
|
result = await upload_image_to_document("/etc/passwd", "doc-123")
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed["ok"] is False
|
||||||
|
|
||||||
|
async def test_size_limit_failure(self, tmp_path, monkeypatch):
|
||||||
|
test_file = tmp_path / "large.png"
|
||||||
|
test_file.write_bytes(b"x" * 100)
|
||||||
|
monkeypatch.setattr("server.MAX_FILE_SIZE", 50)
|
||||||
|
|
||||||
|
result = await upload_image_to_document(str(test_file), "doc-123")
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed["ok"] is False
|
||||||
|
assert "exceeds 50MB" in parsed["error"]
|
||||||
|
|
||||||
|
async def test_successful_image_upload(self, tmp_path, monkeypatch):
|
||||||
|
test_file = tmp_path / "test.png"
|
||||||
|
test_file.write_text("fake png content")
|
||||||
|
|
||||||
|
async def mock_call(method, params):
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"data": {
|
||||||
|
"uploadUrl": "https://presigned.example.com/upload",
|
||||||
|
"url": "https://cdn.example.com/image.png",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def mock_upload(url, path, ct):
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
monkeypatch.setattr(outline_client, "call", mock_call)
|
||||||
|
monkeypatch.setattr(outline_client, "upload_to_presigned_url", mock_upload)
|
||||||
|
|
||||||
|
result = await upload_image_to_document(str(test_file), "doc-123", "My Alt")
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed["ok"] is True
|
||||||
|
assert parsed["markdown"] == ""
|
||||||
|
|
||||||
|
async def test_default_alt_text(self, tmp_path, monkeypatch):
|
||||||
|
test_file = tmp_path / "screenshot.png"
|
||||||
|
test_file.write_text("fake png content")
|
||||||
|
|
||||||
|
async def mock_call(method, params):
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"data": {
|
||||||
|
"uploadUrl": "https://presigned.example.com/upload",
|
||||||
|
"url": "https://cdn.example.com/image.png",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def mock_upload(url, path, ct):
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
monkeypatch.setattr(outline_client, "call", mock_call)
|
||||||
|
monkeypatch.setattr(outline_client, "upload_to_presigned_url", mock_upload)
|
||||||
|
|
||||||
|
result = await upload_image_to_document(str(test_file), "doc-123")
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed["ok"] is True
|
||||||
|
assert parsed["markdown"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestImportFileToOutline:
|
||||||
|
async def test_path_validation_failure(self):
|
||||||
|
result = await import_file_to_outline("/etc/passwd", "col-123")
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed["ok"] is False
|
||||||
|
assert "must be within" in parsed["error"]
|
||||||
|
|
||||||
|
async def test_size_limit_failure(self, tmp_path, monkeypatch):
|
||||||
|
test_file = tmp_path / "large.md"
|
||||||
|
test_file.write_bytes(b"x" * 100)
|
||||||
|
monkeypatch.setattr("server.MAX_FILE_SIZE", 50)
|
||||||
|
|
||||||
|
result = await import_file_to_outline(str(test_file), "col-123")
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed["ok"] is False
|
||||||
|
assert "exceeds 50MB" in parsed["error"]
|
||||||
|
|
||||||
|
async def test_successful_import(self, tmp_path, monkeypatch):
|
||||||
|
test_file = tmp_path / "notes.md"
|
||||||
|
test_file.write_text("# My Notes\n\nHello world")
|
||||||
|
|
||||||
|
async def mock_call_multipart(method, path, fields):
|
||||||
|
assert method == "documents.import"
|
||||||
|
assert fields["collectionId"] == "col-123"
|
||||||
|
return {"ok": True, "data": {"id": "doc-456", "url": "https://outline.example.com/doc/doc-456"}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(outline_client, "call_multipart", mock_call_multipart)
|
||||||
|
|
||||||
|
result = await import_file_to_outline(str(test_file), "col-123")
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed["ok"] is True
|
||||||
|
assert parsed["data"]["id"] == "doc-456"
|
||||||
|
|
||||||
|
async def test_import_with_parent_document(self, tmp_path, monkeypatch):
|
||||||
|
test_file = tmp_path / "notes.md"
|
||||||
|
test_file.write_text("# Child Doc")
|
||||||
|
|
||||||
|
captured_fields = {}
|
||||||
|
|
||||||
|
async def mock_call_multipart(method, path, fields):
|
||||||
|
captured_fields.update(fields)
|
||||||
|
return {"ok": True, "data": {"id": "doc-789"}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(outline_client, "call_multipart", mock_call_multipart)
|
||||||
|
|
||||||
|
result = await import_file_to_outline(
|
||||||
|
str(test_file), "col-123", parent_document_id="parent-999"
|
||||||
|
)
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed["ok"] is True
|
||||||
|
assert captured_fields["parentDocumentId"] == "parent-999"
|
||||||
|
|
||||||
|
async def test_import_failure(self, tmp_path, monkeypatch):
|
||||||
|
test_file = tmp_path / "notes.md"
|
||||||
|
test_file.write_text("# Notes")
|
||||||
|
|
||||||
|
async def mock_call_multipart(method, path, fields):
|
||||||
|
return {"ok": False, "error": "Collection not found"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(outline_client, "call_multipart", mock_call_multipart)
|
||||||
|
|
||||||
|
result = await import_file_to_outline(str(test_file), "col-123")
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed["ok"] is False
|
||||||
|
assert "Collection not found" in parsed["error"]
|
||||||
Reference in New Issue
Block a user