Files
shelfmark/tests/core/test_provider_account_access.py
T
splitsec2andCaliBrain 77b80eb31a fix(metadata): only an admin may use the provider's own account, its lists and shelves (#1501)
Refs #981.

Hardcover is signed in as one account, the instance owner's, and every
logged-in user shares it. A non-admin can see the owner's lists and
shelves in the List filter, browse one by asking for it, and add or
remove books and change read status from search results, which is what
#981 describes.

Now only an admin may. With authentication set to none the one user
keeps everything.

- `GET` targets and the batch lookup return no options for a non-admin,
so the buttons don't show and the account isn't asked.
- `PUT` targets answers 403.
- The List filter (`hardcover_list`) has no options for a non-admin, and
a request that asks for it has the field dropped before the search.
Providers name these fields in `account_search_fields`, and Hardcover
lists `hardcover_list`.
- Public things are unchanged: author, title and series suggestions, and
a pasted list URL in the search box.

That pasted URL is the one gap I know of. It resolves any list the
account can see, so a user who knows the slug of one of the owner's
private lists could still open it. Closing it means the provider has to
know who is asking, which is a bigger change than this one, so I left
it.

Not included from the issue: a toggle for letting users view lists and
limiting which lists they see. I kept this to the admin-only part so it
stays small, and I can do the toggle as a follow-up if you want it.

Tests cover the routes as an admin, a user and with no authentication
(targets read, batch and write, list options, a crafted list search, and
public fields still passing). Ruff, basedpyright and the backend suite
pass.

Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-10-20 23:29:05 -04:00

177 lines
6.6 KiB
Python

"""Only an admin may use the metadata provider's own account (Hardcover lists and shelves)."""
from __future__ import annotations
import importlib
from unittest.mock import patch
import pytest
from shelfmark.metadata_providers.hardcover import HardcoverProvider
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background startup disabled."""
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
@pytest.fixture
def provider(monkeypatch):
instance = HardcoverProvider(api_key="test-token")
calls: dict[str, list] = {"searched": [], "targets": [], "batch": [], "set": [], "options": []}
def _search_paginated(options):
from shelfmark.metadata_providers import SearchResult
calls["searched"].append(options)
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
monkeypatch.setattr(instance, "search_paginated", _search_paginated)
monkeypatch.setattr(instance, "is_available", lambda: True)
monkeypatch.setattr(
instance,
"get_book_targets",
lambda book_id: calls["targets"].append(book_id) or [{"value": "list:1", "label": "Mine"}],
)
monkeypatch.setattr(
instance,
"get_book_targets_batch",
lambda ids: calls["batch"].append(ids) or {i: [{"value": "list:1"}] for i in ids},
)
monkeypatch.setattr(
instance,
"set_book_target_state",
lambda book_id, target, *, selected: (
calls["set"].append((book_id, target, selected)) or {"changed": True}
),
)
monkeypatch.setattr(
instance,
"get_search_field_options",
lambda field_key, query=None: (
calls["options"].append(field_key) or [{"value": "status:1", "label": "Want to Read"}]
),
)
instance.calls = calls # type: ignore[attr-defined]
return instance
@pytest.fixture
def client(main_module, provider):
with (
patch("shelfmark.metadata_providers.get_provider", return_value=provider),
patch("shelfmark.metadata_providers.is_provider_registered", return_value=True),
patch("shelfmark.metadata_providers.is_provider_enabled", return_value=True),
patch("shelfmark.metadata_providers.get_provider_kwargs", return_value={}),
):
yield main_module.app.test_client()
def _session(client, *, is_admin: bool) -> None:
with client.session_transaction() as sess:
sess["user_id"] = "someone"
sess["is_admin"] = is_admin
def _as(main_module, client, *, is_admin: bool, auth_mode: str = "builtin"):
_session(client, is_admin=is_admin)
return patch.object(main_module, "get_auth_mode", return_value=auth_mode)
class TestTargets:
def test_a_user_sees_no_targets_and_the_account_is_not_asked(
self, main_module, client, provider
) -> None:
with _as(main_module, client, is_admin=False):
single = client.get("/api/metadata/book/hardcover/42/targets")
batch = client.post(
"/api/metadata/book/hardcover/targets/batch", json={"book_ids": [1, 2]}
)
assert single.json == {"options": []}
assert batch.json == {"results": {"1": [], "2": []}}
assert provider.calls["targets"] == []
assert provider.calls["batch"] == []
def test_a_user_cannot_change_a_list(self, main_module, client, provider) -> None:
with _as(main_module, client, is_admin=False):
resp = client.put(
"/api/metadata/book/hardcover/42/targets",
json={"target": "list:1", "selected": True},
)
assert resp.status_code == 403
assert provider.calls["set"] == []
def test_an_admin_keeps_targets(self, main_module, client, provider) -> None:
with _as(main_module, client, is_admin=True):
single = client.get("/api/metadata/book/hardcover/42/targets")
put = client.put(
"/api/metadata/book/hardcover/42/targets",
json={"target": "list:1", "selected": True},
)
assert single.json == {"options": [{"value": "list:1", "label": "Mine"}]}
assert put.status_code == 200
assert provider.calls["set"] == [("42", "list:1", True)]
def test_with_no_authentication_the_one_user_keeps_targets(
self, main_module, client, provider
) -> None:
with _as(main_module, client, is_admin=False, auth_mode="none"):
single = client.get("/api/metadata/book/hardcover/42/targets")
assert single.json == {"options": [{"value": "list:1", "label": "Mine"}]}
class TestListField:
def test_a_user_gets_no_list_options(self, main_module, client, provider) -> None:
with _as(main_module, client, is_admin=False):
resp = client.get("/api/metadata/field-options?provider=hardcover&field=hardcover_list")
assert resp.json == {"options": []}
assert provider.calls["options"] == []
def test_a_user_still_gets_public_suggestions(self, main_module, client, provider) -> None:
with _as(main_module, client, is_admin=False):
resp = client.get("/api/metadata/field-options?provider=hardcover&field=author&query=x")
assert resp.json["options"]
assert provider.calls["options"] == ["author"]
def test_an_admin_gets_the_list_options(self, main_module, client, provider) -> None:
with _as(main_module, client, is_admin=True):
resp = client.get("/api/metadata/field-options?provider=hardcover&field=hardcover_list")
assert resp.json["options"]
def test_a_user_cannot_browse_a_list_by_asking_for_it(
self, main_module, client, provider
) -> None:
with _as(main_module, client, is_admin=False):
client.get("/api/metadata/search?provider=hardcover&hardcover_list=status:1&query=dune")
(options,) = provider.calls["searched"]
assert "hardcover_list" not in options.fields
def test_a_user_can_still_search_with_public_fields(
self, main_module, client, provider
) -> None:
with _as(main_module, client, is_admin=False):
client.get("/api/metadata/search?provider=hardcover&author=id:1&query=dune")
(options,) = provider.calls["searched"]
assert options.fields == {"author": "id:1"}
def test_an_admin_can_browse_a_list(self, main_module, client, provider) -> None:
with _as(main_module, client, is_admin=True):
client.get("/api/metadata/search?provider=hardcover&hardcover_list=status:1&query=dune")
(options,) = provider.calls["searched"]
assert options.fields["hardcover_list"] == "status:1"