Skip to content

Commit 4a09bf5

Browse files
committed
fix(file): verify the upload before storing the file entity
- assert the upload API actually reported Success, mwclient only raises on an error key - upload before store_entity so a failure cannot leave a metadata-only entity
1 parent bb49392 commit 4a09bf5

2 files changed

Lines changed: 62 additions & 8 deletions

File tree

‎src/osw/controller/file/wiki.py‎

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,24 @@ def reraise_upload_error(
4646
) from error
4747

4848

49+
def assert_upload_success(result: Any, title: str, host: str) -> None:
50+
"""Raises unless the upload API reports that it stored the file
51+
52+
mwclient raises only when the response carries an 'error' key. A file that
53+
MediaWiki declined for any other reason comes back as a normal return value
54+
with a result other than 'Success', so without this check the upload would
55+
fail silently.
56+
"""
57+
status = result.get("result") if isinstance(result, dict) else None
58+
if status == "Success":
59+
return
60+
warnings = result.get("warnings") if isinstance(result, dict) else None
61+
detail = f" Warnings: {warnings}." if warnings else ""
62+
raise ValueError(
63+
f"Upload of '{title}' to {host} did not succeed (result: {status}).{detail}"
64+
)
65+
66+
4967
class WikiFileController(model.WikiFile, RemoteFileController):
5068
"""File controller for wiki files"""
5169

@@ -168,15 +186,12 @@ def put(self, file: IO, **kwargs: Dict[str, Any]):
168186
}
169187
for key in ["entities", "namespace"]:
170188
se_params.pop(key, None) # avoid duplicated kwargs
171-
self.osw.store_entity(
172-
OSW.StoreEntityParam(
173-
entities=[self.cast(model.WikiFile, **wf_params)],
174-
namespace=self.namespace,
175-
**se_params,
176-
)
177-
)
189+
# Upload before storing the entity: MediaWiki offers no transaction
190+
# across the two writes, so one of them can be left standing. A file
191+
# page without metadata is visibly incomplete, while metadata without a
192+
# file looks like a valid entity until someone tries to download it.
178193
try:
179-
self.osw.mw_site.upload(
194+
result = self.osw.mw_site.upload(
180195
file=file,
181196
filename=self.title,
182197
# comment="",
@@ -185,6 +200,14 @@ def put(self, file: IO, **kwargs: Dict[str, Any]):
185200
)
186201
except mwclient.errors.APIError as e:
187202
reraise_upload_error(e, self.osw.mw_site, self.title, self.suffix)
203+
assert_upload_success(result, self.title, self.osw.mw_site.host)
204+
self.osw.store_entity(
205+
OSW.StoreEntityParam(
206+
entities=[self.cast(model.WikiFile, **wf_params)],
207+
namespace=self.namespace,
208+
**se_params,
209+
)
210+
)
188211

189212
def put_from(self, other: FileController, **kwargs: Dict[str, Any]):
190213
# if isinstance(file, LocalFileController) and self.suffix is None:

‎tests/test_wiki_file_upload_errors.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22
33
Regression guard for #51: a file rejected because of its extension must produce
44
an error that names the extension, instead of a bare MediaWiki API code.
5+
6+
Also guards the silent-failure path: mwclient raises only on an 'error' key, so
7+
an upload MediaWiki declined by other means must be caught by inspecting the
8+
returned result.
59
"""
610

711
import mwclient.errors
812
import pytest
913

1014
from osw.controller.file.wiki import (
15+
assert_upload_success,
1116
get_allowed_file_extensions,
1217
reraise_upload_error,
1318
)
@@ -85,3 +90,29 @@ def test_get_allowed_file_extensions_reads_siteinfo():
8590
site = _FakeSite(extensions=["png", "jpg"])
8691

8792
assert get_allowed_file_extensions(site) == ["png", "jpg"]
93+
94+
95+
def test_successful_upload_passes():
96+
assert (
97+
assert_upload_success({"result": "Success"}, "a.png", "wiki.example.org")
98+
is None
99+
)
100+
101+
102+
@pytest.mark.parametrize("result", [{}, None, {"result": "Poll"}])
103+
def test_upload_without_success_raises(result):
104+
"""Anything but a Success result means the file did not arrive."""
105+
with pytest.raises(ValueError) as exc_info:
106+
assert_upload_success(result, "a.png", "wiki.example.org")
107+
108+
assert "a.png" in str(exc_info.value)
109+
assert "wiki.example.org" in str(exc_info.value)
110+
111+
112+
def test_warned_upload_reports_the_warnings():
113+
result = {"result": "Warning", "warnings": {"badfilename": "a_png"}}
114+
115+
with pytest.raises(ValueError) as exc_info:
116+
assert_upload_success(result, "a.png", "wiki.example.org")
117+
118+
assert "badfilename" in str(exc_info.value)

0 commit comments

Comments
 (0)