mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-17 06:52:59 +00:00
487 lines
16 KiB
Python
487 lines
16 KiB
Python
"""Tests for jupyterhub.singleuser"""
|
|
|
|
import os
|
|
import sys
|
|
import warnings
|
|
from contextlib import nullcontext
|
|
from pathlib import Path
|
|
from pprint import pprint
|
|
from subprocess import CalledProcessError, check_output
|
|
from unittest import mock
|
|
from urllib.parse import urlencode, urlparse
|
|
|
|
import pytest
|
|
from bs4 import BeautifulSoup
|
|
|
|
import jupyterhub
|
|
|
|
from .. import orm
|
|
from ..utils import url_path_join
|
|
from .mocking import public_url
|
|
from .utils import AsyncSession, async_requests, get_page
|
|
|
|
IS_JUPYVERSE = os.environ.get("JUPYTERHUB_SINGLEUSER_APP") == "jupyverse"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _jupyverse(app):
|
|
if IS_JUPYVERSE:
|
|
app.config.Spawner.default_url = "/lab"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"access_scopes, server_name, expect_success",
|
|
[
|
|
(["access:servers!group=$group"], "", True),
|
|
(["access:servers!group=other-group"], "", False),
|
|
(["access:servers"], "", True),
|
|
(["access:servers"], "named", True),
|
|
(["access:servers!user=$user"], "", True),
|
|
(["access:servers!user=$user"], "named", True),
|
|
(["access:servers!server=$server"], "", True),
|
|
(["access:servers!server=$server"], "named-server", True),
|
|
(["access:servers!server=$user/other"], "", False),
|
|
(["access:servers!server=$user/other"], "some-name", False),
|
|
(["access:servers!user=$other"], "", False),
|
|
(["access:servers!user=$other"], "named", False),
|
|
(["access:services"], "", False),
|
|
(["self"], "named", False),
|
|
([], "", False),
|
|
],
|
|
)
|
|
async def test_singleuser_auth(
|
|
app,
|
|
create_user_with_scopes,
|
|
access_scopes,
|
|
server_name,
|
|
expect_success,
|
|
full_spawn,
|
|
):
|
|
# login, start the server
|
|
cookies = await app.login_user('nandy')
|
|
user = app.users['nandy']
|
|
|
|
group = orm.Group.find(app.db, name="visitors")
|
|
if group is None:
|
|
group = orm.Group(name="visitors")
|
|
app.db.add(group)
|
|
app.db.commit()
|
|
if group not in user.groups:
|
|
user.groups.append(group)
|
|
app.db.commit()
|
|
|
|
if server_name not in user.spawners or not user.spawners[server_name].active:
|
|
await user.spawn(server_name)
|
|
await app.proxy.add_user(user, server_name)
|
|
spawner = user.spawners[server_name]
|
|
url = url_path_join(public_url(app, user), server_name)
|
|
|
|
s = AsyncSession()
|
|
|
|
# no cookies, redirects to login page
|
|
r = await s.get(url)
|
|
r.raise_for_status()
|
|
assert '/hub/login' in r.url
|
|
|
|
# unauthenticated /api/ should 403, not redirect
|
|
api_url = url_path_join(url, "api/status")
|
|
r = await s.get(api_url, allow_redirects=False)
|
|
assert r.status_code == 403
|
|
|
|
# with cookies, login successful
|
|
r = await s.get(url, cookies=cookies)
|
|
r.raise_for_status()
|
|
assert (
|
|
urlparse(r.url)
|
|
.path.rstrip('/')
|
|
.endswith(
|
|
url_path_join(
|
|
f'/user/{user.name}', spawner.name, spawner.default_url or "/tree"
|
|
)
|
|
)
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
# logout
|
|
r = await s.get(url_path_join(url, 'logout'))
|
|
assert len(r.cookies) == 0
|
|
|
|
# accessing another user's server hits the oauth confirmation page
|
|
access_scopes = [s.replace("$user", user.name) for s in access_scopes]
|
|
access_scopes = [
|
|
s.replace("$server", f"{user.name}/{server_name}") for s in access_scopes
|
|
]
|
|
access_scopes = [s.replace("$group", f"{group.name}") for s in access_scopes]
|
|
other_user = create_user_with_scopes(*access_scopes, name="burgess")
|
|
|
|
cookies = await app.login_user('burgess')
|
|
s = AsyncSession()
|
|
s.cookies = cookies
|
|
r = await s.get(url)
|
|
assert urlparse(r.url).path.endswith('/oauth2/authorize')
|
|
if not expect_success:
|
|
# user isn't authorized, should raise 403
|
|
assert r.status_code == 403
|
|
return
|
|
r.raise_for_status()
|
|
# submit the oauth form to complete authorization
|
|
r = await s.post(r.url, data={'scopes': ['identify'], '_xsrf': s.cookies['_xsrf']})
|
|
final_url = urlparse(r.url).path.rstrip('/')
|
|
final_path = url_path_join(
|
|
'/user/', user.name, spawner.name, spawner.default_url or "/tree"
|
|
)
|
|
assert final_url.endswith(final_path)
|
|
r.raise_for_status()
|
|
|
|
# revoke user access, should result in 403
|
|
other_user.roles = []
|
|
app.db.commit()
|
|
|
|
# reset session id to avoid cached response
|
|
s.cookies.pop('jupyterhub-session-id')
|
|
|
|
r = await s.get(r.url, allow_redirects=False)
|
|
assert r.status_code == 403
|
|
assert 'burgess' in r.text
|
|
await user.stop(server_name)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
IS_JUPYVERSE, reason="jupyverse doesn't look up directories for configuration files"
|
|
)
|
|
async def test_disable_user_config(request, app, tmp_path, full_spawn):
|
|
# login, start the server
|
|
cookies = await app.login_user('nandy')
|
|
s = AsyncSession()
|
|
s.cookies = cookies
|
|
user = app.users['nandy']
|
|
# stop spawner, if running:
|
|
if user.running:
|
|
print("stopping")
|
|
await user.stop()
|
|
# start with new config:
|
|
user.spawner.debug = True
|
|
user.spawner.disable_user_config = True
|
|
user.spawner.default_url = "/jupyterhub-test-info"
|
|
|
|
# make sure it's resolved to start
|
|
tmp_path = tmp_path.resolve()
|
|
real_home_dir = tmp_path / "realhome"
|
|
real_home_dir.mkdir()
|
|
# make symlink to test resolution
|
|
home_dir = tmp_path / "home"
|
|
home_dir.symlink_to(real_home_dir)
|
|
# home_dir is defined on SimpleSpawner
|
|
user.spawner.home_dir = str(home_dir)
|
|
jupyter_config_dir = home_dir / ".jupyter"
|
|
jupyter_config_dir.mkdir()
|
|
# verify config paths
|
|
with (jupyter_config_dir / "jupyter_server_config.py").open("w") as f:
|
|
f.write("c.TestSingleUser.jupyter_config_py = True")
|
|
|
|
await user.spawn()
|
|
await app.proxy.add_user(user)
|
|
|
|
url = public_url(app, user)
|
|
|
|
# with cookies, login successful
|
|
r = await s.get(url)
|
|
r.raise_for_status()
|
|
assert r.url.endswith('/user/nandy/jupyterhub-test-info')
|
|
assert r.status_code == 200
|
|
|
|
info = r.json()
|
|
pprint(info)
|
|
assert info['disable_user_config']
|
|
server_config = info['config']
|
|
settings = info['settings']
|
|
assert 'TestSingleUser' not in server_config
|
|
|
|
# check against tmp_path, the parent of both our home directories
|
|
# (symlink and real)
|
|
def assert_not_in_home(path, name):
|
|
path = Path(path).resolve()
|
|
assert not (str(path) + os.path.sep).startswith(
|
|
str(tmp_path) + os.path.sep
|
|
), f"{name}: {path} is in home {tmp_path}"
|
|
|
|
for path in info['config_file_paths']:
|
|
assert_not_in_home(path, 'config_file_paths')
|
|
|
|
# check every path setting for lookup in $HOME
|
|
# is this too much?
|
|
for key, setting in settings.items():
|
|
if 'path' in key and isinstance(setting, list):
|
|
for path in setting:
|
|
assert_not_in_home(path, key)
|
|
|
|
|
|
@pytest.mark.parametrize("extension", [True, False])
|
|
@pytest.mark.parametrize("notebook_dir", ["", "~", "~/sub", "ABS"])
|
|
@pytest.mark.skipif(
|
|
IS_JUPYVERSE, reason="jupyverse has not notebook directory configuration"
|
|
)
|
|
async def test_notebook_dir(
|
|
request, app, tmpdir, user, full_spawn, extension, notebook_dir
|
|
):
|
|
if extension:
|
|
try:
|
|
import jupyter_server # noqa
|
|
except ImportError:
|
|
pytest.skip("needs jupyter-server 2")
|
|
else:
|
|
if jupyter_server.version_info < (2,):
|
|
pytest.skip("needs jupyter-server 2")
|
|
|
|
token = user.new_api_token(scopes=["access:servers!user"])
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
|
|
spawner = user.spawner
|
|
if extension:
|
|
user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "1"
|
|
else:
|
|
user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "0"
|
|
|
|
home_dir = tmpdir.join("home").mkdir()
|
|
sub_dir = home_dir.join("sub").mkdir()
|
|
with sub_dir.join("subfile.txt").open("w") as f:
|
|
f.write("txt\n")
|
|
abs_dir = tmpdir.join("abs").mkdir()
|
|
with abs_dir.join("absfile.txt").open("w") as f:
|
|
f.write("absfile\n")
|
|
|
|
if notebook_dir:
|
|
expected_root_dir = notebook_dir.replace("ABS", str(abs_dir)).replace(
|
|
"~", str(home_dir)
|
|
)
|
|
else:
|
|
expected_root_dir = str(home_dir)
|
|
|
|
spawner.notebook_dir = notebook_dir.replace("ABS", str(abs_dir))
|
|
|
|
# home_dir is defined on SimpleSpawner
|
|
user.spawner.home_dir = home = str(home_dir)
|
|
spawner.environment["HOME"] = home
|
|
await user.spawn()
|
|
await app.proxy.add_user(user)
|
|
url = public_url(app, user)
|
|
r = await async_requests.get(
|
|
url_path_join(public_url(app, user), 'jupyterhub-test-info'), headers=headers
|
|
)
|
|
r.raise_for_status()
|
|
info = r.json()
|
|
pprint(info)
|
|
|
|
assert info["root_dir"] == expected_root_dir
|
|
# secondary check: make sure it has the intended effect on root_dir
|
|
r = await async_requests.get(
|
|
url_path_join(public_url(app, user), 'api/contents/'), headers=headers
|
|
)
|
|
r.raise_for_status()
|
|
root_contents = sorted(item['name'] for item in r.json()['content'])
|
|
|
|
# check contents
|
|
if not notebook_dir or notebook_dir == "~":
|
|
# use any to avoid counting possible automatically created files in $HOME
|
|
assert 'sub' in root_contents
|
|
elif notebook_dir == "ABS":
|
|
assert 'absfile.txt' in root_contents
|
|
elif notebook_dir == "~/sub":
|
|
assert 'subfile.txt' in root_contents
|
|
else:
|
|
raise ValueError(f"No contents check for {notebook_dir=}")
|
|
|
|
|
|
@pytest.mark.parametrize("extension", [True, False])
|
|
@pytest.mark.skipif(IS_JUPYVERSE, reason="jupyverse has no auth configuration")
|
|
async def test_forbid_unauthenticated_access(
|
|
request, app, tmp_path, user, full_spawn, extension
|
|
):
|
|
try:
|
|
from jupyter_server.auth.decorator import allow_unauthenticated # noqa
|
|
except ImportError:
|
|
pytest.skip("needs jupyter-server 2.13")
|
|
|
|
from jupyter_server.utils import JupyterServerAuthWarning
|
|
|
|
# login, start the server
|
|
cookies = await app.login_user('nandy')
|
|
s = AsyncSession()
|
|
s.cookies = cookies
|
|
user = app.users['nandy']
|
|
# stop spawner, if running:
|
|
if user.running:
|
|
await user.stop()
|
|
# start with new config:
|
|
user.spawner.default_url = "/jupyterhub-test-info"
|
|
|
|
if extension:
|
|
user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "1"
|
|
else:
|
|
user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "0"
|
|
|
|
# make sure it's resolved to start
|
|
tmp_path = tmp_path.resolve()
|
|
real_home_dir = tmp_path / "realhome"
|
|
real_home_dir.mkdir()
|
|
# make symlink to test resolution
|
|
home_dir = tmp_path / "home"
|
|
home_dir.symlink_to(real_home_dir)
|
|
# home_dir is defined on SimpleSpawner
|
|
user.spawner.home_dir = str(home_dir)
|
|
jupyter_config_dir = home_dir / ".jupyter"
|
|
jupyter_config_dir.mkdir()
|
|
# verify config paths
|
|
with (jupyter_config_dir / "jupyter_server_config.py").open("w") as f:
|
|
f.write("c.ServerApp.allow_unauthenticated_access = False")
|
|
|
|
# If there are core endpoints (added by jupyterhub) without decorators,
|
|
# spawn will error out. If there are extension endpoints without decorators
|
|
# these will be logged as warnings.
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("error", JupyterServerAuthWarning)
|
|
await user.spawn()
|
|
|
|
|
|
@pytest.mark.skipif(IS_JUPYVERSE, reason="jupyverse has no --help-all")
|
|
def test_help_output():
|
|
out = check_output(
|
|
[sys.executable, '-m', 'jupyterhub.singleuser', '--help-all']
|
|
).decode('utf8', 'replace')
|
|
assert 'JupyterHub' in out
|
|
|
|
|
|
@pytest.mark.skipif(IS_JUPYVERSE, reason="jupyverse has not --version")
|
|
def test_version():
|
|
out = check_output(
|
|
[sys.executable, '-m', 'jupyterhub.singleuser', '--version']
|
|
).decode('utf8', 'replace')
|
|
assert jupyterhub.__version__ in out
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"JUPYTERHUB_SINGLEUSER_APP",
|
|
[
|
|
"",
|
|
"notebook.notebookapp.NotebookApp",
|
|
"jupyter_server.serverapp.ServerApp",
|
|
],
|
|
)
|
|
def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
|
|
try:
|
|
import jupyter_server # noqa
|
|
except ImportError:
|
|
have_server = False
|
|
else:
|
|
have_server = True
|
|
try:
|
|
import notebook.notebookapp # noqa
|
|
except ImportError:
|
|
have_notebook = False
|
|
else:
|
|
have_notebook = True
|
|
|
|
if JUPYTERHUB_SINGLEUSER_APP.startswith("notebook."):
|
|
expect_error = (
|
|
os.environ.get("JUPYTERHUB_SINGLEUSER_EXTENSION") == "1"
|
|
or not have_notebook
|
|
)
|
|
elif JUPYTERHUB_SINGLEUSER_APP.startswith("jupyter_server."):
|
|
expect_error = not have_server
|
|
else:
|
|
# not specified, will try both
|
|
expect_error = not (have_server or have_notebook)
|
|
|
|
if expect_error:
|
|
ctx = pytest.raises(CalledProcessError)
|
|
else:
|
|
ctx = nullcontext()
|
|
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"JUPYTERHUB_SINGLEUSER_APP": JUPYTERHUB_SINGLEUSER_APP,
|
|
},
|
|
):
|
|
with ctx:
|
|
out = check_output(
|
|
[sys.executable, '-m', 'jupyterhub.singleuser', '--help-all']
|
|
).decode('utf8', 'replace')
|
|
if expect_error:
|
|
return
|
|
# use help-all output to check inheritance
|
|
if 'NotebookApp' in JUPYTERHUB_SINGLEUSER_APP or not have_server:
|
|
assert '--NotebookApp.' in out
|
|
assert '--ServerApp.' not in out
|
|
else:
|
|
assert '--ServerApp.' in out
|
|
assert '--NotebookApp.' not in out
|
|
|
|
|
|
@pytest.mark.skipif(IS_JUPYVERSE, reason="nbclassic specific test")
|
|
async def test_nbclassic_control_panel(app, user, full_spawn):
|
|
# login, start the server
|
|
await user.spawn()
|
|
await app.proxy.add_user(user)
|
|
cookies = await app.login_user(user.name)
|
|
next_url = url_path_join(user.url, "tree/")
|
|
url = '/?' + urlencode({'next': next_url})
|
|
r = await get_page(url, app, cookies=cookies)
|
|
r.raise_for_status()
|
|
assert urlparse(r.url).path == urlparse(next_url).path
|
|
page = BeautifulSoup(r.text, "html.parser")
|
|
link = page.find("a", id="jupyterhub-control-panel-link")
|
|
assert link, f"Missing jupyterhub-control-panel-link in {page}"
|
|
if app.subdomain_host:
|
|
prefix = public_url(app)
|
|
else:
|
|
prefix = app.base_url
|
|
assert link["href"] == url_path_join(prefix, "hub/home")
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
IS_JUPYVERSE, reason="jupyverse doesn't implement token authentication"
|
|
)
|
|
@pytest.mark.parametrize("accept_token_in_url", ["1", "0", ""])
|
|
async def test_token_url_cookie(app, user, full_spawn, accept_token_in_url):
|
|
if accept_token_in_url:
|
|
user.spawner.environment["JUPYTERHUB_ALLOW_TOKEN_IN_URL"] = accept_token_in_url
|
|
should_accept = accept_token_in_url == "1"
|
|
|
|
await user.spawn()
|
|
await app.proxy.add_user(user)
|
|
|
|
token = user.new_api_token(scopes=["access:servers!user"])
|
|
url = url_path_join(public_url(app, user), user.spawner.default_url or "/tree/")
|
|
|
|
# first request: auth with token in URL
|
|
s = AsyncSession()
|
|
r = await s.get(url + f"?token={token}", allow_redirects=False)
|
|
print(r.url, r.status_code)
|
|
if not should_accept:
|
|
assert r.status_code == 302
|
|
return
|
|
|
|
assert r.status_code == 200
|
|
assert s.cookies
|
|
# second request, use cookies set by first response,
|
|
# no token in URL
|
|
r = await s.get(url, allow_redirects=False)
|
|
assert r.status_code == 200
|
|
|
|
await user.stop()
|
|
|
|
|
|
async def test_api_403_no_cookie(app, user, full_spawn):
|
|
"""unused oauth cookies don't get set for failed requests to API handlers"""
|
|
await user.spawn()
|
|
await app.proxy.add_user(user)
|
|
url = url_path_join(public_url(app, user), "/api/contents/")
|
|
s = AsyncSession()
|
|
r = await s.get(url, allow_redirects=False)
|
|
# 403, not redirect
|
|
assert r.status_code == 403
|
|
# no state cookie set
|
|
assert not r.cookies
|
|
await user.stop()
|