Skip to content

Commit 325ad2e

Browse files
cipokleanRayBB
andauthored
Pass the s3 cookie into get_loan_history_data (#14001)
* Pass the s3 cookie into get_loan_history_data /account/loan-history.json 500'd under FastAPI because get_loan_history_data read web.cookies(), which needs a web.py request. Callers now pass the cookie in, as fastapi/borrow.py already does, and a missing account raises AccountNotFoundError (404 at each route) instead of web.py's render.notfound. Closes #13959 * Trim loan history docstring and redundant tests The route test in test_account_loans.py already covers the cookie being passed through without web.cookies(), so drop the heavily mocked duplicate in test_lending.py and a repeated assertion. --------- Co-authored-by: Raymond Berger <RayBB@users.noreply.github.com>
1 parent 5c25015 commit 325ad2e

8 files changed

Lines changed: 94 additions & 20 deletions

File tree

‎openlibrary/core/lending.py‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -895,7 +895,11 @@ def get_lending_state(doc, user=None, check_loan_status=False) -> str:
895895
RESULTS_PER_PAGE: int = 25
896896

897897

898-
def get_loan_history_data(username: str, page: int) -> dict:
898+
class AccountNotFoundError(LookupError):
899+
"""No Open Library account exists for the requested username."""
900+
901+
902+
def get_loan_history_data(username: str, page: int, s3_cookie: str | None) -> dict:
899903
"""Fetch loan history data for a user.
900904
901905
This will use a patron's S3 keys to query the IA loan history API,
@@ -906,13 +910,16 @@ def get_loan_history_data(username: str, page: int) -> dict:
906910
includes items that are not in Open Library, and displaying only IA
907911
items creates pagination and navigation issues. For further discussion,
908912
see https://github.com/internetarchive/openlibrary/pull/8375.
909-
"""
910-
from infogami.utils.view import render
911913
914+
`s3_cookie` is the patron's encrypted `s3` cookie. Callers pass it in
915+
because FastAPI requests have no web.py context to read it from.
916+
917+
Raises AccountNotFoundError when `username` has no Open Library account.
918+
"""
912919
if not OpenLibraryAccount.get_by_username(username):
913-
raise render.notfound("Account not found for %s" % username, create=False)
920+
raise AccountNotFoundError(f"Account not found for {username}")
914921

915-
s3_keys = parse_s3_cookie(web.cookies().get("s3"))
922+
s3_keys = parse_s3_cookie(s3_cookie)
916923
limit = RESULTS_PER_PAGE
917924
offset = page * limit - limit
918925

‎openlibrary/fastapi/account.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from openlibrary.core.auth import ExpiredTokenError, HMACToken, MissingKeyError
2222
from openlibrary.core.env import get_ol_env
2323
from openlibrary.core.follows import PubSub
24+
from openlibrary.core.lending import AccountNotFoundError
2425
from openlibrary.fastapi.auth import (
2526
AuthenticatedUser,
2627
get_authenticated_user,
@@ -220,11 +221,14 @@ def account_loans_json(
220221

221222
@router.get("/account/loan-history.json", response_model=AccountLoanHistoryResponse)
222223
def account_loan_history_json(
224+
request: Request,
223225
_: Annotated[AuthenticatedUser, Depends(require_authenticated_user)],
224226
page: Annotated[int, Query(ge=1)] = 1,
225227
) -> dict:
226228
try:
227-
return legacy_account.get_account_loan_history_json(accounts.get_current_user(), page)
229+
return legacy_account.get_account_loan_history_json(accounts.get_current_user(), page, s3_cookie=request.cookies.get("s3"))
230+
except AccountNotFoundError as e:
231+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
228232
except Exception:
229233
if get_ol_env().LOCAL_DEV:
230234
raise HTTPException(

‎openlibrary/plugins/upstream/account.py‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
from openlibrary.core.carousels import get_carousel_data
4444
from openlibrary.core.db import get_db # noqa: TID251
4545
from openlibrary.core.follows import PubSub
46-
from openlibrary.core.lending import get_loan_history_data
46+
from openlibrary.core.lending import AccountNotFoundError, get_loan_history_data
4747
from openlibrary.core.observations import Observations
4848
from openlibrary.core.ratings import Ratings
4949
from openlibrary.i18n import gettext as _
@@ -1256,9 +1256,9 @@ def get_account_loans_json(user: User) -> dict[str, Any]:
12561256
return {"loans": loans}
12571257

12581258

1259-
def get_account_loan_history_json(user: User, page: int) -> dict[str, Any]:
1259+
def get_account_loan_history_json(user: User, page: int, s3_cookie: str | None) -> dict[str, Any]:
12601260
username = user["key"].split("/")[-1]
1261-
loan_history_data = dict(get_loan_history_data(username, page=page))
1261+
loan_history_data = dict(get_loan_history_data(username, page=page, s3_cookie=s3_cookie))
12621262
# Ensure all `docs` are `dicts`, as some are `Edition`s.
12631263
loan_history_data["docs"] = [loan.dict() if not isinstance(loan, dict) else loan for loan in loan_history_data["docs"]]
12641264
return {"loans_history": loan_history_data}
@@ -1293,7 +1293,10 @@ def GET(self):
12931293
provider = lenny.provider_loans(username)
12941294
docs = list(docs) + provider.loans
12951295

1296-
loan_history_data = get_loan_history_data(username, page=page)
1296+
try:
1297+
loan_history_data = get_loan_history_data(username, page=page, s3_cookie=web.cookies().get("s3"))
1298+
except AccountNotFoundError as e:
1299+
raise render.notfound(str(e), create=False)
12971300

12981301
staff_picks = get_carousel_data(carousels=("staff_picks",))["staff_picks"]
12991302

@@ -1346,8 +1349,12 @@ def GET(self):
13461349
i = web.input(page=1)
13471350
page = int(i.page)
13481351
user = accounts.get_current_user()
1352+
try:
1353+
data = get_account_loan_history_json(user, page, s3_cookie=web.cookies().get("s3"))
1354+
except AccountNotFoundError as e:
1355+
raise render.notfound(str(e), create=False)
13491356
web.header("Content-Type", "application/json")
1350-
return delegate.RawText(json.dumps(get_account_loan_history_json(user, page)))
1357+
return delegate.RawText(json.dumps(data))
13511358

13521359

13531360
class account_waitlist(delegate.page):

‎openlibrary/plugins/upstream/mybooks.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ def render_template(self, mb: MyBooksTemplate) -> TemplateResult:
140140
history_books = []
141141
if mb.is_my_page:
142142
try:
143-
history_data = get_loan_history_data(mb.username, page=1)
143+
history_data = get_loan_history_data(mb.username, page=1, s3_cookie=web.cookies().get("s3"))
144144
history_books = [doc for doc in history_data.get("docs", []) if not doc.get("ia_only")]
145145
except Exception:
146146
# Deliberately non-fatal: My Books must still render its

‎openlibrary/tests/core/test_lending.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -248,12 +248,11 @@ def test_returns_empty_history_when_patron_has_no_s3_keys(self):
248248
response.json.return_value = {"history": {"items": []}}
249249
with (
250250
patch.object(lending.OpenLibraryAccount, "get_by_username", return_value=Mock()),
251-
patch("openlibrary.core.lending.web.cookies", return_value={"s3": "irrelevant"}),
252251
patch("openlibrary.core.lending.parse_s3_cookie", return_value=None),
253252
patch("openlibrary.core.lending.s3_loan_api", return_value=response) as mock_api,
254253
patch("openlibrary.core.lending.get_items_and_add_availability", return_value={}),
255254
):
256-
result = lending.get_loan_history_data("someuser", page=1)
255+
result = lending.get_loan_history_data("someuser", page=1, s3_cookie="irrelevant")
257256

258257
# Must short-circuit: calling the real s3_loan_api with None keys raises
259258
# TypeError on `s3_keys | kwargs`, which 500s /account/loans.
@@ -273,12 +272,21 @@ def test_still_queries_ia_when_s3_keys_are_present(self):
273272
response.json.return_value = {"history": {"items": []}}
274273
with (
275274
patch.object(lending.OpenLibraryAccount, "get_by_username", return_value=Mock()),
276-
patch("openlibrary.core.lending.web.cookies", return_value={"s3": "irrelevant"}),
277275
patch("openlibrary.core.lending.parse_s3_cookie", return_value={"access": "a", "secret": "s"}),
278276
patch("openlibrary.core.lending.s3_loan_api", return_value=response) as mock_api,
279277
patch("openlibrary.core.lending.get_items_and_add_availability", return_value={}),
280278
):
281-
result = lending.get_loan_history_data("someuser", page=1)
279+
result = lending.get_loan_history_data("someuser", page=1, s3_cookie="irrelevant")
282280

283281
mock_api.assert_called_once()
284282
assert result["docs"] == []
283+
284+
def test_unknown_account_raises_account_not_found(self):
285+
with (
286+
patch.object(lending.OpenLibraryAccount, "get_by_username", return_value=None),
287+
patch("openlibrary.core.lending.s3_loan_api") as mock_api,
288+
pytest.raises(lending.AccountNotFoundError),
289+
):
290+
lending.get_loan_history_data("nobody", page=1, s3_cookie="irrelevant")
291+
292+
mock_api.assert_not_called()

‎openlibrary/tests/fastapi/test_account_loans.py‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,36 @@ def test_loan_history_json_serializes_docs_and_forwards_page(self, fastapi_clien
8282
assert get_data.called
8383
assert get_data.call_args.kwargs["page"] == page
8484

85+
def test_loan_history_json_passes_request_s3_cookie(self, fastapi_client, mock_authenticated_user):
86+
"""Regression for #13959: the route used to 500 with
87+
AttributeError: 'ThreadedDict' object has no attribute 'env' because
88+
get_loan_history_data() read web.cookies() on a FastAPI worker thread."""
89+
response = MagicMock()
90+
response.json.return_value = {"history": {"items": []}}
91+
92+
with (
93+
patch("openlibrary.fastapi.account.accounts.get_current_user", return_value=FakeLegacyUser()),
94+
patch("openlibrary.core.lending.OpenLibraryAccount.get_by_username", return_value=MagicMock()),
95+
patch("openlibrary.core.lending.parse_s3_cookie", return_value={"access": "a", "secret": "s"}) as parse_s3_cookie,
96+
patch("openlibrary.core.lending.s3_loan_api", return_value=response),
97+
patch("openlibrary.core.lending.get_items_and_add_availability", return_value={}),
98+
):
99+
fastapi_client.cookies.set("s3", "a%3As")
100+
result = fastapi_client.get("/account/loan-history.json")
101+
102+
assert result.status_code == 200
103+
assert result.json() == {"loans_history": {"docs": [], "show_next": False, "limit": 25, "page": 1}}
104+
parse_s3_cookie.assert_called_once_with("a%3As")
105+
106+
def test_loan_history_json_returns_404_when_account_missing(self, fastapi_client, mock_authenticated_user):
107+
with (
108+
patch("openlibrary.fastapi.account.accounts.get_current_user", return_value=FakeLegacyUser()),
109+
patch("openlibrary.core.lending.OpenLibraryAccount.get_by_username", return_value=None),
110+
):
111+
result = fastapi_client.get("/account/loan-history.json")
112+
113+
assert result.status_code == 404
114+
85115
def test_loan_history_json_rejects_invalid_page(self, fastapi_client, mock_authenticated_user):
86116
response = fastapi_client.get("/account/loan-history.json?page=0")
87117

@@ -147,12 +177,11 @@ def test_get_loan_history_data_handles_missing_or_null_history(self, mock_respon
147177

148178
with (
149179
patch("openlibrary.core.lending.OpenLibraryAccount.get_by_username", return_value=mock_account),
150-
patch("openlibrary.core.lending.web.cookies", return_value={"s3": "irrelevant"}),
151180
patch("openlibrary.core.lending.parse_s3_cookie", return_value={"access": "acc", "secret": "sec"}),
152181
patch("openlibrary.core.lending.s3_loan_api", return_value=mock_response) as mock_s3_loan_api,
153182
patch("openlibrary.core.lending.get_items_and_add_availability", return_value={}) as mock_availability,
154183
):
155-
result = get_loan_history_data(username="testuser", page=1)
184+
result = get_loan_history_data(username="testuser", page=1, s3_cookie="irrelevant")
156185

157186
assert result == {
158187
"docs": [],

‎openlibrary/tests/fastapi/test_mybooks_integration.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,20 @@
1010

1111
from unittest.mock import MagicMock, patch
1212

13+
import pytest
14+
1315
from openlibrary.plugins.upstream.mybooks import MyBooksTemplate, mybooks_home
1416
from openlibrary.utils.request_context import RequestContextVars, req_context
1517

1618

19+
@pytest.fixture(autouse=True)
20+
def _s3_cookie():
21+
"""render_template is a web.py handler and reads the patron's `s3` cookie
22+
with web.cookies(), which needs a web.py request these tests don't build."""
23+
with patch("openlibrary.plugins.upstream.mybooks.web.cookies", return_value={"s3": "test-s3"}):
24+
yield
25+
26+
1727
def _req_context():
1828
req_context.set(
1929
RequestContextVars(

‎openlibrary/tests/fastapi/test_unified_loans_carousel.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
from unittest.mock import MagicMock, patch
22

3+
import pytest
4+
35
from openlibrary.plugins.upstream.mybooks import MyBooksTemplate, mybooks_home
46
from openlibrary.utils.request_context import RequestContextVars, req_context
57

68

9+
@pytest.fixture(autouse=True)
10+
def _s3_cookie():
11+
"""render_template is a web.py handler and reads the patron's `s3` cookie
12+
with web.cookies(), which needs a web.py request these tests don't build."""
13+
with patch("openlibrary.plugins.upstream.mybooks.web.cookies", return_value={"s3": "test-s3"}):
14+
yield
15+
16+
717
def test_unified_loans_carousel_merges_active_and_history():
818
# Setup req_context to prevent LookupError
919
req_context.set(RequestContextVars(x_forwarded_for=None, user_agent=None, lang="en", solr_editions=True, print_disabled=False, is_bot=False))
@@ -237,5 +247,4 @@ def test_loan_history_still_fetched_on_own_profile():
237247
mb.username = "viewer"
238248
_run_render(mb, mock_history_data)
239249

240-
mock_history_data.assert_called_once()
241-
assert mock_history_data.call_args.args[0] == "viewer"
250+
mock_history_data.assert_called_once_with("viewer", page=1, s3_cookie="test-s3")

0 commit comments

Comments
 (0)