@@ -80,7 +80,8 @@ def _dialog(chat_id, unread_count):
8080 )
8181
8282
83- def _patch_export (monkeypatch , client ):
83+ def _patch_export (monkeypatch , client , root ):
84+ monkeypatch .setattr (runtime , "SERVER_ALLOWED_ROOTS" , [root .resolve ()])
8485 monkeypatch .setattr (runtime , "clients" , {"default" : client })
8586 monkeypatch .setattr (messages , "get_client" , lambda account = None : client )
8687 monkeypatch .setattr (messages , "is_chat_allowlist_enabled" , lambda : False )
@@ -107,7 +108,7 @@ async def test_export_creates_output_file_with_messages(tmp_path, monkeypatch):
107108 dialogs = [_dialog (chat_id , unread_count = 2 )],
108109 messages_by_chat = {chat_id : msgs },
109110 )
110- _patch_export (monkeypatch , client )
111+ _patch_export (monkeypatch , client , tmp_path )
111112
112113 out = tmp_path / "out.json"
113114 result = await messages .export_unread_messages (chat_ids = [chat_id ], output_path = str (out ))
@@ -151,7 +152,7 @@ async def test_export_resume_skips_already_exported_chats(tmp_path, monkeypatch)
151152 new_chat : [_fake_message (10 , "new-chat-msg" )],
152153 },
153154 )
154- _patch_export (monkeypatch , client )
155+ _patch_export (monkeypatch , client , tmp_path )
155156
156157 result = await messages .export_unread_messages (
157158 chat_ids = [existing_chat , new_chat ],
@@ -199,7 +200,7 @@ async def test_export_no_resume_overwrites(tmp_path, monkeypatch):
199200 dialogs = [_dialog (chat_id , unread_count = 2 )],
200201 messages_by_chat = {chat_id : [_fake_message (1 , "fresh-one" ), _fake_message (2 , "fresh-two" )]},
201202 )
202- _patch_export (monkeypatch , client )
203+ _patch_export (monkeypatch , client , tmp_path )
203204
204205 result = await messages .export_unread_messages (
205206 chat_ids = [chat_id ],
@@ -231,7 +232,7 @@ async def test_export_returns_json_summary(tmp_path, monkeypatch):
231232 chat_id : [_fake_message (1 , "a" ), _fake_message (2 , "b" )],
232233 },
233234 )
234- _patch_export (monkeypatch , client )
235+ _patch_export (monkeypatch , client , tmp_path )
235236
236237 out = tmp_path / "out.json"
237238 result = await messages .export_unread_messages (chat_ids = [chat_id ], output_path = str (out ))
@@ -253,7 +254,7 @@ async def test_export_creates_parent_directories(tmp_path, monkeypatch):
253254 dialogs = [_dialog (chat_id , unread_count = 1 )],
254255 messages_by_chat = {chat_id : [_fake_message (1 , "nested" )]},
255256 )
256- _patch_export (monkeypatch , client )
257+ _patch_export (monkeypatch , client , tmp_path )
257258
258259 out = tmp_path / "nested" / "dir" / "out.json"
259260 assert not out .parent .exists ()
@@ -267,3 +268,48 @@ async def test_export_creates_parent_directories(tmp_path, monkeypatch):
267268 assert str (chat_id ) in data ["chats" ]
268269 assert summary ["status" ] == "ok"
269270 assert summary ["output_path" ] == str (out .resolve ())
271+
272+
273+ @pytest .mark .asyncio
274+ async def test_export_rejects_path_outside_allowed_roots (tmp_path , monkeypatch ):
275+ root = tmp_path / "root"
276+ root .mkdir ()
277+ client = FakeExportClient ()
278+ _patch_export (monkeypatch , client , root )
279+
280+ outside = tmp_path / "outside.json"
281+ outside .write_text ('{"marker": "fixture"}' , encoding = "utf-8" )
282+
283+ result = await messages .export_unread_messages (
284+ chat_ids = [], output_path = str (outside ), resume = True
285+ )
286+
287+ assert result == "Path is outside allowed roots."
288+ assert outside .read_text (encoding = "utf-8" ) == '{"marker": "fixture"}'
289+ assert client .get_messages_calls == []
290+
291+
292+ @pytest .mark .asyncio
293+ async def test_export_disabled_without_allowed_roots (tmp_path , monkeypatch ):
294+ client = FakeExportClient ()
295+ _patch_export (monkeypatch , client , tmp_path )
296+ monkeypatch .setattr (runtime , "SERVER_ALLOWED_ROOTS" , [])
297+
298+ out = tmp_path / "out.json"
299+ result = await messages .export_unread_messages (chat_ids = [], output_path = str (out ))
300+
301+ assert "disabled until allowed roots are configured" in result
302+ assert not out .exists ()
303+
304+
305+ @pytest .mark .asyncio
306+ async def test_export_relative_path_resolves_under_first_root (tmp_path , monkeypatch ):
307+ client = FakeExportClient ()
308+ _patch_export (monkeypatch , client , tmp_path )
309+
310+ result = await messages .export_unread_messages (chat_ids = [], output_path = "exports/out.json" )
311+ summary = _summary (result )
312+
313+ out = tmp_path / "exports" / "out.json"
314+ assert out .exists ()
315+ assert summary ["output_path" ] == str (out .resolve ())
0 commit comments