Skip to content

Commit e00ae8d

Browse files
committed
Split the update_user method to 3 methods (check, update, persist)
1 parent c2d83db commit e00ae8d

File tree

3 files changed

+104
-15
lines changed

3 files changed

+104
-15
lines changed

jupyter_server/auth/identity.py

+31-8
Original file line numberDiff line numberDiff line change
@@ -296,22 +296,30 @@ async def _get_user(self, handler: web.RequestHandler) -> User | None:
296296
def update_user(
297297
self, handler: web.RequestHandler, user_data: dict[UpdatableField, str]
298298
) -> User:
299-
"""Update user information."""
299+
"""Update user information and persist the user model."""
300+
self.check_update(user_data)
300301
current_user = t.cast(User, handler.current_user)
302+
updated_user = self.update_user_model(current_user, user_data)
303+
self.persist_user_model(handler)
304+
return updated_user
301305

306+
def check_update(self, user_data: dict[UpdatableField, str]) -> None:
307+
"""Raises if some fields to update are not updatable."""
302308
for field in user_data:
303309
if field not in self.updatable_fields:
304310
msg = f"Field {field} is not updatable"
305311
raise ValueError(msg)
306312

307-
# Update fields
308-
for field in self.updatable_fields:
309-
if field in user_data:
310-
setattr(current_user, field, user_data[field])
313+
def update_user_model(self, current_user: User, user_data: dict[UpdatableField, str]) -> User:
314+
"""Update user information."""
315+
raise NotImplementedError
311316

312-
# Persist changes (if applicable)
313-
self.set_login_cookie(handler, current_user) # Save updated user to cookie/session
314-
return current_user
317+
def persist_user_model(self, handler: web.RequestHandler) -> None:
318+
"""Persist the user model (i.e. a cookie).
319+
320+
This is a no-op for IdentityProvider.
321+
"""
322+
raise NotImplementedError
315323

316324
def identity_model(self, user: User) -> dict[str, t.Any]:
317325
"""Return a User as an Identity model"""
@@ -681,6 +689,21 @@ def auth_enabled(self) -> bool:
681689
"""Return whether any auth is enabled"""
682690
return bool(self.hashed_password or self.token)
683691

692+
def update_user_model(self, current_user: User, user_data: dict[UpdatableField, str]) -> User:
693+
"""Update user information."""
694+
for field in self.updatable_fields:
695+
if field in user_data:
696+
setattr(current_user, field, user_data[field])
697+
return current_user
698+
699+
def persist_user_model(self, handler: web.RequestHandler) -> None:
700+
"""Persist the user model to a cookie.
701+
702+
This is a no-op for PasswordIdentityProvider, but subclasses may
703+
want to implement this.
704+
"""
705+
self.set_login_cookie(handler, handler.current_user)
706+
684707
def passwd_check(self, password):
685708
"""Check password against our stored hashed password"""
686709
return passwd_check(self.hashed_password, password)

jupyter_server/services/api/handlers.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from jupyter_server._tz import isoformat, utcfromtimestamp
1313
from jupyter_server.auth.decorator import authorized
14-
from jupyter_server.auth.identity import IdentityProvider, UpdatableField
14+
from jupyter_server.auth.identity import IdentityProvider, UpdatableField, User
1515

1616
from ...base.handlers import APIHandler, JupyterHandler
1717

@@ -134,6 +134,8 @@ async def patch(self):
134134
)
135135
except ValueError as e:
136136
raise web.HTTPError(400, str(e)) from e
137+
except NotImplementedError as e:
138+
raise web.HTTPError(501, str(e)) from e
137139

138140

139141
default_handlers = [

tests/services/api/test_api.py

+70-6
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from tornado.httpclient import HTTPError
77

88
from jupyter_server.auth import Authorizer, IdentityProvider, User
9+
from jupyter_server.auth.identity import PasswordIdentityProvider
910

1011

1112
async def test_get_spec(jp_fetch):
@@ -50,6 +51,22 @@ async def get_user(self, handler):
5051
return self.mock_user
5152

5253

54+
class MockPasswordIdentityProvider(PasswordIdentityProvider):
55+
mock_user: MockUser
56+
57+
async def get_user(self, handler):
58+
# super returns a UUID
59+
# return our mock user instead, as long as the request is authorized
60+
_authenticated = super().get_user(handler)
61+
if isinstance(_authenticated, Awaitable):
62+
_authenticated = await _authenticated
63+
authenticated = _authenticated
64+
if isinstance(self.mock_user, dict):
65+
self.mock_user = MockUser(**self.mock_user)
66+
if authenticated:
67+
return self.mock_user
68+
69+
5370
class MockAuthorizer(Authorizer):
5471
def is_authorized(self, handler, user, action, resource):
5572
permissions = user.permissions
@@ -70,6 +87,17 @@ def identity_provider(jp_serverapp):
7087
yield idp
7188

7289

90+
@pytest.fixture
91+
def password_identity_provider(jp_serverapp):
92+
idp = MockPasswordIdentityProvider(parent=jp_serverapp)
93+
authorizer = MockAuthorizer(parent=jp_serverapp)
94+
with mock.patch.dict(
95+
jp_serverapp.web_app.settings,
96+
{"identity_provider": idp, "authorizer": authorizer},
97+
):
98+
yield idp
99+
100+
73101
@pytest.mark.parametrize(
74102
"identity, expected",
75103
[
@@ -118,9 +146,44 @@ async def test_identity(jp_fetch, identity, expected, identity_provider):
118146

119147

120148
@pytest.mark.parametrize("identity", [{"username": "user.username"}])
121-
async def test_update_user_success(jp_fetch, identity, identity_provider):
149+
async def test_update_user_not_implemented_update(jp_fetch, identity, identity_provider):
150+
"""Test successful user update."""
151+
identity_provider.mock_user = MockUser(**identity)
152+
payload = {
153+
"color": "#000000",
154+
}
155+
with pytest.raises(HTTPError) as exc:
156+
await jp_fetch(
157+
"/api/me",
158+
method="PATCH",
159+
body=json.dumps(payload),
160+
headers={"Content-Type": "application/json"},
161+
)
162+
assert exc.value.code == 501
163+
164+
165+
@pytest.mark.parametrize("identity", [{"username": "user.username"}])
166+
async def test_update_user_not_implemented_persist(jp_fetch, identity, identity_provider):
122167
"""Test successful user update."""
123168
identity_provider.mock_user = MockUser(**identity)
169+
identity_provider.update_user_model = lambda *args, **kwargs: identity_provider.mock_user
170+
payload = {
171+
"color": "#000000",
172+
}
173+
with pytest.raises(HTTPError) as exc:
174+
await jp_fetch(
175+
"/api/me",
176+
method="PATCH",
177+
body=json.dumps(payload),
178+
headers={"Content-Type": "application/json"},
179+
)
180+
assert exc.value.code == 501
181+
182+
183+
@pytest.mark.parametrize("identity", [{"username": "user.username"}])
184+
async def test_update_user_success(jp_fetch, identity, password_identity_provider):
185+
"""Test successful user update."""
186+
password_identity_provider.mock_user = MockUser(**identity)
124187
payload = {
125188
"color": "#000000",
126189
}
@@ -137,12 +200,12 @@ async def test_update_user_success(jp_fetch, identity, identity_provider):
137200

138201

139202
@pytest.mark.parametrize("identity", [{"username": "user.username"}])
140-
async def test_update_user_raise(jp_fetch, identity, identity_provider):
203+
async def test_update_user_raise(jp_fetch, identity, password_identity_provider):
141204
"""Test failing user update."""
142-
identity_provider.mock_user = MockUser(**identity)
205+
password_identity_provider.mock_user = MockUser(**identity)
143206
payload = {
144207
"name": "Updated Name",
145-
"color": "#000000",
208+
"fake_prop": "anything",
146209
}
147210
with pytest.raises(HTTPError) as exc:
148211
await jp_fetch(
@@ -151,6 +214,7 @@ async def test_update_user_raise(jp_fetch, identity, identity_provider):
151214
body=json.dumps(payload),
152215
headers={"Content-Type": "application/json"},
153216
)
217+
assert exc.value.code == 400
154218

155219

156220
@pytest.mark.parametrize(
@@ -168,10 +232,10 @@ async def test_update_user_raise(jp_fetch, identity, identity_provider):
168232
],
169233
)
170234
async def test_update_user_success_custom_updatable_fields(
171-
jp_fetch, identity, expected, identity_provider
235+
jp_fetch, identity, expected, password_identity_provider
172236
):
173237
"""Test successful user update."""
174-
identity_provider.mock_user = MockUser(**identity)
238+
password_identity_provider.mock_user = MockUser(**identity)
175239
identity_provider.updatable_fields = ["name", "display_name", "color"]
176240
payload = {
177241
"name": expected["name"],

0 commit comments

Comments
 (0)