Skip to content

Commit ba0742a

Browse files
authored
Saimon/pim 6659 (#344)
1 parent 6aef02e commit ba0742a

File tree

3 files changed

+254
-4
lines changed

3 files changed

+254
-4
lines changed

cterasdk/cio/core/commands.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ def __init__(self, function, receiver, path):
360360
self.path = automatic_resolution(path, receiver.context)
361361

362362
def get_parameter(self):
363-
return self.path.reference
363+
return self.path.relative_encode
364364

365365
def _before_command(self):
366366
raise_or_suppress_access_error(self._receiver, self.path)
@@ -422,7 +422,7 @@ def _before_command(self):
422422

423423
def get_parameter(self):
424424
param = Object()
425-
param.paths = ['/'.join([self.directory.absolute, filename]) for filename in self.objects]
425+
param.paths = [self.directory.join(filename).absolute_encode for filename in self.objects]
426426
param.snapshot = None
427427
param.password = None
428428
param.portalName = None
@@ -629,7 +629,7 @@ def _before_command(self):
629629
logger.info('Listing versions: %s', self.path)
630630

631631
def get_parameter(self):
632-
return self.path.absolute
632+
return self.path.absolute_encode
633633

634634
def _execute(self):
635635
with self.trace_execution():
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
from unittest import mock
2+
from urllib.parse import quote
3+
from datetime import datetime
4+
5+
import munch
6+
7+
from cterasdk.common import Object
8+
from tests.ut.core.user import base_user
9+
10+
11+
class TestSpecialCharacterPaths(base_user.BaseCoreServicesTest):
12+
"""
13+
PIM-6659: Verify that all file operations properly URL-encode paths
14+
containing special characters (%, #, spaces, etc.) when constructing
15+
WebDAV or API request parameters.
16+
"""
17+
18+
SPECIAL_FILENAMES = [
19+
'file_100%_done.txt',
20+
'file_%25_encoded.txt',
21+
'report #1.txt',
22+
'file with spaces.txt',
23+
'file_!@#$%^([{.txt',
24+
'résumé.txt',
25+
]
26+
27+
SPECIAL_DIRECTORIES = [
28+
'My Files/Documents 2026',
29+
'My Files/100% Complete',
30+
'My Files/Report #1',
31+
]
32+
33+
def setUp(self):
34+
super().setUp()
35+
self._special_dir = 'My Files/100% Complete'
36+
self._special_filename = 'file_100%_done.txt'
37+
self._special_path = f'{self._special_dir}/{self._special_filename}'
38+
39+
def _expected_encoded_absolute(self, path):
40+
return f'{self._base}/{quote(path)}'
41+
42+
# --- Open / handle ---
43+
44+
def test_handle_encodes_special_characters(self):
45+
for filename in self.SPECIAL_FILENAMES:
46+
path = f'My Files/{filename}'
47+
self._init_services()
48+
mock_download = mock.MagicMock()
49+
self._services.io._webdav.download = mock_download # pylint: disable=protected-access
50+
self._services.files.handle(path)
51+
call_args = mock_download.call_args[0]
52+
actual_path = str(call_args[0])
53+
expected_path = quote(f'My Files/{filename}')
54+
self.assertEqual(actual_path, expected_path,
55+
f'handle() did not encode path for filename: {filename}')
56+
57+
def test_handle_percent_in_directory(self):
58+
for directory in self.SPECIAL_DIRECTORIES:
59+
path = f'{directory}/document.txt'
60+
self._init_services()
61+
mock_download = mock.MagicMock()
62+
self._services.io._webdav.download = mock_download # pylint: disable=protected-access
63+
self._services.files.handle(path)
64+
call_args = mock_download.call_args[0]
65+
actual_path = str(call_args[0])
66+
expected_path = quote(path)
67+
self.assertEqual(actual_path, expected_path,
68+
f'handle() did not encode directory: {directory}')
69+
70+
# --- Delete ---
71+
72+
def test_delete_encodes_special_characters(self):
73+
for filename in self.SPECIAL_FILENAMES:
74+
path = f'My Files/{filename}'
75+
self._init_services(execute_response=self._task_reference)
76+
self._services.files.delete(path, wait=False)
77+
self._services.api.execute.assert_called_once_with('', 'deleteResources', mock.ANY)
78+
actual_param = self._services.api.execute.call_args[0][2]
79+
self.assertEqual(actual_param.urls[0].src, self._expected_encoded_absolute(path),
80+
f'delete() did not encode path for filename: {filename}')
81+
82+
# --- Undelete / Recover ---
83+
84+
def test_undelete_encodes_special_characters(self):
85+
for filename in self.SPECIAL_FILENAMES:
86+
path = f'My Files/{filename}'
87+
self._init_services(execute_response=self._task_reference)
88+
self._services.files.undelete(path, wait=False)
89+
self._services.api.execute.assert_called_once_with('', 'restoreResources', mock.ANY)
90+
actual_param = self._services.api.execute.call_args[0][2]
91+
self.assertEqual(actual_param.urls[0].src, self._expected_encoded_absolute(path),
92+
f'undelete() did not encode path for filename: {filename}')
93+
94+
# --- Copy ---
95+
96+
def test_copy_encodes_special_characters(self):
97+
for filename in self.SPECIAL_FILENAMES:
98+
source = f'My Files/{filename}'
99+
dest_dir = 'My Files/Archive'
100+
self._init_services(execute_response=self._task_reference)
101+
self._services.files.copy(source, destination=dest_dir, wait=False)
102+
self._services.api.execute.assert_called_once_with('', 'copyResources', mock.ANY)
103+
actual_param = self._services.api.execute.call_args[0][2]
104+
self.assertEqual(actual_param.urls[0].src, self._expected_encoded_absolute(source),
105+
f'copy() did not encode source for filename: {filename}')
106+
expected_dest = self._expected_encoded_absolute(f'{dest_dir}/{filename}')
107+
self.assertEqual(actual_param.urls[0].dest, expected_dest,
108+
f'copy() did not encode dest for filename: {filename}')
109+
110+
def test_copy_to_special_char_destination(self):
111+
source = 'My Files/document.txt'
112+
dest_dir = 'My Files/100% Complete'
113+
self._init_services(execute_response=self._task_reference)
114+
self._services.files.copy(source, destination=dest_dir, wait=False)
115+
actual_param = self._services.api.execute.call_args[0][2]
116+
expected_dest = self._expected_encoded_absolute(f'{dest_dir}/document.txt')
117+
self.assertEqual(actual_param.urls[0].dest, expected_dest)
118+
119+
# --- Move ---
120+
121+
def test_move_encodes_special_characters(self):
122+
for filename in self.SPECIAL_FILENAMES:
123+
source = f'My Files/{filename}'
124+
dest_dir = 'My Files/Archive'
125+
self._init_services(execute_response=self._task_reference)
126+
self._services.files.move(source, destination=dest_dir, wait=False)
127+
self._services.api.execute.assert_called_once_with('', 'moveResources', mock.ANY)
128+
actual_param = self._services.api.execute.call_args[0][2]
129+
self.assertEqual(actual_param.urls[0].src, self._expected_encoded_absolute(source),
130+
f'move() did not encode source for filename: {filename}')
131+
expected_dest = self._expected_encoded_absolute(f'{dest_dir}/{filename}')
132+
self.assertEqual(actual_param.urls[0].dest, expected_dest,
133+
f'move() did not encode dest for filename: {filename}')
134+
135+
def test_move_from_special_char_directory(self):
136+
source = 'My Files/100% Complete/document.txt'
137+
dest_dir = 'My Files/Archive'
138+
self._init_services(execute_response=self._task_reference)
139+
self._services.files.move(source, destination=dest_dir, wait=False)
140+
actual_param = self._services.api.execute.call_args[0][2]
141+
self.assertEqual(actual_param.urls[0].src, self._expected_encoded_absolute(source))
142+
143+
# --- Rename ---
144+
145+
def test_rename_encodes_special_characters(self):
146+
for filename in self.SPECIAL_FILENAMES:
147+
parent = 'My Files/Documents'
148+
path = f'{parent}/{filename}'
149+
new_name = 'renamed.txt'
150+
self._init_services(execute_response=self._task_reference)
151+
self._services.files.rename(path, new_name, wait=False)
152+
self._services.api.execute.assert_called_once_with('', 'moveResources', mock.ANY)
153+
actual_param = self._services.api.execute.call_args[0][2]
154+
self.assertEqual(actual_param.urls[0].src, self._expected_encoded_absolute(path),
155+
f'rename() did not encode source for filename: {filename}')
156+
expected_dest = self._expected_encoded_absolute(f'{parent}/{new_name}')
157+
self.assertEqual(actual_param.urls[0].dest, expected_dest,
158+
f'rename() did not encode dest for filename: {filename}')
159+
160+
def test_rename_to_special_char_name(self):
161+
parent = 'My Files/Documents'
162+
path = f'{parent}/document.txt'
163+
for new_name in self.SPECIAL_FILENAMES:
164+
self._init_services(execute_response=self._task_reference)
165+
self._services.files.rename(path, new_name, wait=False)
166+
actual_param = self._services.api.execute.call_args[0][2]
167+
expected_dest = self._expected_encoded_absolute(f'{parent}/{new_name}')
168+
self.assertEqual(actual_param.urls[0].dest, expected_dest,
169+
f'rename() did not encode dest for new name: {new_name}')
170+
171+
def test_rename_special_char_in_directory(self):
172+
parent = 'My Files/100% Complete'
173+
old_name = 'document.txt'
174+
new_name = 'renamed.txt'
175+
path = f'{parent}/{old_name}'
176+
self._init_services(execute_response=self._task_reference)
177+
self._services.files.rename(path, new_name, wait=False)
178+
actual_param = self._services.api.execute.call_args[0][2]
179+
self.assertEqual(actual_param.urls[0].src, self._expected_encoded_absolute(path))
180+
self.assertEqual(actual_param.urls[0].dest, self._expected_encoded_absolute(f'{parent}/{new_name}'))
181+
182+
def test_rename_percent_to_percent(self):
183+
parent = 'My Files'
184+
old_name = 'file_100%_done.txt'
185+
new_name = 'file_50%_done.txt'
186+
path = f'{parent}/{old_name}'
187+
self._init_services(execute_response=self._task_reference)
188+
self._services.files.rename(path, new_name, wait=False)
189+
actual_param = self._services.api.execute.call_args[0][2]
190+
self.assertEqual(actual_param.urls[0].src, self._expected_encoded_absolute(path))
191+
self.assertEqual(actual_param.urls[0].dest, self._expected_encoded_absolute(f'{parent}/{new_name}'))
192+
193+
# --- ListVersions ---
194+
195+
def test_versions_encodes_special_characters(self):
196+
for filename in self.SPECIAL_FILENAMES:
197+
path = f'My Files/{filename}'
198+
self._init_services(execute_response=[self._create_snapshot_response(path)])
199+
self._services.files.versions(path)
200+
self._services.api.execute.assert_called_once_with(
201+
'', 'listSnapshots', self._expected_encoded_absolute(path)
202+
)
203+
204+
def test_versions_percent_in_directory(self):
205+
path = 'My Files/100% Complete/document.txt'
206+
self._init_services(execute_response=[self._create_snapshot_response(path)])
207+
self._services.files.versions(path)
208+
self._services.api.execute.assert_called_once_with(
209+
'', 'listSnapshots', self._expected_encoded_absolute(path)
210+
)
211+
212+
# --- ListDir ---
213+
214+
def test_listdir_encodes_special_characters(self):
215+
self.patch_call("cterasdk.cio.core.commands.EnsureDirectory.execute")
216+
for directory in self.SPECIAL_DIRECTORIES:
217+
self._init_services(execute_response=Object(**{
218+
'errorType': None,
219+
'hasMore': False,
220+
'items': []
221+
}))
222+
list(self._services.files.listdir(directory))
223+
actual_param = self._services.api.execute.call_args[0][2]
224+
self.assertEqual(actual_param.root, self._expected_encoded_absolute(directory),
225+
f'listdir() did not encode path for directory: {directory}')
226+
227+
# --- Mkdir ---
228+
229+
def test_mkdir_encodes_special_characters(self):
230+
for directory in self.SPECIAL_DIRECTORIES:
231+
self._init_services()
232+
self._services.files.mkdir(directory)
233+
actual_param = self._services.api.execute.call_args[0][2]
234+
parts = directory.split('/')
235+
parent = '/'.join(parts[:-1])
236+
expected_parent = f'{self._base}/{quote(parent)}'
237+
self.assertEqual(actual_param.parentPath, expected_parent,
238+
f'mkdir() did not encode parentPath for directory: {directory}')
239+
240+
# --- Helpers ---
241+
242+
def _create_snapshot_response(self, path):
243+
return munch.Munch({
244+
'url': self._base,
245+
'path': path,
246+
'current': True,
247+
'startTimestamp': datetime.now().isoformat(),
248+
'calculatedTimestamp': datetime.now().isoformat()
249+
})

tests/ut/core/user/test_versions.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from datetime import datetime
2+
from urllib.parse import quote
23
import munch
34
from tests.ut.core.user import base_user
45

@@ -9,7 +10,7 @@ def test_list_versions(self):
910
directory = 'My Files'
1011
self._init_services(execute_response=[self._create_snapshot_response(directory, True)])
1112
ret = self._services.files.versions(directory)
12-
self._services.api.execute.assert_called_once_with('', 'listSnapshots', f'{self._base}/{directory}')
13+
self._services.api.execute.assert_called_once_with('', 'listSnapshots', f'{self._base}/{quote(directory)}')
1314
self.assertEqual(str(ret[0].path), directory)
1415

1516
def _create_snapshot_response(self, path, current):

0 commit comments

Comments
 (0)