mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-13 13:03:01 +00:00
567 lines
18 KiB
Python
567 lines
18 KiB
Python
"""Tests for named servers"""
|
|
import asyncio
|
|
import json
|
|
import time
|
|
from unittest import mock
|
|
from urllib.parse import unquote, urlencode, urlparse
|
|
|
|
import pytest
|
|
from requests.exceptions import HTTPError
|
|
from tornado.httputil import url_concat
|
|
|
|
from .. import orm
|
|
from ..utils import url_escape_path, url_path_join
|
|
from .mocking import FormSpawner
|
|
from .test_api import TIMESTAMP, add_user, api_request, fill_user, normalize_user
|
|
from .utils import async_requests, get_page, public_url
|
|
|
|
|
|
@pytest.fixture
|
|
def named_servers(app):
|
|
with mock.patch.dict(
|
|
app.tornado_settings,
|
|
{'allow_named_servers': True, 'named_server_limit_per_user': 2},
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def named_servers_with_callable_limit(app):
|
|
def named_server_limit_per_user_fn(handler):
|
|
"""Limit number of named servers to `2` for non-admin users. No limit for admin users."""
|
|
user = handler.current_user
|
|
if user and user.admin:
|
|
return 0
|
|
return 2
|
|
|
|
with mock.patch.dict(
|
|
app.tornado_settings,
|
|
{
|
|
'allow_named_servers': True,
|
|
'named_server_limit_per_user': named_server_limit_per_user_fn,
|
|
},
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def default_server_name(app, named_servers):
|
|
"""configure app to use a default server name"""
|
|
server_name = 'myserver'
|
|
try:
|
|
app.default_server_name = server_name
|
|
yield server_name
|
|
finally:
|
|
app.default_server_name = ''
|
|
|
|
|
|
async def test_default_server(app, named_servers):
|
|
"""Test the default /users/:user/server handler when named servers are enabled"""
|
|
username = 'rosie'
|
|
user = add_user(app.db, app, name=username)
|
|
r = await api_request(app, 'users', username, 'server', method='post')
|
|
assert r.status_code == 201
|
|
assert r.text == ''
|
|
|
|
r = await api_request(app, 'users', username)
|
|
r.raise_for_status()
|
|
|
|
user_model = normalize_user(r.json())
|
|
assert user_model == fill_user(
|
|
{
|
|
'name': username,
|
|
'roles': ['user'],
|
|
'auth_state': None,
|
|
'server': user.url,
|
|
'servers': {
|
|
'': {
|
|
'name': '',
|
|
'started': TIMESTAMP,
|
|
'last_activity': TIMESTAMP,
|
|
'url': user.url,
|
|
'pending': None,
|
|
'ready': True,
|
|
'stopped': False,
|
|
'progress_url': 'PREFIX/hub/api/users/{}/server/progress'.format(
|
|
username
|
|
),
|
|
'state': {'pid': 0},
|
|
'user_options': {},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
# now stop the server
|
|
r = await api_request(app, 'users', username, 'server', method='delete')
|
|
assert r.status_code == 204
|
|
assert r.text == ''
|
|
|
|
r = await api_request(app, 'users', username)
|
|
r.raise_for_status()
|
|
|
|
user_model = normalize_user(r.json())
|
|
assert user_model == fill_user(
|
|
{'name': username, 'roles': ['user'], 'auth_state': None}
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'servername,escapedname,caller_escape',
|
|
[
|
|
('trevor', 'trevor', False),
|
|
('$p~c|a! ch@rs', '%24p~c%7Ca%21%20ch@rs', False),
|
|
('$p~c|a! ch@rs', '%24p~c%7Ca%21%20ch@rs', True),
|
|
('hash#?question', 'hash%23%3Fquestion', True),
|
|
],
|
|
)
|
|
async def test_create_named_server(
|
|
app, named_servers, servername, escapedname, caller_escape
|
|
):
|
|
username = 'walnut'
|
|
user = add_user(app.db, app, name=username)
|
|
# assert user.allow_named_servers == True
|
|
cookies = await app.login_user(username)
|
|
request_servername = servername
|
|
if caller_escape:
|
|
request_servername = url_escape_path(servername)
|
|
|
|
r = await api_request(
|
|
app, 'users', username, 'servers', request_servername, method='post'
|
|
)
|
|
r.raise_for_status()
|
|
assert r.status_code == 201
|
|
assert r.text == ''
|
|
|
|
url = url_path_join(public_url(app, user), request_servername, 'env')
|
|
expected_url = url_path_join(public_url(app, user), escapedname, 'env')
|
|
r = await async_requests.get(url, cookies=cookies)
|
|
r.raise_for_status()
|
|
# requests doesn't fully encode the servername: "$p~c%7Ca!%20ch@rs".
|
|
# Since this is the internal requests representation and not the JupyterHub
|
|
# representation it just needs to be equivalent.
|
|
assert unquote(r.url) == unquote(expected_url)
|
|
env = r.json()
|
|
prefix = env.get('JUPYTERHUB_SERVICE_PREFIX')
|
|
assert prefix == user.spawners[servername].server.base_url
|
|
assert prefix.endswith(f'/user/{username}/{escapedname}/')
|
|
|
|
r = await api_request(app, 'users', username)
|
|
r.raise_for_status()
|
|
|
|
# Ensure the unescaped name is stored in the DB
|
|
db_server_names = set(
|
|
app.db.query(orm.User).filter_by(name=username).first().orm_spawners.keys()
|
|
)
|
|
assert db_server_names == {"", servername}
|
|
|
|
user_model = normalize_user(r.json())
|
|
assert user_model == fill_user(
|
|
{
|
|
'name': username,
|
|
'roles': ['user'],
|
|
'auth_state': None,
|
|
'servers': {
|
|
servername: {
|
|
'name': name,
|
|
'started': TIMESTAMP,
|
|
'last_activity': TIMESTAMP,
|
|
'url': url_path_join(user.url, escapedname, '/'),
|
|
'pending': None,
|
|
'ready': True,
|
|
'stopped': False,
|
|
'progress_url': 'PREFIX/hub/api/users/{}/servers/{}/progress'.format(
|
|
username, escapedname
|
|
),
|
|
'state': {'pid': 0},
|
|
'user_options': {},
|
|
}
|
|
for name in [servername]
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
async def test_create_invalid_named_server(app, named_servers):
|
|
username = 'walnut'
|
|
user = add_user(app.db, app, name=username)
|
|
# assert user.allow_named_servers == True
|
|
cookies = await app.login_user(username)
|
|
server_name = "a$/b"
|
|
request_servername = 'a%24%2fb'
|
|
|
|
r = await api_request(
|
|
app, 'users', username, 'servers', request_servername, method='post'
|
|
)
|
|
|
|
with pytest.raises(HTTPError) as exc:
|
|
r.raise_for_status()
|
|
assert exc.value.response.json() == {
|
|
'status': 400,
|
|
'message': "Invalid server_name (may not contain '/'): a$/b",
|
|
}
|
|
|
|
|
|
async def test_delete_named_server(app, named_servers):
|
|
username = 'donaar'
|
|
user = add_user(app.db, app, name=username)
|
|
assert user.allow_named_servers
|
|
cookies = await app.login_user(username)
|
|
servername = 'splugoth'
|
|
r = await api_request(app, 'users', username, 'servers', servername, method='post')
|
|
r.raise_for_status()
|
|
assert r.status_code == 201
|
|
|
|
r = await api_request(
|
|
app, 'users', username, 'servers', servername, method='delete'
|
|
)
|
|
r.raise_for_status()
|
|
assert r.status_code == 204
|
|
|
|
r = await api_request(app, 'users', username)
|
|
r.raise_for_status()
|
|
|
|
user_model = normalize_user(r.json())
|
|
assert user_model == fill_user(
|
|
{'name': username, 'roles': ['user'], 'auth_state': None}
|
|
)
|
|
# wrapper Spawner is gone
|
|
assert servername not in user.spawners
|
|
# low-level record still exists
|
|
assert servername in user.orm_spawners
|
|
|
|
r = await api_request(
|
|
app,
|
|
'users',
|
|
username,
|
|
'servers',
|
|
servername,
|
|
method='delete',
|
|
data=json.dumps({'remove': True}),
|
|
)
|
|
r.raise_for_status()
|
|
assert r.status_code == 204
|
|
# low-level record is now removed
|
|
assert servername not in user.orm_spawners
|
|
# and it's still not in the high-level wrapper dict
|
|
assert servername not in user.spawners
|
|
|
|
|
|
async def test_named_server_disabled(app):
|
|
username = 'user'
|
|
servername = 'okay'
|
|
r = await api_request(app, 'users', username, 'servers', servername, method='post')
|
|
assert r.status_code == 400
|
|
r = await api_request(
|
|
app, 'users', username, 'servers', servername, method='delete'
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
async def test_named_server_limit(app, named_servers):
|
|
username = 'foo'
|
|
user = add_user(app.db, app, name=username)
|
|
cookies = await app.login_user(username)
|
|
|
|
# Create 1st named server
|
|
servername1 = 'bar-1'
|
|
r = await api_request(app, 'users', username, 'servers', servername1, method='post')
|
|
r.raise_for_status()
|
|
assert r.status_code == 201
|
|
assert r.text == ''
|
|
|
|
# Create 2nd named server
|
|
servername2 = 'bar-2'
|
|
r = await api_request(app, 'users', username, 'servers', servername2, method='post')
|
|
r.raise_for_status()
|
|
assert r.status_code == 201
|
|
assert r.text == ''
|
|
|
|
# Create 3rd named server
|
|
servername3 = 'bar-3'
|
|
r = await api_request(app, 'users', username, 'servers', servername3, method='post')
|
|
assert r.status_code == 400
|
|
assert r.json() == {
|
|
"status": 400,
|
|
"message": "User foo already has the maximum of 2 named servers. One must be deleted before a new server can be created",
|
|
}
|
|
|
|
# Create default server
|
|
r = await api_request(app, 'users', username, 'server', method='post')
|
|
assert r.status_code == 201
|
|
assert r.text == ''
|
|
|
|
# Delete 1st named server
|
|
r = await api_request(
|
|
app,
|
|
'users',
|
|
username,
|
|
'servers',
|
|
servername1,
|
|
method='delete',
|
|
data=json.dumps({'remove': True}),
|
|
)
|
|
r.raise_for_status()
|
|
assert r.status_code == 204
|
|
|
|
# Create 3rd named server again
|
|
r = await api_request(app, 'users', username, 'servers', servername3, method='post')
|
|
r.raise_for_status()
|
|
assert r.status_code == 201
|
|
assert r.text == ''
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'username,admin',
|
|
[
|
|
('nonsuperfoo', False),
|
|
('superfoo', True),
|
|
],
|
|
)
|
|
async def test_named_server_limit_as_callable(
|
|
app, named_servers_with_callable_limit, username, admin
|
|
):
|
|
"""Test named server limit based on `named_server_limit_per_user_fn` callable"""
|
|
user = add_user(app.db, app, name=username, admin=admin)
|
|
cookies = await app.login_user(username)
|
|
|
|
# Create 1st named server
|
|
servername1 = 'bar-1'
|
|
r = await api_request(
|
|
app, 'users', username, 'servers', servername1, method='post', cookies=cookies
|
|
)
|
|
r.raise_for_status()
|
|
assert r.status_code == 201
|
|
assert r.text == ''
|
|
|
|
# Create 2nd named server
|
|
servername2 = 'bar-2'
|
|
r = await api_request(
|
|
app, 'users', username, 'servers', servername2, method='post', cookies=cookies
|
|
)
|
|
r.raise_for_status()
|
|
assert r.status_code == 201
|
|
assert r.text == ''
|
|
|
|
# Create 3rd named server
|
|
servername3 = 'bar-3'
|
|
r = await api_request(
|
|
app, 'users', username, 'servers', servername3, method='post', cookies=cookies
|
|
)
|
|
|
|
# No named server limit for admin users as in `named_server_limit_per_user_fn` callable
|
|
if admin:
|
|
r.raise_for_status()
|
|
assert r.status_code == 201
|
|
assert r.text == ''
|
|
else:
|
|
assert r.status_code == 400
|
|
assert r.json() == {
|
|
"status": 400,
|
|
"message": f"User {username} already has the maximum of 2 named servers. One must be deleted before a new server can be created",
|
|
}
|
|
|
|
|
|
async def test_named_server_spawn_form(app, username, named_servers):
|
|
server_name = "myserver"
|
|
base_url = public_url(app)
|
|
cookies = await app.login_user(username)
|
|
user = app.users[username]
|
|
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
|
r = await get_page(f'spawn/{username}/{server_name}', app, cookies=cookies)
|
|
r.raise_for_status()
|
|
assert r.url.endswith(f'/spawn/{username}/{server_name}')
|
|
assert FormSpawner.options_form in r.text
|
|
|
|
# submit the form
|
|
next_url = url_path_join(
|
|
app.base_url, 'hub/spawn-pending', username, server_name
|
|
)
|
|
r = await async_requests.post(
|
|
url_concat(
|
|
url_path_join(base_url, 'hub/spawn', username, server_name),
|
|
{'next': next_url},
|
|
),
|
|
cookies=cookies,
|
|
data={'bounds': ['-10', '10'], 'energy': '938MeV'},
|
|
)
|
|
r.raise_for_status()
|
|
assert r.history
|
|
history = [_.url for _ in r.history] + [r.url]
|
|
path_history = [urlparse(url).path for url in history]
|
|
assert next_url in path_history
|
|
assert server_name in user.spawners
|
|
spawner = user.spawners[server_name]
|
|
spawner.user_options == {'energy': '938MeV', 'bounds': [-10, 10], 'notspecified': 5}
|
|
|
|
|
|
async def test_user_redirect_default_server_name(
|
|
app, username, named_servers, default_server_name
|
|
):
|
|
name = username
|
|
server_name = default_server_name
|
|
cookies = await app.login_user(name)
|
|
|
|
r = await api_request(app, 'users', username, 'servers', server_name, method='post')
|
|
r.raise_for_status()
|
|
assert r.status_code == 201
|
|
assert r.text == ''
|
|
|
|
r = await get_page('/user-redirect/tree/top/', app)
|
|
r.raise_for_status()
|
|
print(urlparse(r.url))
|
|
path = urlparse(r.url).path
|
|
assert path == url_path_join(app.base_url, '/hub/login')
|
|
query = urlparse(r.url).query
|
|
assert query == urlencode(
|
|
{'next': url_path_join(app.hub.base_url, '/user-redirect/tree/top/')}
|
|
)
|
|
|
|
r = await get_page('/user-redirect/notebooks/test.ipynb', app, cookies=cookies)
|
|
r.raise_for_status()
|
|
print(urlparse(r.url))
|
|
path = urlparse(r.url).path
|
|
while '/spawn-pending/' in path:
|
|
await asyncio.sleep(0.1)
|
|
r = await async_requests.get(r.url, cookies=cookies)
|
|
path = urlparse(r.url).path
|
|
assert path == url_path_join(
|
|
app.base_url, f'/user/{name}/{server_name}/notebooks/test.ipynb'
|
|
)
|
|
|
|
|
|
async def test_user_redirect_hook_default_server_name(
|
|
app, username, named_servers, default_server_name
|
|
):
|
|
"""
|
|
Test proper behavior of user_redirect_hook when c.JupyterHub.default_server_name is set
|
|
"""
|
|
name = username
|
|
server_name = default_server_name
|
|
cookies = await app.login_user(name)
|
|
|
|
r = await api_request(app, 'users', username, 'servers', server_name, method='post')
|
|
r.raise_for_status()
|
|
assert r.status_code == 201
|
|
assert r.text == ''
|
|
|
|
async def dummy_redirect(path, request, user, base_url):
|
|
assert base_url == app.base_url
|
|
assert path == 'redirect-to-terminal'
|
|
assert request.uri == url_path_join(
|
|
base_url, 'hub', 'user-redirect', 'redirect-to-terminal'
|
|
)
|
|
# exclude custom server_name
|
|
# custom hook is respected exactly
|
|
url = url_path_join(user.url, '/terminals/1')
|
|
return url
|
|
|
|
app.user_redirect_hook = dummy_redirect
|
|
|
|
r = await get_page('/user-redirect/redirect-to-terminal', app)
|
|
r.raise_for_status()
|
|
print(urlparse(r.url))
|
|
path = urlparse(r.url).path
|
|
assert path == url_path_join(app.base_url, '/hub/login')
|
|
query = urlparse(r.url).query
|
|
assert query == urlencode(
|
|
{'next': url_path_join(app.hub.base_url, '/user-redirect/redirect-to-terminal')}
|
|
)
|
|
|
|
# We don't actually want to start the server by going through spawn - just want to make sure
|
|
# the redirect is to the right place
|
|
r = await get_page(
|
|
'/user-redirect/redirect-to-terminal',
|
|
app,
|
|
cookies=cookies,
|
|
allow_redirects=False,
|
|
)
|
|
r.raise_for_status()
|
|
redirected_url = urlparse(r.headers['Location'])
|
|
assert redirected_url.path == url_path_join(
|
|
app.base_url, 'user', username, 'terminals/1'
|
|
)
|
|
|
|
|
|
async def test_named_server_stop_server(app, username, named_servers):
|
|
server_name = "myserver"
|
|
await app.login_user(username)
|
|
user = app.users[username]
|
|
|
|
r = await api_request(app, 'users', username, 'server', method='post')
|
|
assert r.status_code == 201
|
|
assert r.text == ''
|
|
assert user.spawners[''].server
|
|
|
|
with mock.patch.object(
|
|
app.proxy, 'add_user', side_effect=Exception('mock exception')
|
|
):
|
|
r = await api_request(
|
|
app, 'users', username, 'servers', server_name, method='post'
|
|
)
|
|
r.raise_for_status()
|
|
assert r.status_code == 201
|
|
assert r.text == ''
|
|
|
|
assert user.spawners[server_name].server is None
|
|
assert user.spawners[''].server
|
|
assert user.running
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"include_stopped_servers",
|
|
[True, False],
|
|
)
|
|
async def test_stopped_servers(app, user, named_servers, include_stopped_servers):
|
|
r = await api_request(app, 'users', user.name, 'server', method='post')
|
|
r.raise_for_status()
|
|
r = await api_request(app, 'users', user.name, 'servers', "named", method='post')
|
|
r.raise_for_status()
|
|
|
|
# wait for starts
|
|
for i in range(60):
|
|
r = await api_request(app, 'users', user.name)
|
|
r.raise_for_status()
|
|
user_model = r.json()
|
|
if not all(s["ready"] for s in user_model["servers"].values()):
|
|
time.sleep(1)
|
|
else:
|
|
break
|
|
else:
|
|
raise TimeoutError(f"User never stopped: {user_model}")
|
|
|
|
r = await api_request(app, 'users', user.name, 'server', method='delete')
|
|
r.raise_for_status()
|
|
r = await api_request(app, 'users', user.name, 'servers', "named", method='delete')
|
|
r.raise_for_status()
|
|
|
|
# wait for stops
|
|
for i in range(60):
|
|
r = await api_request(app, 'users', user.name)
|
|
r.raise_for_status()
|
|
user_model = r.json()
|
|
if not all(s["stopped"] for s in user_model["servers"].values()):
|
|
time.sleep(1)
|
|
else:
|
|
break
|
|
else:
|
|
raise TimeoutError(f"User never stopped: {user_model}")
|
|
|
|
# we have two stopped servers
|
|
path = f"users/{user.name}"
|
|
if include_stopped_servers:
|
|
path = f"{path}?include_stopped_servers"
|
|
r = await api_request(app, path)
|
|
r.raise_for_status()
|
|
user_model = r.json()
|
|
servers = list(user_model["servers"].values())
|
|
if include_stopped_servers:
|
|
assert len(servers) == 2
|
|
assert all(s["last_activity"] for s in servers)
|
|
assert all(s["started"] is None for s in servers)
|
|
assert all(s["stopped"] for s in servers)
|
|
assert not any(s["ready"] for s in servers)
|
|
assert not any(s["pending"] for s in servers)
|
|
else:
|
|
assert user_model["servers"] == {}
|