diff --git a/src/osw/controller/file/wiki.py b/src/osw/controller/file/wiki.py index 8a032291..1db1a582 100644 --- a/src/osw/controller/file/wiki.py +++ b/src/osw/controller/file/wiki.py @@ -2,6 +2,8 @@ import os from typing import IO, Any, Dict, List, Optional +import mwclient.errors + from osw.controller.file.base import FileController from osw.controller.file.remote import RemoteFileController from osw.core import OSW, model @@ -9,6 +11,71 @@ from osw.wtsite import WtSite +def format_allowed_extensions(allowed: WtSite.AllowedFileExtensionsResult) -> str: + """Renders the allowed-extensions hint for a rejected-upload error message""" + if not allowed.extensions or allowed.fetched_at is None: + return "" + ttl_hours = int(WtSite.ALLOWED_FILE_EXTENSIONS_TTL.total_seconds() // 3600) + return ( + f" Extensions allowed there, read {allowed.fetched_at:%Y-%m-%d %H:%M:%S} and " + f"cached for {ttl_hours} h: {', '.join(sorted(allowed.extensions))}. " + "Call osw.site.clear_allowed_file_extensions_cache() to re-read the list " + "if the wiki configuration changed since." + ) + + +def reraise_upload_error( + error: mwclient.errors.APIError, site: WtSite, title: str, suffix: Optional[str] +) -> None: + """Turns a rejected file extension into a readable error, re-raises the rest + + MediaWiki reports a rejected extension as filetype-banned, + filetype-banned-type or filetype-badtype, depending on the version. + """ + if "filetype" not in str(getattr(error, "code", "")): + raise error + allowed = site.get_allowed_file_extensions() + hint = format_allowed_extensions(allowed) + raise ValueError( + f"Upload of '{title}' was rejected because the file extension " + f"'{suffix}' is not allowed on {site.mw_site.host}.{hint}" + ) from error + + +def assert_upload_success(result: Any, title: str, host: str) -> None: + """Raises unless the upload API reports that it stored the file + + mwclient raises only when the response carries an 'error' key. A file that + MediaWiki declined for any other reason comes back as a normal return value + with a result other than 'Success', so without this check the upload would + fail silently. + """ + status = result.get("result") if isinstance(result, dict) else None + if status == "Success": + return + warnings = result.get("warnings") if isinstance(result, dict) else None + detail = f" Warnings: {warnings}." if warnings else "" + raise ValueError( + f"Upload of '{title}' to {host} did not succeed (result: {status}).{detail}" + ) + + +def store_params_for_upload( + se_params: Dict[str, Any], page_existed: bool +) -> Dict[str, Any]: + """Picks the overwrite policy for the entity that accompanies an upload + + An upload creates the file page when it was not there yet. store_entity + would then see an existing page and, under the default 'keep existing', + leave the metadata unwritten. So the entity has to replace what the upload + put there. A page that was already there keeps whatever the caller asked + for. + """ + if page_existed: + return se_params + return {**se_params, "overwrite": "replace remote"} + + class WikiFileController(model.WikiFile, RemoteFileController): """File controller for wiki files""" @@ -131,20 +198,29 @@ def put(self, file: IO, **kwargs: Dict[str, Any]): } for key in ["entities", "namespace"]: se_params.pop(key, None) # avoid duplicated kwargs + # Upload before storing the entity: MediaWiki offers no transaction + # across the two writes, so one of them can be left standing. A file + # page without metadata is visibly incomplete, while metadata without a + # file looks like a valid entity until someone tries to download it. + page_existed = self.osw.mw_site.pages[f"{self.namespace}:{self.title}"].exists + try: + result = self.osw.mw_site.upload( + file=file, + filename=self.title, + # comment="", + # description="", + ignore=True, + ) + except mwclient.errors.APIError as e: + reraise_upload_error(e, self.osw.site, self.title, self.suffix) + assert_upload_success(result, self.title, self.osw.mw_site.host) self.osw.store_entity( OSW.StoreEntityParam( entities=[self.cast(model.WikiFile, **wf_params)], namespace=self.namespace, - **se_params, + **store_params_for_upload(se_params, page_existed), ) ) - self.osw.mw_site.upload( - file=file, - filename=self.title, - # comment="", - # description="", - ignore=True, - ) def put_from(self, other: FileController, **kwargs: Dict[str, Any]): # if isinstance(file, LocalFileController) and self.suffix is None: diff --git a/src/osw/core.py b/src/osw/core.py index fd37d314..6f485f24 100644 --- a/src/osw/core.py +++ b/src/osw/core.py @@ -59,12 +59,14 @@ get_full_title, get_namespace, get_title, - get_uuid, is_empty, namespace_from_full_title, remove_empty, title_from_full_title, ) +from osw.utils.wiki import ( + get_uuid as get_uuid_from_osw_id, +) from osw.wiki_tools import SearchParam from osw.wtsite import WtPage, WtSite @@ -197,18 +199,21 @@ def get_osw_id(uuid: Union[str, UUID]) -> str: @staticmethod def get_uuid(osw_id: str) -> UUID: - """Returns the uuid for a given OSW-ID + """Returns the uuid for a given OSW-ID. Kept for backwards compatibility, + the implementation lives in osw.utils.wiki.get_uuid() Parameters ---------- osw_id - OSW-ID string, e.g. OSW2ea5b605c91f4e5a95593dff79fdd4a5 + OSW-ID string, e.g. OSW2ea5b605c91f4e5a95593dff79fdd4a5, with or + without file suffixes, e.g. + OSW2ea5b605c91f4e5a95593dff79fdd4a5.drawio.png Returns ------- uuid object, e.g. UUID("2ea5b605-c91f-4e5a-9559-3dff79fdd4a5") """ - return UUID(osw_id.replace("OSW", "")) + return get_uuid_from_osw_id(osw_id) class SortEntitiesResult(OswBaseModel): by_name: Dict[str, List[OswBaseModel]] @@ -1348,7 +1353,7 @@ def validate_entity(cls, entity, values): if jsondata is None: # Guard clause title = title_from_full_title(page.title) try: - uuid_from_title = get_uuid(title) + uuid_from_title = get_uuid_from_osw_id(title) except ValueError: print( f"Error: UUID could not be determined from title: '{title}', " diff --git a/src/osw/utils/wiki.py b/src/osw/utils/wiki.py index 054c3f77..ef7fe0dd 100644 --- a/src/osw/utils/wiki.py +++ b/src/osw/utils/wiki.py @@ -1,9 +1,17 @@ +import re from copy import deepcopy from uuid import UUID # Legacy imports: from opensemantic.v1 import get_full_title, get_namespace, get_title # noqa: F401 +OSW_ID_PATTERN = re.compile( + r"^(?:OSW)?" # the prefix, absent when a bare uuid is passed + r"([0-9a-fA-F]{32})" # the uuid, in the hex form an OSW-ID carries it + r"(?:\.[\w-]+)*$" # file suffixes, e.g. '.png' or '.drawio.png' +) +"""Matches an OSW-ID with an optional prefix and any number of file suffixes""" + def get_osw_id(uuid: UUID) -> str: """Generates a OSW-ID based on the given uuid by prefixing "OSW" and removing @@ -21,19 +29,33 @@ def get_osw_id(uuid: UUID) -> str: return "OSW" + str(uuid).replace("-", "") -def get_uuid(osw_id) -> UUID: - """Returns the uuid for a given OSW-ID. Duplicate of OSW.get_uuid() from src/sw/core/osw.py +def get_uuid(osw_id: str) -> UUID: + """Returns the uuid for a given OSW-ID. The single implementation, wrapped by + OSW.get_uuid() from src/osw/core.py + + A file page keeps its file extension in the title, so the OSW-ID of a file is + followed by one or more suffixes that are not part of the uuid. These are + ignored, as is the OSW prefix. Parameters ---------- osw_id - OSW-ID string, e.g., OSW2ea5b605c91f4e5a95593dff79fdd4a5 + OSW-ID string, e.g., OSW2ea5b605c91f4e5a95593dff79fdd4a5, with or without + file suffixes, e.g., OSW2ea5b605c91f4e5a95593dff79fdd4a5.drawio.png Returns ------- uuid object, e.g., UUID("2ea5b605-c91f-4e5a-9559-3dff79fdd4a5") + + Raises + ------ + ValueError + If no OSW-ID can be read from the given string. """ - return UUID(osw_id.replace("OSW", "")) + match = OSW_ID_PATTERN.match(osw_id) + if match is None: + raise ValueError(f"No OSW-ID could be read from '{osw_id}'") + return UUID(match.group(1)) def namespace_from_full_title(full_title: str) -> str: diff --git a/src/osw/wtsite.py b/src/osw/wtsite.py index 2c49b787..650c062b 100644 --- a/src/osw/wtsite.py +++ b/src/osw/wtsite.py @@ -10,7 +10,7 @@ import warnings import xml.etree.ElementTree as et from copy import deepcopy -from datetime import datetime +from datetime import datetime, timedelta from io import StringIO from pathlib import Path from pprint import pprint @@ -79,6 +79,9 @@ class WtSiteLegacyConfig(OswBaseModel): class Config: arbitrary_types_allowed = True + ALLOWED_FILE_EXTENSIONS_TTL = timedelta(hours=24) + """how long a fetched list of accepted file extensions stays valid""" + def __init__(self, config: Union[WtSiteConfig, WtSiteLegacyConfig]): """creates a new WtSite instance from a WtSiteConfig @@ -161,6 +164,10 @@ def __init__(self, config: Union[WtSiteConfig, WtSiteLegacyConfig]): self._page_cache = {} self._cache_enabled = False + # The file extensions the wiki accepts, read on demand and reused for + # ALLOWED_FILE_EXTENSIONS_TTL + self._allowed_file_extensions = None + def _get_session_lock(self) -> threading.RLock: """Return the session lock, lazily creating it if absent. @@ -492,6 +499,68 @@ def clear_cache(self): del self._page_cache self._page_cache = {} + class AllowedFileExtensionsResult(OswBaseModel): + """The file extensions a wiki accepts and when that list was read""" + + extensions: Optional[List[str]] + """the accepted extensions, None if the lookup failed""" + fetched_at: Optional[datetime] + """when the list was read from the wiki, None if the lookup failed""" + + def get_allowed_file_extensions( + self, refresh: bool = False + ) -> AllowedFileExtensionsResult: + """Returns the file extensions the wiki accepts, from cache if possible + + The list only changes when the wiki configuration changes, so it is + cached for ALLOWED_FILE_EXTENSIONS_TTL instead of being read on every + call. A failed lookup is never cached, so the next call retries; a + failed refresh leaves any previously cached list in place. + + Parameters + ---------- + refresh: + if True, bypasses the cache and re-reads the list from the wiki + + Returns + ------- + an AllowedFileExtensionsResult with the accepted extensions and + when they were read, or with both fields None if the lookup failed + """ + cached = getattr(self, "_allowed_file_extensions", None) + if ( + cached is not None + and not refresh + and cached.fetched_at is not None + and datetime.now() - cached.fetched_at < self.ALLOWED_FILE_EXTENSIONS_TTL + ): + return cached + try: + result = self.mw_site.api( + "query", meta="siteinfo", siprop="fileextensions", formatversion=2 + ) + extensions = [ + entry["ext"] + for entry in result.get("query", {}).get("fileextensions", []) + if "ext" in entry + ] + except Exception: + # only used to enrich an error message, never worth failing over + return WtSite.AllowedFileExtensionsResult(extensions=None, fetched_at=None) + self._allowed_file_extensions = WtSite.AllowedFileExtensionsResult( + extensions=extensions, fetched_at=datetime.now() + ) + return self._allowed_file_extensions + + def clear_allowed_file_extensions_cache(self): + """Clears the cached list of file extensions the wiki accepts + + The next get_allowed_file_extensions() call reads the list from the + wiki again. Use this when the wiki's upload configuration changed + within ALLOWED_FILE_EXTENSIONS_TTL. + """ + self._allowed_file_extensions = None + def _clear_cookies(self): # see https://github.com/mwclient/mwclient/issues/221 # Iterate a snapshot (list(...)) so mutating the jar mid-loop is safe, and diff --git a/tests/test_wiki_file_upload_errors.py b/tests/test_wiki_file_upload_errors.py new file mode 100644 index 00000000..bef59575 --- /dev/null +++ b/tests/test_wiki_file_upload_errors.py @@ -0,0 +1,180 @@ +"""Unit tests for upload error reporting in osw.controller.file.wiki. + +Regression guard for #51: a file rejected because of its extension must produce +an error that names the extension, instead of a bare MediaWiki API code. + +Also guards the silent-failure path: mwclient raises only on an 'error' key, so +an upload MediaWiki declined by other means must be caught by inspecting the +returned result. +""" + +import mwclient.errors +import pytest + +from osw.controller.file.wiki import ( + assert_upload_success, + format_allowed_extensions, + reraise_upload_error, + store_params_for_upload, +) +from osw.wtsite import WtSite + + +class _FakeMwSite: + """Stands in for the mwclient.Site behind WtSite.mw_site.""" + + host = "wiki.example.org" + + def __init__(self, extensions=None, fail=False): + self._extensions = extensions or [] + self._fail = fail + self.api_calls = 0 + + def api(self, *args, **kwargs): + self.api_calls += 1 + if self._fail: + raise RuntimeError("siteinfo unavailable") + return {"query": {"fileextensions": [{"ext": e} for e in self._extensions]}} + + +def _fake_site(extensions=None, fail=False): + """Builds a minimal WtSite carrying a fake mwclient site, bypassing __init__.""" + ws = WtSite.__new__(WtSite) + ws._site = _FakeMwSite(extensions=extensions, fail=fail) + ws._allowed_file_extensions = None + return ws + + +def _api_error(code): + return mwclient.errors.APIError(code, "info", {}) + + +@pytest.mark.parametrize( + "code", ["filetype-banned", "filetype-banned-type", "filetype-badtype"] +) +def test_rejected_extension_names_the_extension(code): + site = _fake_site(extensions=["png", "pdf"]) + + with pytest.raises(ValueError) as exc_info: + reraise_upload_error(_api_error(code), site, "OSW123.exe", ".exe") + + message = str(exc_info.value) + assert ".exe" in message + assert "OSW123.exe" in message + assert "wiki.example.org" in message + assert "pdf, png" in message # allowed extensions, sorted + + +def test_original_error_is_chained(): + site = _fake_site(extensions=["png"]) + original = _api_error("filetype-banned") + + with pytest.raises(ValueError) as exc_info: + reraise_upload_error(original, site, "OSW123.exe", ".exe") + + assert exc_info.value.__cause__ is original + + +def test_unrelated_api_error_is_reraised_unchanged(): + site = _fake_site(extensions=["png"]) + original = _api_error("readapidenied") + + with pytest.raises(mwclient.errors.APIError) as exc_info: + reraise_upload_error(original, site, "OSW123.png", ".png") + + assert exc_info.value is original + + +def test_message_omits_the_hint_when_siteinfo_fails(): + """A failing siteinfo lookup must not mask the upload error.""" + site = _fake_site(fail=True) + + with pytest.raises(ValueError) as exc_info: + reraise_upload_error(_api_error("filetype-banned"), site, "a.exe", ".exe") + + message = str(exc_info.value) + assert "Extensions allowed" not in message + assert "clear_allowed_file_extensions_cache" not in message + assert ".exe" in message + + +def test_message_names_the_fetch_time_and_the_reset_hint(): + site = _fake_site(extensions=["png", "pdf"]) + + with pytest.raises(ValueError) as exc_info: + reraise_upload_error(_api_error("filetype-banned"), site, "a.exe", ".exe") + + message = str(exc_info.value) + fetched_at = site.get_allowed_file_extensions().fetched_at + assert f"{fetched_at:%Y-%m-%d %H:%M:%S}" in message + assert "osw.site.clear_allowed_file_extensions_cache()" in message + + +def test_a_second_rejected_upload_does_not_query_the_wiki_again(): + site = _fake_site(extensions=["png", "pdf"]) + + with pytest.raises(ValueError): + reraise_upload_error(_api_error("filetype-banned"), site, "a.exe", ".exe") + with pytest.raises(ValueError): + reraise_upload_error(_api_error("filetype-banned"), site, "b.exe", ".exe") + + assert site.mw_site.api_calls == 1 + + +def test_format_allowed_extensions_is_empty_when_extensions_is_none(): + allowed = WtSite.AllowedFileExtensionsResult(extensions=None, fetched_at=None) + + assert format_allowed_extensions(allowed) == "" + + +def test_format_allowed_extensions_is_empty_when_extensions_is_empty(): + allowed = WtSite.AllowedFileExtensionsResult(extensions=[], fetched_at=None) + + assert format_allowed_extensions(allowed) == "" + + +def test_successful_upload_passes(): + assert ( + assert_upload_success({"result": "Success"}, "a.png", "wiki.example.org") + is None + ) + + +@pytest.mark.parametrize("result", [{}, None, {"result": "Poll"}]) +def test_upload_without_success_raises(result): + """Anything but a Success result means the file did not arrive.""" + with pytest.raises(ValueError) as exc_info: + assert_upload_success(result, "a.png", "wiki.example.org") + + assert "a.png" in str(exc_info.value) + assert "wiki.example.org" in str(exc_info.value) + + +def test_a_page_the_upload_created_gets_its_metadata_written(): + """Otherwise store_entity keeps the empty page the upload just left behind.""" + assert store_params_for_upload({}, page_existed=False) == { + "overwrite": "replace remote" + } + + +def test_an_existing_page_keeps_the_callers_overwrite_policy(): + se_params = {"overwrite": "keep existing", "edit_comment": "hi"} + + assert store_params_for_upload(se_params, page_existed=True) == se_params + + +def test_store_params_are_not_mutated(): + se_params = {"edit_comment": "hi"} + + store_params_for_upload(se_params, page_existed=False) + + assert se_params == {"edit_comment": "hi"} + + +def test_warned_upload_reports_the_warnings(): + result = {"result": "Warning", "warnings": {"badfilename": "a_png"}} + + with pytest.raises(ValueError) as exc_info: + assert_upload_success(result, "a.png", "wiki.example.org") + + assert "badfilename" in str(exc_info.value) diff --git a/tests/test_wtsite_allowed_file_extensions.py b/tests/test_wtsite_allowed_file_extensions.py new file mode 100644 index 00000000..24aca44e --- /dev/null +++ b/tests/test_wtsite_allowed_file_extensions.py @@ -0,0 +1,154 @@ +"""Unit tests for WtSite.get_allowed_file_extensions and its cache. + +These tests construct a WtSite without going through __init__ (which would +require network / credentials) and exercise the cache mechanics directly +against a fake mwclient site. +""" + +from datetime import datetime, timedelta + +from osw.wtsite import WtSite + + +class _FakeMwSite: + """Stands in for the mwclient.Site behind WtSite.mw_site.""" + + host = "wiki.example.org" + + def __init__(self, extensions=None, fail=False, payload=None): + self._extensions = extensions or [] + self._fail = fail + self._payload = payload + self.api_calls = 0 + self.calls = [] + """the (args, kwargs) of every api() call, to check the request sent""" + + def api(self, *args, **kwargs): + self.api_calls += 1 + self.calls.append((args, kwargs)) + if self._fail: + raise RuntimeError("siteinfo unavailable") + if self._payload is not None: + return self._payload + return {"query": {"fileextensions": [{"ext": e} for e in self._extensions]}} + + +def _fake_site(extensions=None, fail=False, payload=None): + """Builds a minimal WtSite carrying a fake mwclient site, bypassing __init__.""" + ws = WtSite.__new__(WtSite) + ws._site = _FakeMwSite(extensions=extensions, fail=fail, payload=payload) + ws._allowed_file_extensions = None + return ws + + +def test_reads_the_extensions_from_siteinfo_and_returns_a_fetched_at(): + site = _fake_site(extensions=["png", "pdf"]) + + result = site.get_allowed_file_extensions() + + assert result.extensions == ["png", "pdf"] + assert isinstance(result.fetched_at, datetime) + + +def test_a_second_call_returns_the_cached_value_without_querying_the_wiki_again(): + site = _fake_site(extensions=["png", "pdf"]) + + first = site.get_allowed_file_extensions() + second = site.get_allowed_file_extensions() + + assert second is first + assert site.mw_site.api_calls == 1 + + +def test_refresh_true_re_reads_and_updates_fetched_at(): + site = _fake_site(extensions=["png"]) + + first = site.get_allowed_file_extensions() + second = site.get_allowed_file_extensions(refresh=True) + + assert site.mw_site.api_calls == 2 + assert second.fetched_at >= first.fetched_at + + +def test_an_entry_older_than_the_ttl_is_re_read(): + site = _fake_site(extensions=["png"]) + site.get_allowed_file_extensions() + site._allowed_file_extensions.fetched_at = datetime.now() - timedelta(hours=25) + + site.get_allowed_file_extensions() + + assert site.mw_site.api_calls == 2 + + +def test_an_entry_23_hours_old_is_still_served_from_the_cache(): + site = _fake_site(extensions=["png"]) + site.get_allowed_file_extensions() + site._allowed_file_extensions.fetched_at = datetime.now() - timedelta(hours=23) + + site.get_allowed_file_extensions() + + assert site.mw_site.api_calls == 1 + + +def test_clear_allowed_file_extensions_cache_forces_a_re_read(): + site = _fake_site(extensions=["png"]) + site.get_allowed_file_extensions() + + site.clear_allowed_file_extensions_cache() + site.get_allowed_file_extensions() + + assert site.mw_site.api_calls == 2 + + +def test_a_failing_lookup_returns_none_and_is_not_cached(): + """A failed lookup must not poison the cache: a later successful call must + still return the real list, not the failure.""" + site = _fake_site(fail=True) + + failed = site.get_allowed_file_extensions() + + assert failed.extensions is None + assert failed.fetched_at is None + + site.mw_site._fail = False + site.mw_site._extensions = ["png"] + succeeded = site.get_allowed_file_extensions() + + assert succeeded.extensions == ["png"] + + +def test_a_wtsite_without_the_attribute_set_at_all_still_works(): + """A WtSite built via __new__ may not have _allowed_file_extensions set, + the getattr fallback in get_allowed_file_extensions must handle that.""" + ws = WtSite.__new__(WtSite) + ws._site = _FakeMwSite(extensions=["png"]) + + result = ws.get_allowed_file_extensions() + + assert result.extensions == ["png"] + + +def test_the_request_sent_matches_the_siteinfo_fileextensions_query(): + """A typo in the action, meta or siprop would otherwise pass unnoticed.""" + site = _fake_site(extensions=["png"]) + + site.get_allowed_file_extensions() + + args, kwargs = site.mw_site.calls[0] + assert args == ("query",) + assert kwargs == { + "meta": "siteinfo", + "siprop": "fileextensions", + "formatversion": 2, + } + + +def test_a_malformed_siteinfo_payload_returns_none_instead_of_raising(): + """fileextensions entries that are not mappings must not crash the lookup, + since it only ever enriches an error message.""" + site = _fake_site(payload={"query": {"fileextensions": [1, 2, 3]}}) + + result = site.get_allowed_file_extensions() + + assert result.extensions is None + assert result.fetched_at is None diff --git a/tests/utils/utils_test.py b/tests/utils/utils_test.py index c9519c4c..fab0b499 100644 --- a/tests/utils/utils_test.py +++ b/tests/utils/utils_test.py @@ -1,6 +1,9 @@ import uuid +import pytest + import osw.model.entity as model +from osw.core import OSW from osw.utils.regex import count_match_groups from osw.utils.strings import camel_case, pascal_case from osw.utils.wiki import ( @@ -28,6 +31,50 @@ def test_get_uuid(): assert get_uuid(osw_id) == uuid_ +@pytest.mark.parametrize( + "suffix", ["", ".png", ".drawio.png", ".tar.gz", ".jpg-2", ".a.b.c.d"] +) +def test_get_uuid_ignores_file_suffixes(suffix): + """File pages carry their extension in the title, ahead of the OSW-ID.""" + uuid_ = uuid.uuid4() + osw_id = f"OSW{str(uuid_).replace('-', '')}{suffix}" + + assert get_uuid(osw_id) == uuid_ + + +def test_get_uuid_accepts_a_bare_uuid(): + """The prefix is optional, as it was when it was stripped by str.replace.""" + uuid_ = uuid.uuid4() + + assert get_uuid(str(uuid_).replace("-", "")) == uuid_ + + +@pytest.mark.parametrize( + "osw_id", + [ + "", + "OSW", + "Category:OSW2ea5b605c91f4e5a95593dff79fdd4a5", # full title, not an ID + "OSW2ea5b605c91f4e5a95593dff79fdd4a", # one character short + "OSW2ea5b605c91f4e5a95593dff79fdd4a5x", + "OSW2ea5b605c91f4e5a95593dff79fdd4a5.", # empty suffix + "OSWzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "osw2ea5b605c91f4e5a95593dff79fdd4a5", # prefix is case sensitive + "2ea5b605-c91f-4e5a-9559-3dff79fdd4a5", # an OSW-ID carries no dashes + ], +) +def test_get_uuid_rejects_what_is_not_an_osw_id(osw_id): + with pytest.raises(ValueError): + get_uuid(osw_id) + + +def test_osw_get_uuid_delegates(): + """OSW.get_uuid is a wrapper, so it must handle suffixes just the same.""" + osw_id = "OSW2ea5b605c91f4e5a95593dff79fdd4a5.drawio.png" + + assert OSW.get_uuid(osw_id) == get_uuid(osw_id) + + def test_get_entity_namespace(): class DummyClass(model.Entity): pass