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"] == "![My Alt](https://cdn.example.com/image.png)" 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"] == "![screenshot.png](https://cdn.example.com/image.png)" @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"]