"""Tests for the Webshare API client: password hashing, XML parsing, token retry.""" import hashlib from unittest.mock import MagicMock, patch import pytest from shelfmark.release_sources.webshare import api from shelfmark.release_sources.webshare.api import WebshareClient, WebshareError def _xml(body: str, status: str = "OK") -> bytes: return f"{status}{body}".encode() def _response(content: bytes) -> MagicMock: response = MagicMock() response.content = content response.raise_for_status.return_value = None return response @pytest.fixture(autouse=True) def _clear_tokens(): WebshareClient._tokens.clear() yield WebshareClient._tokens.clear() @pytest.mark.parametrize( ("password", "salt", "expected"), [ # Vectors from `openssl passwd -1 -salt `. ("password", "saltsalt", "$1$saltsalt$qjXMvbEw8oaL.CzflDtaK/"), ("heslo123ΔΎ", "UtvP0QwB", "$1$UtvP0QwB$BNPMnAafFtaDfoqW.SVSy/"), ("x" * 40, "$1$abc$", "$1$abc$b/oSMeDNO7QqHD3W/TXaY0"), ], ) def test_md5_crypt_matches_openssl(password, salt, expected): assert api.md5_crypt(password, salt) == expected def test_hash_password_is_sha1_of_md5crypt_and_md5_digest(): password_hash, digest = api.hash_password("user", "password", "saltsalt") expected_hash = hashlib.sha1(b"$1$saltsalt$qjXMvbEw8oaL.CzflDtaK/").hexdigest() assert password_hash == expected_hash assert digest == hashlib.md5(f"user:Webshare:{expected_hash}".encode()).hexdigest() def test_search_parses_files_and_sends_token(): calls = [] def post(url, data, **kwargs): calls.append((url, dict(data))) if url.endswith("/salt/"): return _response(_xml("saltsalt")) if url.endswith("/login/"): return _response(_xml("TOKEN1")) return _response( _xml( "2" "abc123Kniha (CZ).epubepub" "1234563" "10" "broken.epub" ) ) with patch.object(api.requests, "post", side_effect=post): files = WebshareClient("user", "pw").search("kniha", limit=10) assert [f.ident for f in files] == ["abc123"] assert files[0].size_bytes == 123456 assert files[0].positive_votes == 3 assert files[0].info_url == "https://webshare.cz/#/file/abc123" search_url, search_data = calls[-1] assert search_url == "https://webshare.cz/api/search/" assert search_data["wst"] == "TOKEN1" assert search_data["what"] == "kniha" def test_token_is_reused_between_calls(): logins = [] def post(url, data, **kwargs): if url.endswith("/salt/"): return _response(_xml("s")) if url.endswith("/login/"): logins.append(1) return _response(_xml("T")) return _response(_xml("https://dl.example/file.epub")) with patch.object(api.requests, "post", side_effect=post): WebshareClient("user", "pw").file_link("a") WebshareClient("user", "pw").file_link("b") assert len(logins) == 1 def test_expired_token_triggers_one_relogin(): tokens = iter(["OLD", "NEW"]) seen_tokens = [] def post(url, data, **kwargs): if url.endswith("/salt/"): return _response(_xml("s")) if url.endswith("/login/"): return _response(_xml(f"{next(tokens)}")) seen_tokens.append(data["wst"]) if data["wst"] == "OLD": return _response( _xml("SEARCH_FATAL_1Bad token", "FATAL") ) return _response(_xml("0")) with patch.object(api.requests, "post", side_effect=post): assert WebshareClient("user", "pw").search("x") == [] assert seen_tokens == ["OLD", "NEW"] def test_file_errors_are_not_retried(): link_calls = [] def post(url, data, **kwargs): if url.endswith("/salt/"): return _response(_xml("s")) if url.endswith("/login/"): return _response(_xml("T")) link_calls.append(1) return _response( _xml("FILE_LINK_FATAL_1File not found.", "FATAL") ) with ( patch.object(api.requests, "post", side_effect=post), pytest.raises(WebshareError) as exc_info, ): WebshareClient("user", "pw").file_link("gone") assert exc_info.value.code == "FILE_LINK_FATAL_1" assert str(exc_info.value) == "File not found." assert len(link_calls) == 1 def test_login_requires_credentials(): with pytest.raises(WebshareError): WebshareClient("", "").login() def test_unreadable_response_raises(): with ( patch.object(api.requests, "post", return_value=_response(b"nope")), pytest.raises(WebshareError), ): WebshareClient("user", "pw").login() def test_changed_password_forces_new_login(): logins = [] def post(url, data, **kwargs): if url.endswith("/salt/"): return _response(_xml("s")) if url.endswith("/login/"): logins.append(data["password"]) return _response(_xml(f"T{len(logins)}")) return _response(_xml("https://dl.example/file.epub")) with patch.object(api.requests, "post", side_effect=post): WebshareClient("user", "old").file_link("a") WebshareClient("user", "new").file_link("a") assert len(logins) == 2 assert logins[0] != logins[1]