Files
shelfmark/tests/webshare/test_api.py
T
2007384058 feat(sources): add Webshare.cz as a release source (#1543)
## What

Adds [Webshare.cz](https://webshare.cz), the Czech file host, as a
release source for ebooks and audiobooks. It follows the Libgen source:
disabled by default, with a settings tab for credentials and a **Test
Login** button that reports whether the account is VIP.

## Why

Webshare is one of the main places Czech and Slovak ebooks and
audiobooks are shared, and none of the existing sources reach it.
Together with the Databáze knih metadata provider (#1479), this lets
Universal mode find Czech and Slovak titles end to end.

## How

- **API client** (`webshare/api.py`): Webshare's XML API (`POST
/api/<endpoint>/`). Login is the salted exchange the API documents:
fetch the salt, send `SHA1(MD5_CRYPT(password, salt))` plus an MD5
digest. Python dropped the `crypt` module in 3.13, so md5-crypt is a
small pure-Python port, checked against `openssl passwd -1`. The session
token is cached per account and refreshed once when an authenticated
call fails. File-level errors (`FILE_LINK_FATAL_*`) are not retried. XML
is parsed with `defusedxml`.
- **Search** (`webshare/source.py`): Webshare matches file names only,
so the file name becomes the release title and its extension the format.
Queries are title + author, then title only, or the manual query.
Results are filtered to the configured `SUPPORTED_FORMATS` /
`SUPPORTED_AUDIOBOOK_FORMATS`, plus `zip`/`rar` for audiobooks, since
they are usually uploaded as archives. Password-protected files are
skipped. The language is guessed from tags in the name (`CZ`, `SK`,
`ENG`, ...), and the vote balance is a sortable column. API failures
raise `SourceUnavailableError`.
- **Download** (`webshare/handler.py`): resolves a time-limited link per
attempt with `file_link`, then streams it into `TMP_DIR` with progress
and cancel support. Large audiobook archives are not buffered in memory.
- **Settings**: `WEBSHARE_ENABLED`, `WEBSHARE_USERNAME`,
`WEBSHARE_PASSWORD` (a `PasswordField`, already covered by the
`_PASSWORD` redaction in `genDebug.sh`), and `WEBSHARE_MAX_RESULTS`. The
`docs/environment-variables.md` section was added.

## Testing

- `tests/webshare/`: 31 offline tests. They cover md5-crypt vectors,
password hashing, XML parsing, token reuse and re-login, non-retried
file errors, availability gating, format filtering, query fallback,
release mapping, language guessing, and the handler's streaming,
small-file rejection, link errors and cancel.
- `ruff check` and `ruff format --check` are clean. The rest of the unit
suite fails the same way with and without this change: Windows-only
tests on my machine.
- Verified live against webshare.cz: search parsing and the salt
endpoint's error path.
- Still a draft: I haven't yet verified login and download with a real
account.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-10-22 20:30:01 -04:00

176 lines
5.8 KiB
Python

"""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"<?xml version='1.0' encoding='UTF-8'?><response><status>{status}</status>{body}</response>".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 <salt> <password>`.
("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("<salt>saltsalt</salt>"))
if url.endswith("/login/"):
return _response(_xml("<token>TOKEN1</token>"))
return _response(
_xml(
"<total>2</total>"
"<file><ident>abc123</ident><name>Kniha (CZ).epub</name><type>epub</type>"
"<size>123456</size><positive_votes>3</positive_votes>"
"<negative_votes>1</negative_votes><password>0</password></file>"
"<file><ident></ident><name>broken.epub</name></file>"
)
)
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("<salt>s</salt>"))
if url.endswith("/login/"):
logins.append(1)
return _response(_xml("<token>T</token>"))
return _response(_xml("<link>https://dl.example/file.epub</link>"))
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("<salt>s</salt>"))
if url.endswith("/login/"):
return _response(_xml(f"<token>{next(tokens)}</token>"))
seen_tokens.append(data["wst"])
if data["wst"] == "OLD":
return _response(
_xml("<code>SEARCH_FATAL_1</code><message>Bad token</message>", "FATAL")
)
return _response(_xml("<total>0</total>"))
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("<salt>s</salt>"))
if url.endswith("/login/"):
return _response(_xml("<token>T</token>"))
link_calls.append(1)
return _response(
_xml("<code>FILE_LINK_FATAL_1</code><message>File not found.</message>", "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"<html>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("<salt>s</salt>"))
if url.endswith("/login/"):
logins.append(data["password"])
return _response(_xml(f"<token>T{len(logins)}</token>"))
return _response(_xml("<link>https://dl.example/file.epub</link>"))
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]