@@ -139,6 +139,181 @@ async def test_upload_large_text(self, nc_mcp: McpTestHelper) -> None:
139139 assert len (result ) == 100_000
140140
141141
142+ class TestUploadFileBinary :
143+ """Binary upload round-trips through Nextcloud. Payloads are fetched back with
144+ raw WebDAV (bypassing `get_file`) so we can assert byte-for-byte equality.
145+ """
146+
147+ @pytest .mark .asyncio
148+ async def test_upload_png_round_trip (self , nc_mcp : McpTestHelper ) -> None :
149+ await nc_mcp .create_test_dir ()
150+ await nc_mcp .call (
151+ "upload_file_binary" ,
152+ path = f"{ TEST_BASE_DIR } /pixel.png" ,
153+ content_base64 = _TINY_PNG_B64 ,
154+ content_type = "image/png" ,
155+ )
156+ got , ct = await nc_mcp .client .dav_get (f"{ TEST_BASE_DIR } /pixel.png" )
157+ assert got == _TINY_PNG
158+ assert ct == "image/png"
159+
160+ @pytest .mark .asyncio
161+ async def test_upload_pdf_round_trip (self , nc_mcp : McpTestHelper ) -> None :
162+ await nc_mcp .create_test_dir ()
163+ pdf_bytes = (
164+ b"%PDF-1.4\n "
165+ b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n "
166+ b"2 0 obj<</Type/Pages/Count 0/Kids[]>>endobj\n "
167+ b"xref\n 0 3\n 0000000000 65535 f\n "
168+ b"0000000009 00000 n\n 0000000052 00000 n\n "
169+ b"trailer<</Size 3/Root 1 0 R>>\n startxref\n 91\n %%EOF\n "
170+ )
171+ pdf_b64 = base64 .b64encode (pdf_bytes ).decode ("ascii" )
172+ await nc_mcp .call (
173+ "upload_file_binary" ,
174+ path = f"{ TEST_BASE_DIR } /sample.pdf" ,
175+ content_base64 = pdf_b64 ,
176+ )
177+ got , ct = await nc_mcp .client .dav_get (f"{ TEST_BASE_DIR } /sample.pdf" )
178+ assert got == pdf_bytes
179+ assert ct == "application/pdf"
180+
181+ @pytest .mark .asyncio
182+ async def test_upload_full_byte_range (self , nc_mcp : McpTestHelper ) -> None :
183+ """Bytes 0x00-0xFF cover the full range that plain upload_file cannot send."""
184+ await nc_mcp .create_test_dir ()
185+ raw = bytes (range (256 )) * 8 # 2 KiB of every byte value
186+ b64 = base64 .b64encode (raw ).decode ("ascii" )
187+ await nc_mcp .call (
188+ "upload_file_binary" ,
189+ path = f"{ TEST_BASE_DIR } /all-bytes.bin" ,
190+ content_base64 = b64 ,
191+ content_type = "application/octet-stream" ,
192+ )
193+ got , _ = await nc_mcp .client .dav_get (f"{ TEST_BASE_DIR } /all-bytes.bin" )
194+ assert got == raw
195+
196+ @pytest .mark .asyncio
197+ async def test_content_type_inferred_from_extension (self , nc_mcp : McpTestHelper ) -> None :
198+ await nc_mcp .create_test_dir ()
199+ await nc_mcp .call (
200+ "upload_file_binary" ,
201+ path = f"{ TEST_BASE_DIR } /auto.png" ,
202+ content_base64 = _TINY_PNG_B64 ,
203+ )
204+ _ , ct = await nc_mcp .client .dav_get (f"{ TEST_BASE_DIR } /auto.png" )
205+ assert ct == "image/png"
206+
207+ @pytest .mark .asyncio
208+ async def test_overwrites_existing_file (self , nc_mcp : McpTestHelper ) -> None :
209+ await nc_mcp .create_test_dir ()
210+ first = base64 .b64encode (b"first-version" ).decode ("ascii" )
211+ second = base64 .b64encode (b"second-version-longer" ).decode ("ascii" )
212+ await nc_mcp .call ("upload_file_binary" , path = f"{ TEST_BASE_DIR } /ow.bin" , content_base64 = first )
213+ await nc_mcp .call ("upload_file_binary" , path = f"{ TEST_BASE_DIR } /ow.bin" , content_base64 = second )
214+ got , _ = await nc_mcp .client .dav_get (f"{ TEST_BASE_DIR } /ow.bin" )
215+ assert got == b"second-version-longer"
216+
217+ @pytest .mark .asyncio
218+ async def test_empty_content_creates_empty_file (self , nc_mcp : McpTestHelper ) -> None :
219+ await nc_mcp .create_test_dir ()
220+ await nc_mcp .call (
221+ "upload_file_binary" ,
222+ path = f"{ TEST_BASE_DIR } /empty.bin" ,
223+ content_base64 = "" ,
224+ content_type = "application/octet-stream" ,
225+ )
226+ got , _ = await nc_mcp .client .dav_get (f"{ TEST_BASE_DIR } /empty.bin" )
227+ assert got == b""
228+
229+ @pytest .mark .asyncio
230+ async def test_result_message_reports_bytes_and_type (self , nc_mcp : McpTestHelper ) -> None :
231+ await nc_mcp .create_test_dir ()
232+ result = await nc_mcp .call (
233+ "upload_file_binary" ,
234+ path = f"{ TEST_BASE_DIR } /reported.png" ,
235+ content_base64 = _TINY_PNG_B64 ,
236+ content_type = "image/png" ,
237+ )
238+ assert str (len (_TINY_PNG )) in result
239+ assert "image/png" in result
240+
241+ @pytest .mark .asyncio
242+ async def test_invalid_base64_raises (self , nc_mcp : McpTestHelper ) -> None :
243+ await nc_mcp .create_test_dir ()
244+ with pytest .raises (ToolError , match = r"not valid base64" ):
245+ await nc_mcp .call (
246+ "upload_file_binary" ,
247+ path = f"{ TEST_BASE_DIR } /bad.bin" ,
248+ content_base64 = "!!!not-base64!!!" ,
249+ )
250+
251+ @pytest .mark .asyncio
252+ async def test_mime_wrapped_base64_accepted (self , nc_mcp : McpTestHelper ) -> None :
253+ """MIME-style base64 (76-char line wraps with \\ r\\ n) should decode cleanly."""
254+ await nc_mcp .create_test_dir ()
255+ raw = bytes (range (200 ))
256+ wrapped = base64 .encodebytes (raw ).decode ("ascii" ) # always adds \n every 76 chars
257+ assert "\n " in wrapped
258+ await nc_mcp .call (
259+ "upload_file_binary" ,
260+ path = f"{ TEST_BASE_DIR } /wrapped.bin" ,
261+ content_base64 = wrapped ,
262+ )
263+ got , _ = await nc_mcp .client .dav_get (f"{ TEST_BASE_DIR } /wrapped.bin" )
264+ assert got == raw
265+
266+ @pytest .mark .asyncio
267+ async def test_base64_with_stray_whitespace_accepted (self , nc_mcp : McpTestHelper ) -> None :
268+ await nc_mcp .create_test_dir ()
269+ raw = b"payload with stray whitespace"
270+ encoded = base64 .b64encode (raw ).decode ("ascii" )
271+ dirty = f" { encoded [:10 ]} \t \n { encoded [10 :]} \r \n "
272+ await nc_mcp .call (
273+ "upload_file_binary" ,
274+ path = f"{ TEST_BASE_DIR } /dirty.bin" ,
275+ content_base64 = dirty ,
276+ )
277+ got , _ = await nc_mcp .client .dav_get (f"{ TEST_BASE_DIR } /dirty.bin" )
278+ assert got == raw
279+
280+ @pytest .mark .asyncio
281+ async def test_uploaded_image_readable_via_get_file (self , nc_mcp : McpTestHelper ) -> None :
282+ """Binary upload integrates with existing get_file image handling."""
283+ await nc_mcp .create_test_dir ()
284+ await nc_mcp .call (
285+ "upload_file_binary" ,
286+ path = f"{ TEST_BASE_DIR } /readable.png" ,
287+ content_base64 = _TINY_PNG_B64 ,
288+ )
289+ result = await nc_mcp .mcp ._tool_manager .call_tool ("get_file" , {"path" : f"{ TEST_BASE_DIR } /readable.png" })
290+ assert isinstance (result , list )
291+ item = result [0 ] # type: ignore[index]
292+ assert item .type == "image" # type: ignore[union-attr]
293+ assert item .mimeType == "image/png" # type: ignore[union-attr]
294+ assert base64 .b64decode (item .data ) == _TINY_PNG # type: ignore[union-attr]
295+
296+ @pytest .mark .asyncio
297+ async def test_read_only_blocks (self , nc_mcp_read_only : McpTestHelper ) -> None :
298+ with pytest .raises (ToolError , match = r"[Pp]ermission" ):
299+ await nc_mcp_read_only .call (
300+ "upload_file_binary" ,
301+ path = f"{ TEST_BASE_DIR } /denied.png" ,
302+ content_base64 = _TINY_PNG_B64 ,
303+ )
304+
305+ @pytest .mark .asyncio
306+ async def test_write_permission_allows (self , nc_mcp_write : McpTestHelper ) -> None :
307+ await nc_mcp_write .create_test_dir ()
308+ result = await nc_mcp_write .call (
309+ "upload_file_binary" ,
310+ path = f"{ TEST_BASE_DIR } /write-ok.png" ,
311+ content_base64 = _TINY_PNG_B64 ,
312+ content_type = "image/png" ,
313+ )
314+ assert "uploaded successfully" in result
315+
316+
142317class TestCreateDirectory :
143318 @pytest .mark .asyncio
144319 async def test_create_directory (self , nc_mcp : McpTestHelper ) -> None :
0 commit comments