Skip to content

Commit 2b19037

Browse files
committed
Tests: API endpoint contracts + crypto filename/meta cipher
- API: rename_file (with/without type), delete_file/folder, trash mgmt (paginated, clear-all, restore_item with/without dest), path-based meta lookups, create_file_entry, replace_file, raw upload_chunk + download_chunk via direct requests.put/get (timeout, headers, error propagation), health_check, WebDAV compat layer fall-throughs. - Crypto: generate_bucket_key (deterministic, 64-hex, differs per bucket/mnemonic), filename encryption key (32-byte, deterministic), encrypt_meta/decrypt_meta AES-256-GCM round-trip (ASCII/unicode/ empty/wrong-key/truncated), full encrypt_filename/decrypt_filename protocol round-trip + deterministic IV + cross-bucket isolation. Coverage: 85% -> 88%; crypto: 85% -> 100%; api: 74% -> 98%. Tests: 495 -> 540.
1 parent 32e207f commit 2b19037

4 files changed

Lines changed: 443 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,12 @@ All Bandit medium+ findings (was 14, now 0) are resolved or annotated:
116116
|---------------------------------|---------:|
117117
| `config/config.py` | 85% |
118118
| `services/auth.py` | 100% |
119-
| `services/crypto.py` | 85% |
119+
| `services/crypto.py` | 100% |
120120
| `services/drive.py` | 87% |
121121
| `services/network_utils.py` | 90% |
122122
| `services/webdav_provider.py` | 84% |
123123
| `services/webdav_server.py` | 83% |
124-
| `utils/api.py` | 74% |
125-
| **Total** | **85%** |
124+
| `utils/api.py` | 98% |
125+
| **Total** | **88%** |
126126

127-
(Total tests: **495** passing in ~3 seconds.)
127+
(Total tests: **540** passing in ~3 seconds.)

readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ internxt-python/
270270
│ └── webdav_server.py # WebDAV server management
271271
├── utils/
272272
│ └── api.py # HTTP API client
273-
├── tests/ # Pytest suite (~500 tests, 85% coverage)
273+
├── tests/ # Pytest suite (~540 tests, 88% coverage)
274274
├── pyproject.toml # Pytest, coverage, ruff config
275275
├── requirements-dev.txt # Dev/test dependencies
276276
└── .github/workflows/ci.yml # Lint + type-check + test on Py 3.10/3.11/3.12

tests/test_api_endpoints_extra.py

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
"""More API endpoint contract tests covering everything we missed in the
2+
first pass: rename, delete, trash management, path-based lookup, network
3+
upload chunk transports, restore_item, replace_file, WebDAV compat layer.
4+
"""
5+
from unittest.mock import MagicMock, patch
6+
7+
import pytest
8+
import requests as requests_lib
9+
10+
from utils.api import api_client
11+
12+
13+
@pytest.fixture
14+
def capture():
15+
"""Capture _make_request calls."""
16+
captured = []
17+
def fake(method, url, **kwargs):
18+
captured.append((method, url, kwargs))
19+
resp = MagicMock()
20+
resp.content = b'{}'
21+
resp.json.return_value = {}
22+
return resp
23+
with patch.object(api_client, '_make_request', side_effect=fake):
24+
yield captured
25+
26+
27+
# ---------- rename_file / rename_folder ----------
28+
29+
def test_rename_file_with_type_includes_both_fields(capture):
30+
api_client.rename_file('uuid', 'newname', 'pdf')
31+
method, url, kwargs = capture[-1]
32+
assert method == 'PUT'
33+
assert url.endswith('/files/uuid/meta')
34+
assert kwargs['data']['plainName'] == 'newname'
35+
assert kwargs['data']['type'] == 'pdf'
36+
37+
38+
def test_rename_file_without_type_omits_type(capture):
39+
api_client.rename_file('uuid', 'newname')
40+
_, _, kwargs = capture[-1]
41+
assert kwargs['data']['plainName'] == 'newname'
42+
assert 'type' not in kwargs['data']
43+
44+
45+
def test_rename_folder_no_type_field(capture):
46+
api_client.rename_folder('uuid', 'NewFolder')
47+
method, url, kwargs = capture[-1]
48+
assert method == 'PUT'
49+
assert url.endswith('/folders/uuid/meta')
50+
assert kwargs['data'] == {'plainName': 'NewFolder'}
51+
52+
53+
# ---------- delete_file / delete_folder (single-item DELETE) ----------
54+
55+
def test_delete_file_uses_files_endpoint(capture):
56+
api_client.delete_file('file-uuid')
57+
method, url, _ = capture[-1]
58+
assert method == 'DELETE'
59+
assert url.endswith('/files/file-uuid')
60+
61+
62+
def test_delete_folder_uses_folders_endpoint(capture):
63+
api_client.delete_folder('folder-uuid')
64+
method, url, _ = capture[-1]
65+
assert method == 'DELETE'
66+
assert url.endswith('/folders/folder-uuid')
67+
68+
69+
# ---------- trash management ----------
70+
71+
def test_get_trash_content_paginated(capture):
72+
api_client.get_trash_content(offset=20, limit=10, item_type='files')
73+
method, url, kwargs = capture[-1]
74+
assert method == 'GET'
75+
assert url.endswith('/storage/trash/paginated')
76+
assert kwargs['params'] == {'offset': 20, 'limit': 10, 'type': 'files'}
77+
78+
79+
def test_get_trash_content_defaults(capture):
80+
api_client.get_trash_content()
81+
_, _, kwargs = capture[-1]
82+
assert kwargs['params']['offset'] == 0
83+
assert kwargs['params']['limit'] == 50
84+
assert kwargs['params']['type'] == 'both'
85+
86+
87+
def test_clear_trash_uses_delete_all(capture):
88+
api_client.clear_trash()
89+
method, url, _ = capture[-1]
90+
assert method == 'DELETE'
91+
assert url.endswith('/storage/trash/all')
92+
93+
94+
def test_restore_item_payload(capture):
95+
api_client.restore_item('item-uuid', 'file', 'dest-folder-uuid')
96+
method, url, kwargs = capture[-1]
97+
assert method == 'POST'
98+
assert url.endswith('/trash/restore')
99+
assert kwargs['data'] == {
100+
'uuid': 'item-uuid',
101+
'type': 'file',
102+
'destinationFolderUuid': 'dest-folder-uuid',
103+
}
104+
105+
106+
def test_restore_item_to_root_when_no_destination(capture):
107+
api_client.restore_item('item-uuid', 'folder')
108+
_, _, kwargs = capture[-1]
109+
assert kwargs['data']['destinationFolderUuid'] is None
110+
111+
112+
# ---------- path-based lookups ----------
113+
114+
def test_get_folder_by_path_endpoint(capture):
115+
api_client.get_folder_by_path('/Documents/Reports')
116+
method, url, kwargs = capture[-1]
117+
assert method == 'GET'
118+
assert url.endswith('/folders/meta')
119+
assert kwargs['params'] == {'path': '/Documents/Reports'}
120+
121+
122+
def test_get_file_by_path_endpoint(capture):
123+
api_client.get_file_by_path('/Docs/x.txt')
124+
method, url, kwargs = capture[-1]
125+
assert method == 'GET'
126+
assert url.endswith('/files/meta')
127+
assert kwargs['params'] == {'path': '/Docs/x.txt'}
128+
129+
130+
# ---------- create_file_entry / replace_file ----------
131+
132+
def test_create_file_entry_endpoint(capture):
133+
payload = {'folderUuid': 'p', 'plainName': 'doc', 'size': 100}
134+
api_client.create_file_entry(payload)
135+
method, url, kwargs = capture[-1]
136+
assert method == 'POST'
137+
assert url.endswith('/files')
138+
assert kwargs['data'] == payload
139+
140+
141+
def test_replace_file_uses_put_with_uuid(capture):
142+
api_client.replace_file('file-uuid', {'fileId': 'new', 'size': 200})
143+
method, url, kwargs = capture[-1]
144+
assert method == 'PUT'
145+
assert url.endswith('/files/file-uuid')
146+
assert kwargs['data'] == {'fileId': 'new', 'size': 200}
147+
148+
149+
# ---------- network upload/download chunk (real PUT/GET to pre-signed URLs) ----------
150+
151+
def test_upload_chunk_uses_raw_put(capture):
152+
"""upload_chunk talks directly to the pre-signed URL via requests.put,
153+
NOT through the session wrapper (no auth header pollution)."""
154+
fake_resp = MagicMock()
155+
fake_resp.raise_for_status.return_value = None
156+
with patch('utils.api.requests.put', return_value=fake_resp) as mock_put:
157+
api_client.upload_chunk('https://upload-url.example/blob', b"raw bytes")
158+
args, kwargs = mock_put.call_args
159+
assert args[0] == 'https://upload-url.example/blob'
160+
assert kwargs['data'] == b"raw bytes"
161+
assert kwargs['headers']['Content-Type'] == 'application/octet-stream'
162+
assert kwargs['timeout'] == 300
163+
164+
165+
def test_upload_chunk_propagates_http_errors():
166+
fake_resp = MagicMock()
167+
fake_resp.raise_for_status.side_effect = requests_lib.exceptions.HTTPError("502")
168+
with patch('utils.api.requests.put', return_value=fake_resp):
169+
with pytest.raises(requests_lib.exceptions.HTTPError):
170+
api_client.upload_chunk('https://u/', b"data")
171+
172+
173+
def test_download_chunk_uses_raw_get_returns_bytes():
174+
fake_resp = MagicMock()
175+
fake_resp.raise_for_status.return_value = None
176+
fake_resp.content = b"the encrypted bytes"
177+
with patch('utils.api.requests.get', return_value=fake_resp) as mock_get:
178+
out = api_client.download_chunk('https://download-url/blob')
179+
args, kwargs = mock_get.call_args
180+
assert args[0] == 'https://download-url/blob'
181+
assert kwargs['timeout'] == 300
182+
assert out == b"the encrypted bytes"
183+
184+
185+
def test_download_chunk_propagates_http_errors():
186+
fake_resp = MagicMock()
187+
fake_resp.raise_for_status.side_effect = requests_lib.exceptions.HTTPError("404")
188+
with patch('utils.api.requests.get', return_value=fake_resp):
189+
with pytest.raises(requests_lib.exceptions.HTTPError):
190+
api_client.download_chunk('https://d/')
191+
192+
193+
# ---------- health_check ----------
194+
195+
def test_health_check_returns_user_info_on_success():
196+
fake_user = {'uuid': 'u', 'email': 'u@ex.com'}
197+
with patch.object(api_client, 'get_user_info', return_value=fake_user):
198+
out = api_client.health_check()
199+
assert out == fake_user
200+
201+
202+
def test_health_check_returns_error_dict_on_failure():
203+
with patch.object(api_client, 'get_user_info',
204+
side_effect=ConnectionError("net")):
205+
out = api_client.health_check()
206+
assert out['status'] == 'error'
207+
assert 'failed' in out['message'].lower()
208+
209+
210+
# ---------- WebDAV compat: move_item / rename_item / trash_item / update_file ----------
211+
212+
def test_compat_move_item_tries_file_first(capture):
213+
"""Top-level move_item tries move_file, then falls back to move_folder."""
214+
with patch.object(api_client, 'move_file',
215+
return_value={'success': True}) as mock_mv:
216+
api_client.move_item('uuid', 'dest')
217+
mock_mv.assert_called_once_with('uuid', 'dest')
218+
219+
220+
def test_compat_move_item_falls_back_to_folder():
221+
with patch.object(api_client, 'move_file',
222+
side_effect=ValueError("not file")), \
223+
patch.object(api_client, 'move_folder',
224+
return_value={'success': True}) as mock_mvf:
225+
api_client.move_item('uuid', 'dest')
226+
mock_mvf.assert_called_once_with('uuid', 'dest')
227+
228+
229+
def test_compat_rename_item_with_extension_calls_rename_file_with_type():
230+
with patch.object(api_client, 'rename_file',
231+
return_value={'success': True}) as mock_rn:
232+
api_client.rename_item('uuid', 'foo.pdf')
233+
args, _ = mock_rn.call_args
234+
# rename_file('uuid', 'foo', 'pdf')
235+
assert args == ('uuid', 'foo', 'pdf')
236+
237+
238+
def test_compat_rename_item_without_extension_calls_rename_file_no_type():
239+
with patch.object(api_client, 'rename_file',
240+
return_value={'success': True}) as mock_rn:
241+
api_client.rename_item('uuid', 'README')
242+
args, _ = mock_rn.call_args
243+
# rename_file('uuid', 'README') — no type arg
244+
assert args == ('uuid', 'README')
245+
246+
247+
def test_compat_rename_item_falls_back_to_folder():
248+
with patch.object(api_client, 'rename_file',
249+
side_effect=ValueError("not file")), \
250+
patch.object(api_client, 'rename_folder',
251+
return_value={'success': True}) as mock_rnf:
252+
api_client.rename_item('uuid', 'NewName')
253+
mock_rnf.assert_called_once()
254+
255+
256+
def test_compat_trash_item_falls_back_to_folder():
257+
with patch.object(api_client, 'trash_file',
258+
side_effect=ValueError("not file")), \
259+
patch.object(api_client, 'trash_folder',
260+
return_value={'success': True}) as mock_tf:
261+
api_client.trash_item('uuid')
262+
mock_tf.assert_called_once()
263+
264+
265+
def test_compat_update_file_payload(capture):
266+
"""update_file (compat layer) wraps replace_file with fileId+size payload."""
267+
with patch.object(api_client, 'replace_file',
268+
return_value={'success': True}) as mock_replace:
269+
api_client.update_file('file-uuid', 'new-net-id', 5000)
270+
args, _ = mock_replace.call_args
271+
assert args[0] == 'file-uuid'
272+
assert args[1] == {'fileId': 'new-net-id', 'size': 5000}

0 commit comments

Comments
 (0)