mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 18:14:10 +00:00
Compare commits
259 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a3c93088a8 | ||
![]() |
834229622d | ||
![]() |
44a1ea42de | ||
![]() |
3879a96b67 | ||
![]() |
d40627d397 | ||
![]() |
057cdbc9e9 | ||
![]() |
75390d2e46 | ||
![]() |
f5e4846cfa | ||
![]() |
3dc115a829 | ||
![]() |
af4ddbfc58 | ||
![]() |
50a4d1e34d | ||
![]() |
86a238334c | ||
![]() |
dacb9d1668 | ||
![]() |
95cc170383 | ||
![]() |
437a9d150f | ||
![]() |
c9616d6f11 | ||
![]() |
61aed70c4d | ||
![]() |
9abb573d47 | ||
![]() |
b074304834 | ||
![]() |
201e7ca3d8 | ||
![]() |
fa8cd90793 | ||
![]() |
7dafae29fb | ||
![]() |
89a6c745b5 | ||
![]() |
821d9e229d | ||
![]() |
db7619fa7a | ||
![]() |
1ed9423530 | ||
![]() |
147a578f7a | ||
![]() |
3a59a15164 | ||
![]() |
1b7aded7f9 | ||
![]() |
bc45d77365 | ||
![]() |
1b3b005ca4 | ||
![]() |
e0be811b2c | ||
![]() |
3627251246 | ||
![]() |
8d056170d7 | ||
![]() |
3590d16e30 | ||
![]() |
572d258cd2 | ||
![]() |
11d0954551 | ||
![]() |
650d47d5c1 | ||
![]() |
945fc824d8 | ||
![]() |
a8aa737b00 | ||
![]() |
cd689a1fab | ||
![]() |
b3f04e7c66 | ||
![]() |
fbcf857991 | ||
![]() |
6c5e5452bc | ||
![]() |
2f5ba7ba30 | ||
![]() |
a045eefa64 | ||
![]() |
6ea4f2af0d | ||
![]() |
3d3ad2929c | ||
![]() |
00287ff5ba | ||
![]() |
805d063d1d | ||
![]() |
e6bacf7109 | ||
![]() |
33ccfa7963 | ||
![]() |
593404f558 | ||
![]() |
e7bc282c80 | ||
![]() |
b939b482a1 | ||
![]() |
8afc2c9ae9 | ||
![]() |
d11eda14ed | ||
![]() |
ab79251fe2 | ||
![]() |
484dbf48de | ||
![]() |
6eb526d08a | ||
![]() |
e0a17db5f1 | ||
![]() |
45132b7244 | ||
![]() |
c23cddeb51 | ||
![]() |
672e19a22a | ||
![]() |
4a6c9c3a01 | ||
![]() |
2b79bc44da | ||
![]() |
7861662e17 | ||
![]() |
4a1842bf8a | ||
![]() |
8f18303e50 | ||
![]() |
bcad6e287d | ||
![]() |
9de1951952 | ||
![]() |
99cb1f17f0 | ||
![]() |
10d5157e95 | ||
![]() |
2fc4f26832 | ||
![]() |
f6230001bb | ||
![]() |
960f7cbeb9 | ||
![]() |
76f06a6b55 | ||
![]() |
9c498aa5d4 | ||
![]() |
a0b60f9118 | ||
![]() |
27cb56429b | ||
![]() |
b1ffd4b10b | ||
![]() |
a9ea064202 | ||
![]() |
687a41a467 | ||
![]() |
5348451b2e | ||
![]() |
55f0579dcc | ||
![]() |
a3ea0f0449 | ||
![]() |
78492a4a8e | ||
![]() |
f22203f50e | ||
![]() |
500b354a00 | ||
![]() |
9d4093782f | ||
![]() |
43b3cebfff | ||
![]() |
63c381431d | ||
![]() |
bf41767b33 | ||
![]() |
83d6e4e993 | ||
![]() |
d64a2ddd95 | ||
![]() |
392176d873 | ||
![]() |
58420b3307 | ||
![]() |
a5e3b66dee | ||
![]() |
a9fbe5c9f6 | ||
![]() |
71bbbe4a67 | ||
![]() |
3843885382 | ||
![]() |
25ea559e0d | ||
![]() |
c18815de91 | ||
![]() |
50d53667ce | ||
![]() |
68e2baf4aa | ||
![]() |
6fc9d40e51 | ||
![]() |
0b25694b40 | ||
![]() |
bf750e488f | ||
![]() |
359f9055fc | ||
![]() |
b84dd5d735 | ||
![]() |
3ed345f496 | ||
![]() |
6633f8ef28 | ||
![]() |
757053a9ec | ||
![]() |
36cad38ddf | ||
![]() |
1e9a1cb621 | ||
![]() |
9f051d3172 | ||
![]() |
53576c8f82 | ||
![]() |
bb5ec39b2f | ||
![]() |
4c54c6dcc8 | ||
![]() |
39da98f133 | ||
![]() |
29e69aa880 | ||
![]() |
0c315f31b7 | ||
![]() |
508842a68c | ||
![]() |
4b31615a05 | ||
![]() |
17b64280e8 | ||
![]() |
88be7a9967 | ||
![]() |
4ca2344af7 | ||
![]() |
4c050cf165 | ||
![]() |
5e2ccb81fa | ||
![]() |
b8dc3befab | ||
![]() |
2f29848757 | ||
![]() |
4f3d6cdd0c | ||
![]() |
67733ef928 | ||
![]() |
e657754e7f | ||
![]() |
2d6087959c | ||
![]() |
08a913707f | ||
![]() |
9c8a4f287a | ||
![]() |
64d6f0222c | ||
![]() |
538abdf084 | ||
![]() |
144abcb965 | ||
![]() |
6e5c307edb | ||
![]() |
67ebe0b0cf | ||
![]() |
dcf21d53fd | ||
![]() |
f5bb0a2622 | ||
![]() |
704712cc81 | ||
![]() |
f86d53a234 | ||
![]() |
5466224988 | ||
![]() |
f9fa21bfd7 | ||
![]() |
e4855c30f5 | ||
![]() |
f1c4fdd5a2 | ||
![]() |
e58cf06706 | ||
![]() |
91f4918cff | ||
![]() |
b15ccfa4ae | ||
![]() |
5102fde2f0 | ||
![]() |
f5dc005a70 | ||
![]() |
5fd8f0f596 | ||
![]() |
26ceafa8a3 | ||
![]() |
2e2ed8a4ff | ||
![]() |
6cc734f884 | ||
![]() |
4f7f07d3b7 | ||
![]() |
d436c97e3d | ||
![]() |
807c5b8ff9 | ||
![]() |
8da06d1259 | ||
![]() |
1c1be8a24b | ||
![]() |
897606b00c | ||
![]() |
615af5eb33 | ||
![]() |
85f94c12fc | ||
![]() |
ccfee4d235 | ||
![]() |
a2ba55756d | ||
![]() |
1b3e94db6c | ||
![]() |
614d9d89d0 | ||
![]() |
05a3f5aa9a | ||
![]() |
4f47153123 | ||
![]() |
a14d9ecaa1 | ||
![]() |
6815f30d36 | ||
![]() |
13172e6856 | ||
![]() |
ebc9fd7758 | ||
![]() |
0761a5db02 | ||
![]() |
46e7a231fe | ||
![]() |
ffa5a20e2f | ||
![]() |
2088a57ffe | ||
![]() |
345805781f | ||
![]() |
9eb52ea788 | ||
![]() |
fb1405ecd8 | ||
![]() |
3f01bf400b | ||
![]() |
c528751502 | ||
![]() |
0018184150 | ||
![]() |
7903f76e11 | ||
![]() |
d5551a2f32 | ||
![]() |
ca564a5948 | ||
![]() |
0fcc559323 | ||
![]() |
a746e8e7fb | ||
![]() |
b2ce6023e1 | ||
![]() |
39b331df1b | ||
![]() |
a69140ae1b | ||
![]() |
225ca9007a | ||
![]() |
11efebf1e2 | ||
![]() |
3e5082f265 | ||
![]() |
36cb1df27e | ||
![]() |
fcad2d5695 | ||
![]() |
2ec722d3af | ||
![]() |
390f50e246 | ||
![]() |
3276e4a58f | ||
![]() |
2a8428dbb0 | ||
![]() |
7febb3aa06 | ||
![]() |
92c6a23a13 | ||
![]() |
bb75081086 | ||
![]() |
915c244d02 | ||
![]() |
b5e0f46796 | ||
![]() |
34e8e2d828 | ||
![]() |
c2cbeda9e4 | ||
![]() |
92a33bd358 | ||
![]() |
e19700348d | ||
![]() |
04ac02c09d | ||
![]() |
2b61c16c06 | ||
![]() |
028722a5ac | ||
![]() |
ca7e07de54 | ||
![]() |
c523e74644 | ||
![]() |
dd932784ed | ||
![]() |
4704217dc5 | ||
![]() |
3893fb6d2c | ||
![]() |
59b2b36a27 | ||
![]() |
f6eaaebdf4 | ||
![]() |
bb20002aea | ||
![]() |
d1995ba7eb | ||
![]() |
b06f4cda33 | ||
![]() |
9d7a235107 | ||
![]() |
18459bad11 | ||
![]() |
ced941a6aa | ||
![]() |
85e37e7f8c | ||
![]() |
53067de596 | ||
![]() |
9c13861eb8 | ||
![]() |
b0ed9f5928 | ||
![]() |
ff0d15fa43 | ||
![]() |
81bb05d0ef | ||
![]() |
95649a3ece | ||
![]() |
08288f5b0f | ||
![]() |
01b1ce3995 | ||
![]() |
cbe93810be | ||
![]() |
75309d9dc4 | ||
![]() |
8594b3fa70 | ||
![]() |
1e956df4c7 | ||
![]() |
8ba2bcdfd4 | ||
![]() |
999cc0a37c | ||
![]() |
a6611e5999 | ||
![]() |
c0d5778d93 | ||
![]() |
293fe4e838 | ||
![]() |
dfee471e22 | ||
![]() |
db7cdc4aa7 | ||
![]() |
c048ad4aac | ||
![]() |
9e245379e8 | ||
![]() |
496f414a2e | ||
![]() |
df67a75893 | ||
![]() |
249b4af59f | ||
![]() |
db3b2d8961 | ||
![]() |
7d44a0ffc8 | ||
![]() |
202b2590e9 | ||
![]() |
c98ef547a8 | ||
![]() |
8a866a9102 |
55
.github/workflows/release.yml
vendored
55
.github/workflows/release.yml
vendored
@@ -1,15 +1,32 @@
|
||||
# Build releases and (on tags) publish to PyPI
|
||||
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
||||
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
|
||||
#
|
||||
# Test build release artifacts (PyPI package, Docker images) and publish them on
|
||||
# pushed git tags.
|
||||
#
|
||||
name: Release
|
||||
|
||||
# always build releases (to make sure wheel-building works)
|
||||
# but only publish to PyPI on tags
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "!dependabot/**"
|
||||
tags:
|
||||
- "*"
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "**.rst"
|
||||
- ".github/workflows/*"
|
||||
- "!.github/workflows/release.yml"
|
||||
push:
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "**.rst"
|
||||
- ".github/workflows/*"
|
||||
- "!.github/workflows/release.yml"
|
||||
branches-ignore:
|
||||
- "dependabot/**"
|
||||
- "pre-commit-ci-update-config"
|
||||
tags:
|
||||
- "**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-release:
|
||||
@@ -96,7 +113,6 @@ jobs:
|
||||
# Setup docker to build for multiple platforms, see:
|
||||
# https://github.com/docker/build-push-action/tree/v2.4.0#usage
|
||||
# https://github.com/docker/build-push-action/blob/v2.4.0/docs/advanced/multi-platform.md
|
||||
|
||||
- name: Set up QEMU (for docker buildx)
|
||||
uses: docker/setup-qemu-action@25f0500ff22e406f7191a2a8ba8cda16901ca018 # associated tag: v1.0.2
|
||||
|
||||
@@ -120,6 +136,8 @@ jobs:
|
||||
run: |
|
||||
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}"
|
||||
|
||||
# image: jupyterhub/jupyterhub
|
||||
#
|
||||
# https://github.com/jupyterhub/action-major-minor-tag-calculator
|
||||
# If this is a tagged build this will return additional parent tags.
|
||||
# E.g. 1.2.3 is expanded to Docker tags
|
||||
@@ -137,7 +155,7 @@ jobs:
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -146,8 +164,8 @@ jobs:
|
||||
# array into a comma separated list of tags
|
||||
tags: ${{ join(fromJson(steps.jupyterhubtags.outputs.tags)) }}
|
||||
|
||||
# jupyterhub-onbuild
|
||||
|
||||
# image: jupyterhub/jupyterhub-onbuild
|
||||
#
|
||||
- name: Get list of jupyterhub-onbuild tags
|
||||
id: onbuildtags
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||
@@ -158,7 +176,7 @@ jobs:
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub-onbuild
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
|
||||
with:
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
|
||||
@@ -167,8 +185,8 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ join(fromJson(steps.onbuildtags.outputs.tags)) }}
|
||||
|
||||
# jupyterhub-demo
|
||||
|
||||
# image: jupyterhub/jupyterhub-demo
|
||||
#
|
||||
- name: Get list of jupyterhub-demo tags
|
||||
id: demotags
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||
@@ -179,7 +197,7 @@ jobs:
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub-demo
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
|
||||
with:
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
|
||||
@@ -191,7 +209,8 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}
|
||||
|
||||
# jupyterhub/singleuser
|
||||
# image: jupyterhub/singleuser
|
||||
#
|
||||
- name: Get list of jupyterhub/singleuser tags
|
||||
id: singleusertags
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||
@@ -202,7 +221,7 @@ jobs:
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub/singleuser
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
|
||||
with:
|
||||
build-args: |
|
||||
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
|
||||
|
64
.github/workflows/test-docs.yml
vendored
Normal file
64
.github/workflows/test-docs.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
||||
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
|
||||
#
|
||||
# This workflow validates the REST API definition and runs the pytest tests in
|
||||
# the docs/ folder. This workflow does not build the documentation. That is
|
||||
# instead tested via ReadTheDocs (https://readthedocs.org/projects/jupyterhub/).
|
||||
#
|
||||
name: Test docs
|
||||
|
||||
# The tests defined in docs/ are currently influenced by changes to _version.py
|
||||
# and scopes.py.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "jupyterhub/_version.py"
|
||||
- "jupyterhub/scopes.py"
|
||||
- ".github/workflows/*"
|
||||
- "!.github/workflows/test-docs.yml"
|
||||
push:
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "jupyterhub/_version.py"
|
||||
- "jupyterhub/scopes.py"
|
||||
- ".github/workflows/*"
|
||||
- "!.github/workflows/test-docs.yml"
|
||||
branches-ignore:
|
||||
- "dependabot/**"
|
||||
- "pre-commit-ci-update-config"
|
||||
tags:
|
||||
- "**"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# UTF-8 content may be interpreted as ascii and causes errors without this.
|
||||
LANG: C.UTF-8
|
||||
PYTEST_ADDOPTS: "--verbose --color=yes"
|
||||
|
||||
jobs:
|
||||
validate-rest-api-definition:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Validate REST API definition
|
||||
uses: char0n/swagger-editor-validate@182d1a5d26ff5c2f4f452c43bd55e2c7d8064003
|
||||
with:
|
||||
definition-file: docs/source/_static/rest-api.yml
|
||||
|
||||
test-docs:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: "3.9"
|
||||
|
||||
- name: Install requirements
|
||||
run: |
|
||||
pip install -r docs/requirements.txt pytest -e .
|
||||
|
||||
- name: pytest docs/
|
||||
run: |
|
||||
pytest docs/
|
108
.github/workflows/test-jsx.yml
vendored
Normal file
108
.github/workflows/test-jsx.yml
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
||||
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
|
||||
#
|
||||
name: Test jsx (admin-react.js)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "jsx/**"
|
||||
- ".github/workflows/test-jsx.yml"
|
||||
push:
|
||||
paths:
|
||||
- "jsx/**"
|
||||
- ".github/workflows/test-jsx.yml"
|
||||
branches-ignore:
|
||||
- "dependabot/**"
|
||||
- "pre-commit-ci-update-config"
|
||||
tags:
|
||||
- "**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# The ./jsx folder contains React based source code files that are to compile
|
||||
# to share/jupyterhub/static/js/admin-react.js. The ./jsx folder includes
|
||||
# tests also has tests that this job is meant to run with `yarn test`
|
||||
# according to the documentation in jsx/README.md.
|
||||
test-jsx-admin-react:
|
||||
runs-on: ubuntu-20.04
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: "14"
|
||||
|
||||
- name: Install yarn
|
||||
run: |
|
||||
npm install -g yarn
|
||||
|
||||
- name: yarn
|
||||
run: |
|
||||
cd jsx
|
||||
yarn
|
||||
|
||||
- name: yarn test
|
||||
run: |
|
||||
cd jsx
|
||||
yarn test
|
||||
|
||||
# The ./jsx folder contains React based source files that are to compile to
|
||||
# share/jupyterhub/static/js/admin-react.js. This job makes sure that whatever
|
||||
# we have in jsx/src matches the compiled asset that we package and
|
||||
# distribute.
|
||||
#
|
||||
# This job's purpose is to make sure we don't forget to compile changes and to
|
||||
# verify nobody sneaks in a change in the hard to review compiled asset.
|
||||
#
|
||||
# NOTE: In the future we may want to stop version controlling the compiled
|
||||
# artifact and instead generate it whenever we package JupyterHub. If we
|
||||
# do this, we are required to setup node and compile the source code
|
||||
# more often, at the same time we could avoid having this check be made.
|
||||
#
|
||||
compile-jsx-admin-react:
|
||||
runs-on: ubuntu-20.04
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: "14"
|
||||
|
||||
- name: Install yarn
|
||||
run: |
|
||||
npm install -g yarn
|
||||
|
||||
- name: yarn
|
||||
run: |
|
||||
cd jsx
|
||||
yarn
|
||||
|
||||
- name: yarn build
|
||||
run: |
|
||||
cd jsx
|
||||
yarn build
|
||||
|
||||
- name: yarn place
|
||||
run: |
|
||||
cd jsx
|
||||
yarn place
|
||||
|
||||
- name: Verify compiled jsx/src matches version controlled artifact
|
||||
run: |
|
||||
if [[ `git status --porcelain=v1` ]]; then
|
||||
echo "The source code in ./jsx compiles to something different than found in ./share/jupyterhub/static/js/admin-react.js!"
|
||||
echo
|
||||
echo "Please re-compile the source code in ./jsx with the following commands:"
|
||||
echo
|
||||
echo "yarn"
|
||||
echo "yarn build"
|
||||
echo "yarn place"
|
||||
echo
|
||||
echo "See ./jsx/README.md for more details."
|
||||
exit 1
|
||||
else
|
||||
echo "Compilation of jsx/src to share/jupyterhub/static/js/admin-react.js didn't lead to changes."
|
||||
fi
|
51
.github/workflows/test.yml
vendored
51
.github/workflows/test.yml
vendored
@@ -1,14 +1,28 @@
|
||||
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
||||
# ref: https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions
|
||||
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
|
||||
#
|
||||
name: Test
|
||||
|
||||
# Trigger the workflow's on all PRs but only on pushed tags or commits to
|
||||
# main/master branch to avoid PRs developed in a GitHub fork's dedicated branch
|
||||
# to trigger.
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "**.rst"
|
||||
- ".github/workflows/*"
|
||||
- "!.github/workflows/test.yml"
|
||||
push:
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "**.rst"
|
||||
- ".github/workflows/*"
|
||||
- "!.github/workflows/test.yml"
|
||||
branches-ignore:
|
||||
- "dependabot/**"
|
||||
- "pre-commit-ci-update-config"
|
||||
tags:
|
||||
- "**"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -17,29 +31,10 @@ env:
|
||||
PYTEST_ADDOPTS: "--verbose --color=yes"
|
||||
|
||||
jobs:
|
||||
rest-api:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Validate REST API
|
||||
uses: char0n/swagger-editor-validate@182d1a5d26ff5c2f4f452c43bd55e2c7d8064003
|
||||
with:
|
||||
definition-file: docs/source/_static/rest-api.yml
|
||||
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: "3.9"
|
||||
# in addition to the doc requirements
|
||||
# the docs *tests* require pre-commit and pytest
|
||||
- run: |
|
||||
pip install -r docs/requirements.txt pytest pre-commit -e .
|
||||
- run: |
|
||||
pytest docs/
|
||||
|
||||
# Run "pytest jupyterhub/tests" in various configurations
|
||||
pytest:
|
||||
runs-on: ubuntu-20.04
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
|
||||
strategy:
|
||||
# Keep running even if one variation of the job fail
|
||||
@@ -126,7 +121,6 @@ jobs:
|
||||
run: |
|
||||
npm install
|
||||
npm install -g configurable-http-proxy
|
||||
npm install -g yarn
|
||||
npm list
|
||||
|
||||
# NOTE: actions/setup-python@v2 make use of a cache within the GitHub base
|
||||
@@ -189,14 +183,18 @@ jobs:
|
||||
if: ${{ matrix.db }}
|
||||
run: |
|
||||
if [ "${{ matrix.db }}" == "mysql" ]; then
|
||||
if [[ -z "$(which mysql)" ]]; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y mysql-client
|
||||
fi
|
||||
DB=mysql bash ci/docker-db.sh
|
||||
DB=mysql bash ci/init-db.sh
|
||||
fi
|
||||
if [ "${{ matrix.db }}" == "postgres" ]; then
|
||||
if [[ -z "$(which psql)" ]]; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y postgresql-client
|
||||
fi
|
||||
DB=postgres bash ci/docker-db.sh
|
||||
DB=postgres bash ci/init-db.sh
|
||||
fi
|
||||
@@ -204,9 +202,6 @@ jobs:
|
||||
- name: Run pytest
|
||||
run: |
|
||||
pytest --maxfail=2 --cov=jupyterhub jupyterhub/tests
|
||||
- name: Run yarn jest test
|
||||
run: |
|
||||
cd jsx && yarn && yarn test
|
||||
- name: Submit codecov report
|
||||
run: |
|
||||
codecov
|
||||
|
@@ -1,30 +1,52 @@
|
||||
# pre-commit is a tool to perform a predefined set of tasks manually and/or
|
||||
# automatically before git commits are made.
|
||||
#
|
||||
# Config reference: https://pre-commit.com/#pre-commit-configyaml---top-level
|
||||
#
|
||||
# Common tasks
|
||||
#
|
||||
# - Run on all files: pre-commit run --all-files
|
||||
# - Register git hooks: pre-commit install --install-hooks
|
||||
#
|
||||
repos:
|
||||
# Autoformat: Python code, syntax patterns are modernized
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.29.1
|
||||
rev: v2.32.1
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args:
|
||||
- --py36-plus
|
||||
|
||||
# Autoformat: Python code
|
||||
- repo: https://github.com/asottile/reorder_python_imports
|
||||
rev: v2.6.0
|
||||
rev: v3.1.0
|
||||
hooks:
|
||||
- id: reorder-python-imports
|
||||
|
||||
# Autoformat: Python code
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 21.11b1
|
||||
rev: 22.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
|
||||
# Autoformat: markdown, yaml, javascript (see the file .prettierignore)
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v2.4.1
|
||||
rev: v2.6.2
|
||||
hooks:
|
||||
- id: prettier
|
||||
|
||||
# Autoformat and linting, misc. details
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.2.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
exclude: share/jupyterhub/static/js/admin-react.js
|
||||
- id: requirements-txt-fixer
|
||||
- id: check-case-conflict
|
||||
- id: check-executables-have-shebangs
|
||||
|
||||
# Linting: Python code (see the file .flake8)
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: "4.0.1"
|
||||
hooks:
|
||||
- id: flake8
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.0.1
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: check-case-conflict
|
||||
- id: check-executables-have-shebangs
|
||||
- id: requirements-txt-fixer
|
||||
|
@@ -4,10 +4,12 @@ sphinx:
|
||||
configuration: docs/source/conf.py
|
||||
|
||||
build:
|
||||
image: latest
|
||||
os: ubuntu-20.04
|
||||
tools:
|
||||
nodejs: "16"
|
||||
python: "3.9"
|
||||
|
||||
python:
|
||||
version: 3.7
|
||||
install:
|
||||
- method: pip
|
||||
path: .
|
@@ -59,7 +59,7 @@ JupyterHub also provides a
|
||||
[REST API][]
|
||||
for administration of the Hub and its users.
|
||||
|
||||
[rest api]: https://juptyerhub.readthedocs.io/en/latest/reference/rest-api.html
|
||||
[rest api]: https://jupyterhub.readthedocs.io/en/latest/reference/rest-api.html
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -117,8 +117,7 @@ To start the Hub server, run the command:
|
||||
|
||||
jupyterhub
|
||||
|
||||
Visit `https://localhost:8000` in your browser, and sign in with your unix
|
||||
PAM credentials.
|
||||
Visit `http://localhost:8000` in your browser, and sign in with your system username and password.
|
||||
|
||||
_Note_: To allow multiple users to sign in to the server, you will need to
|
||||
run the `jupyterhub` command as a _privileged user_, such as root.
|
||||
|
@@ -11,7 +11,8 @@ jupyterlab >=3
|
||||
mock
|
||||
pre-commit
|
||||
pytest>=3.3
|
||||
pytest-asyncio
|
||||
pytest-asyncio; python_version < "3.7"
|
||||
pytest-asyncio>=0.17; python_version >= "3.7"
|
||||
pytest-cov
|
||||
requests-mock
|
||||
tbump
|
||||
|
@@ -3,8 +3,10 @@
|
||||
alabaster_jupyterhub
|
||||
autodoc-traits
|
||||
myst-parser
|
||||
pre-commit
|
||||
pydata-sphinx-theme
|
||||
pytablewriter>=0.56
|
||||
ruamel.yaml
|
||||
sphinx>=1.7
|
||||
sphinx-copybutton
|
||||
sphinx-jsonschema
|
||||
|
@@ -6,7 +6,7 @@ info:
|
||||
description: The REST API for JupyterHub
|
||||
license:
|
||||
name: BSD-3-Clause
|
||||
version: 2.0.0rc5
|
||||
version: 2.3.0
|
||||
servers:
|
||||
- url: /hub/api
|
||||
security:
|
||||
@@ -1419,3 +1419,4 @@ components:
|
||||
Read information about the proxy’s routing table, sync the Hub
|
||||
with the proxy and notify the Hub about a new proxy.
|
||||
shutdown: Shutdown the hub.
|
||||
read:metrics: Read prometheus metrics.
|
||||
|
72
docs/source/admin/log-messages.md
Normal file
72
docs/source/admin/log-messages.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Interpreting common log messages
|
||||
|
||||
When debugging errors and outages, looking at the logs emitted by
|
||||
JupyterHub is very helpful. This document tries to document some common
|
||||
log messages, and what they mean.
|
||||
|
||||
## Failing suspected API request to not-running server
|
||||
|
||||
### Example
|
||||
|
||||
Your logs might be littered with lines that might look slightly scary
|
||||
|
||||
```
|
||||
[W 2022-03-10 17:25:19.774 JupyterHub base:1349] Failing suspected API request to not-running server: /hub/user/<user-name>/api/metrics/v1
|
||||
```
|
||||
|
||||
### Most likely cause
|
||||
|
||||
This likely means is that the user's server has stopped running but they
|
||||
still have a browser tab open. For example, you might have 3 tabs open, and shut
|
||||
your server down via one. Or you closed your laptop, your server was
|
||||
culled for inactivity, and then you reopen your laptop again! The
|
||||
client side code (JupyterLab, Classic Notebook, etc) does not know
|
||||
yet that the server is dead, and continues to make some API requests.
|
||||
JupyterHub's architecture means that the proxy routes all requests that
|
||||
don't go to a running user server to the hub process itself. The hub
|
||||
process then explicitly returns a failure response, so the client knows
|
||||
that the server is not running anymore. This is used by JupyterLab to
|
||||
tell you your server is not running anymore, and offer you the option
|
||||
to let you restart it.
|
||||
|
||||
Most commonly, you'll see this in reference to the `/api/metrics/v1`
|
||||
URL, used by [jupyter-resource-usage](https://github.com/jupyter-server/jupyter-resource-usage).
|
||||
|
||||
### Actions you can take
|
||||
|
||||
This log message is benign, and there is usually no action for you to take.
|
||||
|
||||
## JupyterHub Singleuser Version mismatch
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
jupyterhub version 1.5.0 != jupyterhub-singleuser version 1.3.0. This could cause failure to authenticate and result in redirect loops!
|
||||
```
|
||||
|
||||
### Cause
|
||||
|
||||
JupyterHub requires the `jupyterhub` python package installed inside the image or
|
||||
environment the user server starts in. This message indicates that the version of
|
||||
the `jupyterhub` package installed inside the user image or environment is not
|
||||
the same version as the JupyterHub server itself. This is not necessarily always a
|
||||
problem - some version drift is mostly acceptable, and the only two known cases of
|
||||
breakage are across the 0.7 and 2.0 version releases. In those cases, issues pop
|
||||
up immediately after upgrading your version of JupyterHub, so **always check the JupyterHub
|
||||
changelog before upgrading!**. The primary problems this _could_ cause are:
|
||||
|
||||
1. Infinite redirect loops after the user server starts
|
||||
2. Missing expected environment variables in the user server once it starts
|
||||
3. Failure for the started user server to authenticate with the JupyterHub server -
|
||||
note that this is _not_ the same as _user authentication_ failing!
|
||||
|
||||
However, for the most part, unless you are seeing these specific issues, the log
|
||||
message should be counted as a warning to get the `jupyterhub` package versions
|
||||
aligned, rather than as an indicator of an existing problem.
|
||||
|
||||
### Actions you can take
|
||||
|
||||
Upgrade the version of the `jupyterhub` package in your user environment or image
|
||||
so it matches the version of JupyterHub running your JupyterHub server! If you
|
||||
are using the [zero-to-jupyterhub](https://z2jh.jupyter.org) helm chart, you can find the appropriate
|
||||
version of the `jupyterhub` package to install in your user image [here](https://jupyterhub.github.io/helm-chart/)
|
@@ -1,5 +1,3 @@
|
||||
.. _admin/upgrading:
|
||||
|
||||
====================
|
||||
Upgrading JupyterHub
|
||||
====================
|
||||
|
File diff suppressed because one or more lines are too long
@@ -21,6 +21,7 @@ extensions = [
|
||||
'myst_parser',
|
||||
]
|
||||
|
||||
myst_heading_anchors = 2
|
||||
myst_enable_extensions = [
|
||||
'colon_fence',
|
||||
'deflist',
|
||||
@@ -130,6 +131,30 @@ html_static_path = ['_static']
|
||||
|
||||
htmlhelp_basename = 'JupyterHubdoc'
|
||||
|
||||
html_theme_options = {
|
||||
"icon_links": [
|
||||
{
|
||||
"name": "GitHub",
|
||||
"url": "https://github.com/jupyterhub/jupyterhub",
|
||||
"icon": "fab fa-github-square",
|
||||
},
|
||||
{
|
||||
"name": "Discourse",
|
||||
"url": "https://discourse.jupyter.org/c/jupyterhub/10",
|
||||
"icon": "fab fa-discourse",
|
||||
},
|
||||
],
|
||||
"use_edit_page_button": True,
|
||||
"navbar_align": "left",
|
||||
}
|
||||
|
||||
html_context = {
|
||||
"github_user": "jupyterhub",
|
||||
"github_repo": "jupyterhub",
|
||||
"github_version": "main",
|
||||
"doc_path": "docs",
|
||||
}
|
||||
|
||||
# -- Options for LaTeX output ---------------------------------------------
|
||||
|
||||
latex_elements = {
|
||||
@@ -205,7 +230,10 @@ epub_exclude_files = ['search.html']
|
||||
|
||||
# -- Intersphinx ----------------------------------------------------------
|
||||
|
||||
intersphinx_mapping = {'https://docs.python.org/3/': None}
|
||||
intersphinx_mapping = {
|
||||
'python': ('https://docs.python.org/3/', None),
|
||||
'tornado': ('https://www.tornadoweb.org/en/stable/', None),
|
||||
}
|
||||
|
||||
# -- Read The Docs --------------------------------------------------------
|
||||
|
||||
|
@@ -16,6 +16,10 @@ c.Authenticator.allowed_users = {'mal', 'zoe', 'inara', 'kaylee'}
|
||||
Users in the `allowed_users` set are added to the Hub database when the Hub is
|
||||
started.
|
||||
|
||||
```{warning}
|
||||
If this configuration value is not set, then **all authenticated users will be allowed into your hub**.
|
||||
```
|
||||
|
||||
## Configure admins (`admin_users`)
|
||||
|
||||
```{note}
|
||||
|
@@ -10,4 +10,5 @@ well as other information relevant to running your own JupyterHub over time.
|
||||
|
||||
troubleshooting
|
||||
admin/upgrading
|
||||
admin/log-messages
|
||||
changelog
|
||||
|
@@ -1,16 +1,33 @@
|
||||
"""
|
||||
This script updates two files with the RBAC scope descriptions found in
|
||||
`scopes.py`.
|
||||
|
||||
The files are:
|
||||
|
||||
1. scope-table.md
|
||||
|
||||
This file is git ignored and referenced by the documentation.
|
||||
|
||||
2. rest-api.yml
|
||||
|
||||
This file is JupyterHub's REST API schema. Both a version and the RBAC
|
||||
scopes descriptions are updated in it.
|
||||
"""
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from subprocess import run
|
||||
|
||||
from pytablewriter import MarkdownTableWriter
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
import jupyterhub
|
||||
from jupyterhub import __version__
|
||||
from jupyterhub.scopes import scope_definitions
|
||||
|
||||
HERE = os.path.abspath(os.path.dirname(__file__))
|
||||
DOCS = Path(HERE).parent.parent.absolute()
|
||||
REST_API_YAML = DOCS.joinpath("source", "_static", "rest-api.yml")
|
||||
SCOPE_TABLE_MD = Path(HERE).joinpath("scope-table.md")
|
||||
|
||||
|
||||
class ScopeTableGenerator:
|
||||
@@ -82,8 +99,9 @@ class ScopeTableGenerator:
|
||||
return table_rows
|
||||
|
||||
def write_table(self):
|
||||
"""Generates the scope table in markdown format and writes it into `scope-table.md`"""
|
||||
filename = f"{HERE}/scope-table.md"
|
||||
"""Generates the RBAC scopes reference documentation as a markdown table
|
||||
and writes it to the .gitignored `scope-table.md`."""
|
||||
filename = SCOPE_TABLE_MD
|
||||
table_name = ""
|
||||
headers = ["Scope", "Grants permission to:"]
|
||||
values = self._parse_scopes()
|
||||
@@ -99,15 +117,20 @@ class ScopeTableGenerator:
|
||||
)
|
||||
|
||||
def write_api(self):
|
||||
"""Generates the API description in markdown format and writes it into `rest-api.yml`"""
|
||||
"""Loads `rest-api.yml` and writes it back with a dynamically set
|
||||
JupyterHub version field and list of RBAC scopes descriptions from
|
||||
`scopes.py`."""
|
||||
filename = REST_API_YAML
|
||||
yaml = YAML(typ='rt')
|
||||
|
||||
yaml = YAML(typ="rt")
|
||||
yaml.preserve_quotes = True
|
||||
yaml.indent(mapping=2, offset=2, sequence=4)
|
||||
|
||||
scope_dict = {}
|
||||
with open(filename) as f:
|
||||
content = yaml.load(f.read())
|
||||
|
||||
content["info"]["version"] = jupyterhub.__version__
|
||||
content["info"]["version"] = __version__
|
||||
for scope in self.scopes:
|
||||
description = self.scopes[scope]['description']
|
||||
doc_description = self.scopes[scope].get('doc_description', '')
|
||||
@@ -121,6 +144,12 @@ class ScopeTableGenerator:
|
||||
with open(filename, 'w') as f:
|
||||
yaml.dump(content, f)
|
||||
|
||||
run(
|
||||
['pre-commit', 'run', 'prettier', '--files', filename],
|
||||
cwd=HERE,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
table_generator = ScopeTableGenerator()
|
||||
|
@@ -7,7 +7,7 @@ JupyterHub provides four roles that are available by default:
|
||||
```{admonition} **Default roles**
|
||||
- `user` role provides a {ref}`default user scope <default-user-scope-target>` `self` that grants access to the user's own resources.
|
||||
- `admin` role contains all available scopes and grants full rights to all actions. This role **cannot be edited**.
|
||||
- `token` role provides a {ref}`default token scope <default-token-scope-target>` `all` that resolves to the same permissions as the owner of the token has.
|
||||
- `token` role provides a {ref}`default token scope <default-token-scope-target>` `inherit` that resolves to the same permissions as the owner of the token has.
|
||||
- `server` role allows for posting activity of "itself" only.
|
||||
|
||||
**These roles cannot be deleted.**
|
||||
|
@@ -38,7 +38,7 @@ By adding a scope to an existing role, all role bearers will gain the associated
|
||||
Metascopes do not follow the general scope syntax. Instead, a metascope resolves to a set of scopes, which can refer to different resources, based on their owning entity. In JupyterHub, there are currently two metascopes:
|
||||
|
||||
1. default user scope `self`, and
|
||||
2. default token scope `all`.
|
||||
2. default token scope `inherit`.
|
||||
|
||||
(default-user-scope-target)=
|
||||
|
||||
@@ -57,11 +57,11 @@ The `self` scope is only valid for user entities. In other cases (e.g., for serv
|
||||
|
||||
### Default token scope
|
||||
|
||||
The token metascope `all` covers the same scopes as the token owner's scopes during requests. For example, if a token owner has roles containing the scopes `read:groups` and `read:users`, the `all` scope resolves to the set of scopes `{read:groups, read:users}`.
|
||||
The token metascope `inherit` causes the token to have the same permissions as the token's owner. For example, if a token owner has roles containing the scopes `read:groups` and `read:users`, the `inherit` scope resolves to the set of scopes `{read:groups, read:users}`.
|
||||
|
||||
If the token owner has default `user` role, the `all` scope resolves to `self`, which will subsequently be expanded to include all the user-specific scopes (or empty set in the case of services).
|
||||
If the token owner has default `user` role, the `inherit` scope resolves to `self`, which will subsequently be expanded to include all the user-specific scopes (or empty set in the case of services).
|
||||
|
||||
If the token owner is a member of any group with roles, the group scopes will also be included in resolving the `all` scope.
|
||||
If the token owner is a member of any group with roles, the group scopes will also be included in resolving the `inherit` scope.
|
||||
|
||||
(horizontal-filtering-target)=
|
||||
|
||||
|
@@ -49,6 +49,6 @@ API tokens can also be issued to users via API ([_/hub/token_](../reference/urls
|
||||
|
||||
### With RBAC
|
||||
|
||||
The RBAC framework allows for granting tokens different levels of permissions via scopes attached to roles. The 'only identify' purpose of the separate OAuth tokens is no longer required. API tokens can be used used for every action, including the login and authentication, for which an API token with no role (i.e., no scope in {ref}`available-scopes-target`) is used.
|
||||
The RBAC framework allows for granting tokens different levels of permissions via scopes attached to roles. The 'only identify' purpose of the separate OAuth tokens is no longer required. API tokens can be used for every action, including the login and authentication, for which an API token with no role (i.e., no scope in {ref}`available-scopes-target`) is used.
|
||||
|
||||
OAuth tokens are therefore dropped from the Hub upgraded with the RBAC framework.
|
||||
|
@@ -1,6 +1,6 @@
|
||||
# Authenticators
|
||||
|
||||
The [Authenticator][] is the mechanism for authorizing users to use the
|
||||
The {class}`.Authenticator` is the mechanism for authorizing users to use the
|
||||
Hub and single user notebook servers.
|
||||
|
||||
## The default PAM Authenticator
|
||||
@@ -137,8 +137,8 @@ via other mechanisms. One such example is using [GitHub OAuth][].
|
||||
|
||||
Because the username is passed from the Authenticator to the Spawner,
|
||||
a custom Authenticator and Spawner are often used together.
|
||||
For example, the Authenticator methods, [pre_spawn_start(user, spawner)][]
|
||||
and [post_spawn_stop(user, spawner)][], are hooks that can be used to do
|
||||
For example, the Authenticator methods, {meth}`.Authenticator.pre_spawn_start`
|
||||
and {meth}`.Authenticator.post_spawn_stop`, are hooks that can be used to do
|
||||
auth-related startup (e.g. opening PAM sessions) and cleanup
|
||||
(e.g. closing PAM sessions).
|
||||
|
||||
@@ -223,7 +223,7 @@ If there are multiple keys present, the **first** key is always used to persist
|
||||
|
||||
Typically, if `auth_state` is persisted it is desirable to affect the Spawner environment in some way.
|
||||
This may mean defining environment variables, placing certificate in the user's home directory, etc.
|
||||
The `Authenticator.pre_spawn_start` method can be used to pass information from authenticator state
|
||||
The {meth}`Authenticator.pre_spawn_start` method can be used to pass information from authenticator state
|
||||
to Spawner environment:
|
||||
|
||||
```python
|
||||
@@ -247,10 +247,42 @@ class MyAuthenticator(Authenticator):
|
||||
spawner.environment['UPSTREAM_TOKEN'] = auth_state['upstream_token']
|
||||
```
|
||||
|
||||
(authenticator-groups)=
|
||||
|
||||
## Authenticator-managed group membership
|
||||
|
||||
:::{versionadded} 2.2
|
||||
:::
|
||||
|
||||
Some identity providers may have their own concept of group membership that you would like to preserve in JupyterHub.
|
||||
This is now possible with `Authenticator.managed_groups`.
|
||||
|
||||
You can set the config:
|
||||
|
||||
```python
|
||||
c.Authenticator.manage_groups = True
|
||||
```
|
||||
|
||||
to enable this behavior.
|
||||
The default is False for Authenticators that ship with JupyterHub,
|
||||
but may be True for custom Authenticators.
|
||||
Check your Authenticator's documentation for manage_groups support.
|
||||
|
||||
If True, {meth}`.Authenticator.authenticate` and {meth}`.Authenticator.refresh_user` may include a field `groups`
|
||||
which is a list of group names the user should be a member of:
|
||||
|
||||
- Membership will be added for any group in the list
|
||||
- Membership in any groups not in the list will be revoked
|
||||
- Any groups not already present in the database will be created
|
||||
- If `None` is returned, no changes are made to the user's group membership
|
||||
|
||||
If authenticator-managed groups are enabled,
|
||||
all group-management via the API is disabled.
|
||||
|
||||
## pre_spawn_start and post_spawn_stop hooks
|
||||
|
||||
Authenticators uses two hooks, [pre_spawn_start(user, spawner)][] and
|
||||
[post_spawn_stop(user, spawner)][] to add pass additional state information
|
||||
Authenticators uses two hooks, {meth}`.Authenticator.pre_spawn_start` and
|
||||
{meth}`.Authenticator.post_spawn_stop(user, spawner)` to add pass additional state information
|
||||
between the authenticator and a spawner. These hooks are typically used auth-related
|
||||
startup, i.e. opening a PAM session, and auth-related cleanup, i.e. closing a
|
||||
PAM session.
|
||||
@@ -259,10 +291,7 @@ PAM session.
|
||||
|
||||
Beginning with version 0.8, JupyterHub is an OAuth provider.
|
||||
|
||||
[authenticator]: https://github.com/jupyterhub/jupyterhub/blob/HEAD/jupyterhub/auth.py
|
||||
[pam]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
||||
[oauth]: https://en.wikipedia.org/wiki/OAuth
|
||||
[github oauth]: https://developer.github.com/v3/oauth/
|
||||
[oauthenticator]: https://github.com/jupyterhub/oauthenticator
|
||||
[pre_spawn_start(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.pre_spawn_start
|
||||
[post_spawn_stop(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.post_spawn_stop
|
||||
|
@@ -165,7 +165,7 @@ As with nginx above, you can use [Apache](https://httpd.apache.org) as the rever
|
||||
First, we will need to enable the apache modules that we are going to need:
|
||||
|
||||
```bash
|
||||
a2enmod ssl rewrite proxy proxy_http proxy_wstunnel
|
||||
a2enmod ssl rewrite proxy headers proxy_http proxy_wstunnel
|
||||
```
|
||||
|
||||
Our Apache configuration is equivalent to the nginx configuration above:
|
||||
@@ -188,13 +188,24 @@ Listen 443
|
||||
|
||||
ServerName HUB.DOMAIN.TLD
|
||||
|
||||
# enable HTTP/2, if available
|
||||
Protocols h2 http/1.1
|
||||
|
||||
# HTTP Strict Transport Security (mod_headers is required) (63072000 seconds)
|
||||
Header always set Strict-Transport-Security "max-age=63072000"
|
||||
|
||||
# configure SSL
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
|
||||
SSLProtocol All -SSLv2 -SSLv3
|
||||
SSLOpenSSLConfCmd DHParameters /etc/ssl/certs/dhparam.pem
|
||||
SSLCipherSuite EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH
|
||||
|
||||
# intermediate configuration from ssl-config.mozilla.org (2022-03-03)
|
||||
# Please note, that this configuration might be out-dated - please update it accordingly using https://ssl-config.mozilla.org/
|
||||
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
|
||||
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
|
||||
SSLHonorCipherOrder off
|
||||
SSLSessionTickets off
|
||||
|
||||
# Use RewriteEngine to handle websocket connection upgrades
|
||||
RewriteEngine On
|
||||
@@ -208,6 +219,7 @@ Listen 443
|
||||
# proxy to JupyterHub
|
||||
ProxyPass http://127.0.0.1:8000/
|
||||
ProxyPassReverse http://127.0.0.1:8000/
|
||||
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
|
||||
</Location>
|
||||
</VirtualHost>
|
||||
```
|
||||
|
@@ -113,7 +113,6 @@ c.JupyterHub.load_roles = [
|
||||
"scopes": [
|
||||
# specify the permissions the token should have
|
||||
"admin:users",
|
||||
"admin:services",
|
||||
],
|
||||
"services": [
|
||||
# assign the service the above permissions
|
||||
|
@@ -1,17 +1,5 @@
|
||||
# Services
|
||||
|
||||
With version 0.7, JupyterHub adds support for **Services**.
|
||||
|
||||
This section provides the following information about Services:
|
||||
|
||||
- [Definition of a Service](#definition-of-a-service)
|
||||
- [Properties of a Service](#properties-of-a-service)
|
||||
- [Hub-Managed Services](#hub-managed-services)
|
||||
- [Launching a Hub-Managed Service](#launching-a-hub-managed-service)
|
||||
- [Externally-Managed Services](#externally-managed-services)
|
||||
- [Writing your own Services](#writing-your-own-services)
|
||||
- [Hub Authentication and Services](#hub-authentication-and-services)
|
||||
|
||||
## Definition of a Service
|
||||
|
||||
When working with JupyterHub, a **Service** is defined as a process that interacts
|
||||
@@ -95,6 +83,7 @@ c.JupyterHub.load_roles = [
|
||||
# 'admin:users' # needed if culling idle users as well
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
@@ -115,6 +104,8 @@ parameters, which describe the environment needed to start the Service process:
|
||||
|
||||
The Hub will pass the following environment variables to launch the Service:
|
||||
|
||||
(service-env)=
|
||||
|
||||
```bash
|
||||
JUPYTERHUB_SERVICE_NAME: The name of the service
|
||||
JUPYTERHUB_API_TOKEN: API token assigned to the service
|
||||
@@ -196,25 +187,45 @@ extra slash you might get unexpected behavior. For example if your service has a
|
||||
|
||||
## Hub Authentication and Services
|
||||
|
||||
JupyterHub 0.7 introduces some utilities for using the Hub's authentication
|
||||
mechanism to govern access to your service. When a user logs into JupyterHub,
|
||||
the Hub sets a **cookie (`jupyterhub-services`)**. The service can use this
|
||||
cookie to authenticate requests.
|
||||
JupyterHub provides some utilities for using the Hub's authentication
|
||||
mechanism to govern access to your service.
|
||||
|
||||
JupyterHub ships with a reference implementation of Hub authentication that
|
||||
Requests to all JupyterHub services are made with OAuth tokens.
|
||||
These can either be requests with a token in the `Authorization` header,
|
||||
or url parameter `?token=...`,
|
||||
or browser requests which must complete the OAuth authorization code flow,
|
||||
which results in a token that should be persisted for future requests
|
||||
(persistence is up to the service,
|
||||
but an encrypted cookie confined to the service path is appropriate,
|
||||
and provided by default).
|
||||
|
||||
:::{versionchanged} 2.0
|
||||
The shared `jupyterhub-services` cookie is removed.
|
||||
OAuth must be used to authenticate browser requests with services.
|
||||
:::
|
||||
|
||||
JupyterHub includes a reference implementation of Hub authentication that
|
||||
can be used by services. You may go beyond this reference implementation and
|
||||
create custom hub-authenticating clients and services. We describe the process
|
||||
below.
|
||||
|
||||
The reference, or base, implementation is the [`HubAuth`][hubauth] class,
|
||||
which implements the requests to the Hub.
|
||||
The reference, or base, implementation is the {class}`.HubAuth` class,
|
||||
which implements the API requests to the Hub that resolve a token to a User model.
|
||||
|
||||
There are two levels of authentication with the Hub:
|
||||
|
||||
- {class}`.HubAuth` - the most basic authentication,
|
||||
for services that should only accept API requests authorized with a token.
|
||||
|
||||
- {class}`.HubOAuth` - For services that should use oauth to authenticate with the Hub.
|
||||
This should be used for any service that serves pages that should be visited with a browser.
|
||||
|
||||
To use HubAuth, you must set the `.api_token`, either programmatically when constructing the class,
|
||||
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
||||
|
||||
Most of the logic for authentication implementation is found in the
|
||||
[`HubAuth.user_for_token`][hubauth.user_for_token]
|
||||
methods, which makes a request of the Hub, and returns:
|
||||
{meth}`.HubAuth.user_for_token` methods,
|
||||
which makes a request of the Hub, and returns:
|
||||
|
||||
- None, if no user could be identified, or
|
||||
- a dict of the following form:
|
||||
@@ -235,6 +246,19 @@ action.
|
||||
HubAuth also caches the Hub's response for a number of seconds,
|
||||
configurable by the `cookie_cache_max_age` setting (default: five minutes).
|
||||
|
||||
If your service would like to make further requests _on behalf of users_,
|
||||
it should use the token issued by this OAuth process.
|
||||
If you are using tornado,
|
||||
you can access the token authenticating the current request with {meth}`.HubAuth.get_token`.
|
||||
|
||||
:::{versionchanged} 2.2
|
||||
|
||||
{meth}`.HubAuth.get_token` adds support for retrieving
|
||||
tokens stored in tornado cookies after completion of OAuth.
|
||||
Previously, it only retrieved tokens from URL parameters or the Authorization header.
|
||||
Passing `get_token(handler, in_cookie=False)` preserves this behavior.
|
||||
:::
|
||||
|
||||
### Flask Example
|
||||
|
||||
For example, you have a Flask service that returns information about a user.
|
||||
@@ -250,18 +274,17 @@ for more details.
|
||||
### Authenticating tornado services with JupyterHub
|
||||
|
||||
Since most Jupyter services are written with tornado,
|
||||
we include a mixin class, [`HubAuthenticated`][hubauthenticated],
|
||||
we include a mixin class, [`HubOAuthenticated`][huboauthenticated],
|
||||
for quickly authenticating your own tornado services with JupyterHub.
|
||||
|
||||
Tornado's `@web.authenticated` method calls a Handler's `.get_current_user`
|
||||
method to identify the user. Mixing in `HubAuthenticated` defines
|
||||
`get_current_user` to use HubAuth. If you want to configure the HubAuth
|
||||
instance beyond the default, you'll want to define an `initialize` method,
|
||||
Tornado's {py:func}`~.tornado.web.authenticated` decorator calls a Handler's {py:meth}`~.tornado.web.RequestHandler.get_current_user`
|
||||
method to identify the user. Mixing in {class}`.HubAuthenticated` defines
|
||||
{meth}`~.HubAuthenticated.get_current_user` to use HubAuth. If you want to configure the HubAuth
|
||||
instance beyond the default, you'll want to define an {py:meth}`~.tornado.web.RequestHandler.initialize` method,
|
||||
such as:
|
||||
|
||||
```python
|
||||
class MyHandler(HubAuthenticated, web.RequestHandler):
|
||||
hub_users = {'inara', 'mal'}
|
||||
class MyHandler(HubOAuthenticated, web.RequestHandler):
|
||||
|
||||
def initialize(self, hub_auth):
|
||||
self.hub_auth = hub_auth
|
||||
@@ -271,14 +294,21 @@ class MyHandler(HubAuthenticated, web.RequestHandler):
|
||||
...
|
||||
```
|
||||
|
||||
The HubAuth will automatically load the desired configuration from the Service
|
||||
environment variables.
|
||||
The HubAuth class will automatically load the desired configuration from the Service
|
||||
[environment variables](service-env).
|
||||
|
||||
If you want to limit user access, you can specify allowed users through either the
|
||||
`.hub_users` attribute or `.hub_groups`. These are sets that check against the
|
||||
username and user group list, respectively. If a user matches neither the user
|
||||
list nor the group list, they will not be allowed access. If both are left
|
||||
undefined, then any user will be allowed.
|
||||
:::{versionchanged} 2.0
|
||||
|
||||
Access scopes are used to govern access to services.
|
||||
Prior to 2.0,
|
||||
sets of users and groups could be used to grant access
|
||||
by defining `.hub_groups` or `.hub_users` on the authenticated handler.
|
||||
These are ignored if the 2.0 `.hub_scopes` is defined.
|
||||
:::
|
||||
|
||||
:::{seealso}
|
||||
{meth}`.HubAuth.check_scopes`
|
||||
:::
|
||||
|
||||
### Implementing your own Authentication with JupyterHub
|
||||
|
||||
@@ -328,7 +358,7 @@ and taking note of the following process:
|
||||
```python
|
||||
{
|
||||
"name": "inara",
|
||||
# groups may be omitted, depending on permissions
|
||||
# groups may be omitted, depending on permissions
|
||||
"groups": ["serenity", "guild"],
|
||||
# scopes is new in JupyterHub 2.0
|
||||
"scopes": [
|
||||
@@ -354,9 +384,6 @@ section on securing the notebook viewer.
|
||||
|
||||
[requests]: http://docs.python-requests.org/en/master/
|
||||
[services_auth]: ../api/services.auth.html
|
||||
[huboauth]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuth
|
||||
[hubauth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
|
||||
[hubauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
||||
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
||||
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
|
||||
[fastapi]: https://fastapi.tiangolo.com
|
||||
|
@@ -108,6 +108,16 @@ class MySpawner(Spawner):
|
||||
return url
|
||||
```
|
||||
|
||||
#### Exception handling
|
||||
|
||||
When `Spawner.start` raises an Exception, a message can be passed on to the user via the exception via a `.jupyterhub_html_message` or `.jupyterhub_message` attribute.
|
||||
|
||||
When the Exception has a `.jupyterhub_html_message` attribute, it will be rendered as HTML to the user.
|
||||
|
||||
Alternatively `.jupyterhub_message` is rendered as unformatted text.
|
||||
|
||||
If both attributes are not present, the Exception will be shown to the user as unformatted text.
|
||||
|
||||
### Spawner.poll
|
||||
|
||||
`Spawner.poll` should check if the spawner is still running.
|
||||
|
@@ -275,7 +275,7 @@ where `ssl_cert` is example-chained.crt and ssl_key to your private key.
|
||||
|
||||
Then restart JupyterHub.
|
||||
|
||||
See also [JupyterHub SSL encryption](./getting-started/security-basics.html#ssl-encryption).
|
||||
See also {ref}`ssl-encryption`.
|
||||
|
||||
### Install JupyterHub without a network connection
|
||||
|
||||
|
@@ -10,7 +10,9 @@ here = Path(__file__).absolute().parent
|
||||
root = here.parent
|
||||
|
||||
|
||||
def test_rest_api_version():
|
||||
def test_rest_api_version_is_updated():
|
||||
"""Checks that the version in JupyterHub's REST API definition file
|
||||
(rest-api.yml) is matching the JupyterHub version."""
|
||||
version_py = root.joinpath("jupyterhub", "_version.py")
|
||||
rest_api_yaml = root.joinpath("docs", "source", "_static", "rest-api.yml")
|
||||
ns = {}
|
||||
@@ -25,18 +27,17 @@ def test_rest_api_version():
|
||||
assert jupyterhub_version == rest_api_version
|
||||
|
||||
|
||||
def test_restapi_scopes():
|
||||
def test_rest_api_rbac_scope_descriptions_are_updated():
|
||||
"""Checks that the RBAC scope descriptions in JupyterHub's REST API
|
||||
definition file (rest-api.yml) as can be updated by generate-scope-table.py
|
||||
matches what is committed."""
|
||||
run([sys.executable, "source/rbac/generate-scope-table.py"], cwd=here, check=True)
|
||||
run(
|
||||
['pre-commit', 'run', 'prettier', '--files', 'source/_static/rest-api.yml'],
|
||||
cwd=here,
|
||||
check=False,
|
||||
)
|
||||
run(
|
||||
[
|
||||
"git",
|
||||
"diff",
|
||||
"--no-pager",
|
||||
"diff",
|
||||
"--color=always",
|
||||
"--exit-code",
|
||||
str(here.joinpath("source", "_static", "rest-api.yml")),
|
||||
],
|
||||
|
30
examples/azuread-with-group-management/jupyterhub_config.py
Normal file
30
examples/azuread-with-group-management/jupyterhub_config.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""sample jupyterhub config file for testing
|
||||
|
||||
configures jupyterhub with dummyauthenticator and simplespawner
|
||||
to enable testing without administrative privileges.
|
||||
"""
|
||||
|
||||
c = get_config() # noqa
|
||||
c.Application.log_level = 'DEBUG'
|
||||
|
||||
from oauthenticator.azuread import AzureAdOAuthenticator
|
||||
import os
|
||||
|
||||
c.JupyterHub.authenticator_class = AzureAdOAuthenticator
|
||||
|
||||
c.AzureAdOAuthenticator.client_id = os.getenv("AAD_CLIENT_ID")
|
||||
c.AzureAdOAuthenticator.client_secret = os.getenv("AAD_CLIENT_SECRET")
|
||||
c.AzureAdOAuthenticator.oauth_callback_url = os.getenv("AAD_CALLBACK_URL")
|
||||
c.AzureAdOAuthenticator.tenant_id = os.getenv("AAD_TENANT_ID")
|
||||
c.AzureAdOAuthenticator.username_claim = "email"
|
||||
c.AzureAdOAuthenticator.authorize_url = os.getenv("AAD_AUTHORIZE_URL")
|
||||
c.AzureAdOAuthenticator.token_url = os.getenv("AAD_TOKEN_URL")
|
||||
c.Authenticator.manage_groups = True
|
||||
c.Authenticator.refresh_pre_spawn = True
|
||||
|
||||
# Optionally set a global password that all users must use
|
||||
# c.DummyAuthenticator.password = "your_password"
|
||||
|
||||
from jupyterhub.spawner import SimpleLocalProcessSpawner
|
||||
|
||||
c.JupyterHub.spawner_class = SimpleLocalProcessSpawner
|
2
examples/azuread-with-group-management/requirements.txt
Normal file
2
examples/azuread-with-group-management/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
oauthenticator
|
||||
pyjwt
|
@@ -8,59 +8,72 @@ There is an implementation each of api-token-based `HubAuthenticated` and OAuth-
|
||||
|
||||
1. Launch JupyterHub and the `whoami` services with
|
||||
|
||||
jupyterhub --ip=127.0.0.1
|
||||
jupyterhub
|
||||
|
||||
2. Visit http://127.0.0.1:8000/services/whoami-oauth
|
||||
|
||||
After logging in with your local-system credentials, you should see a JSON dump of your user info:
|
||||
After logging in with any username and password, you should see a JSON dump of your user info:
|
||||
|
||||
```json
|
||||
{
|
||||
"admin": false,
|
||||
"last_activity": "2016-05-27T14:05:18.016372",
|
||||
"groups": [],
|
||||
"kind": "user",
|
||||
"name": "queequeg",
|
||||
"pending": null,
|
||||
"server": "/user/queequeg"
|
||||
"scopes": ["access:services!service=whoami-oauth"],
|
||||
"session_id": "5a2164273a7346728873bcc2e3c26415"
|
||||
}
|
||||
```
|
||||
|
||||
What is contained in the model will depend on the permissions
|
||||
requested in the `oauth_roles` configuration of the service `whoami-oauth` service.
|
||||
The default is the minimum required for identification and access to the service,
|
||||
which will provide the username and current scopes.
|
||||
|
||||
The `whoami-api` service powered by the base `HubAuthenticated` class only supports token-authenticated API requests,
|
||||
not browser visits, because it does not implement OAuth. Visit it by requesting an api token from the tokens page,
|
||||
not browser visits, because it does not implement OAuth. Visit it by requesting an api token from the tokens page (`/hub/token`),
|
||||
and making a direct request:
|
||||
|
||||
```bash
|
||||
$ curl -H "Authorization: token 8630bbd8ef064c48b22c7f122f0cd8ad" http://127.0.0.1:8000/services/whoami-api/ | jq .
|
||||
token="d584cbc5bba2430fb153aadb305029b4"
|
||||
curl -H "Authorization: token $token" http://127.0.0.1:8000/services/whoami-api/ | jq .
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"admin": false,
|
||||
"created": "2021-05-21T09:47:41.299400Z",
|
||||
"created": "2021-12-20T09:49:37.258427Z",
|
||||
"groups": [],
|
||||
"kind": "user",
|
||||
"last_activity": "2021-05-21T09:49:08.290745Z",
|
||||
"name": "test",
|
||||
"last_activity": "2021-12-20T10:07:31.298056Z",
|
||||
"name": "queequeg",
|
||||
"pending": null,
|
||||
"roles": [
|
||||
"user"
|
||||
],
|
||||
"roles": ["user"],
|
||||
"scopes": [
|
||||
"access:servers!user=queequeg",
|
||||
"access:services",
|
||||
"access:servers!user=test",
|
||||
"read:users!user=test",
|
||||
"read:users:activity!user=test",
|
||||
"read:users:groups!user=test",
|
||||
"read:users:name!user=test",
|
||||
"read:servers!user=test",
|
||||
"read:tokens!user=test",
|
||||
"users!user=test",
|
||||
"users:activity!user=test",
|
||||
"users:groups!user=test",
|
||||
"users:name!user=test",
|
||||
"servers!user=test",
|
||||
"tokens!user=test"
|
||||
"delete:servers!user=queequeg",
|
||||
"read:servers!user=queequeg",
|
||||
"read:tokens!user=queequeg",
|
||||
"read:users!user=queequeg",
|
||||
"read:users:activity!user=queequeg",
|
||||
"read:users:groups!user=queequeg",
|
||||
"read:users:name!user=queequeg",
|
||||
"servers!user=queequeg",
|
||||
"tokens!user=queequeg",
|
||||
"users:activity!user=queequeg"
|
||||
],
|
||||
"server": null
|
||||
"server": null,
|
||||
"servers": {},
|
||||
"session_id": null
|
||||
}
|
||||
```
|
||||
|
||||
The above is a more complete user model than the `whoami-oauth` example, because
|
||||
the token was issued with the default `token` role,
|
||||
which has the `inherit` metascope,
|
||||
meaning the token has access to everything the tokens owner has access to.
|
||||
|
||||
This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
|
||||
|
||||
To govern access to the services, create **roles** with the scope `access:services!service=$service-name`,
|
||||
|
@@ -10,7 +10,15 @@ c.JupyterHub.services = [
|
||||
'name': 'whoami-oauth',
|
||||
'url': 'http://127.0.0.1:10102',
|
||||
'command': [sys.executable, './whoami-oauth.py'],
|
||||
'oauth_roles': ['user'],
|
||||
# the default oauth roles is minimal,
|
||||
# only requesting access to the service,
|
||||
# and identification by name,
|
||||
# nothing more.
|
||||
# Specifying 'oauth_roles' as a list of role names
|
||||
# allows requesting more information about users,
|
||||
# or the ability to take actions on users' behalf, as required.
|
||||
# The default 'token' role has the full permissions of its owner:
|
||||
# 'oauth_roles': ['token'],
|
||||
},
|
||||
]
|
||||
|
||||
|
@@ -5,12 +5,12 @@ object-assign
|
||||
*/
|
||||
|
||||
/*!
|
||||
Copyright (c) 2017 Jed Watson.
|
||||
Copyright (c) 2018 Jed Watson.
|
||||
Licensed under the MIT License (MIT), see
|
||||
http://jedwatson.github.io/classnames
|
||||
*/
|
||||
|
||||
/** @license React v0.20.1
|
||||
/** @license React v0.20.2
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
@@ -28,7 +28,7 @@ object-assign
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v17.0.1
|
||||
/** @license React v17.0.2
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
@@ -37,7 +37,16 @@ object-assign
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v17.0.1
|
||||
/** @license React v17.0.2
|
||||
* react-jsx-runtime.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v17.0.2
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
|
@@ -31,30 +31,36 @@
|
||||
"@babel/core": "^7.12.3",
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"@babel/preset-react": "^7.12.10",
|
||||
"@testing-library/jest-dom": "^5.15.1",
|
||||
"@testing-library/react": "^12.1.2",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"babel-loader": "^8.2.1",
|
||||
"bootstrap": "^4.5.3",
|
||||
"css-loader": "^5.0.1",
|
||||
"eslint-plugin-unused-imports": "^1.1.1",
|
||||
"file-loader": "^6.2.0",
|
||||
"history": "^5.0.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^17.0.1",
|
||||
"react-bootstrap": "^1.4.0",
|
||||
"react-bootstrap": "^2.1.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-icons": "^4.1.0",
|
||||
"react-multi-select-component": "^3.0.7",
|
||||
"react-object-table-viewer": "^1.0.7",
|
||||
"react-redux": "^7.2.2",
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"recompose": "^0.30.0",
|
||||
"redux": "^4.0.5",
|
||||
"regenerator-runtime": "^0.13.9",
|
||||
"style-loader": "^2.0.0",
|
||||
"webpack": "^5.6.0",
|
||||
"webpack-cli": "^3.3.4",
|
||||
"webpack-dev-server": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.4.1",
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
|
||||
"babel-jest": "^26.6.3",
|
||||
"enzyme": "^3.11.0",
|
||||
"eslint": "^7.18.0",
|
||||
@@ -62,6 +68,7 @@
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^26.6.3",
|
||||
"prettier": "^2.2.1"
|
||||
"prettier": "^2.2.1",
|
||||
"sinon": "^13.0.1"
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
export const initialState = {
|
||||
user_data: undefined,
|
||||
user_page: 0,
|
||||
name_filter: "",
|
||||
groups_data: undefined,
|
||||
groups_page: 0,
|
||||
limit: window.api_page_limit,
|
||||
@@ -13,6 +14,7 @@ export const reducers = (state = initialState, action) => {
|
||||
return Object.assign({}, state, {
|
||||
user_page: action.value.page,
|
||||
user_data: action.value.data,
|
||||
name_filter: action.value.name_filter || "",
|
||||
});
|
||||
|
||||
// Updates the client group model data and stores the page
|
||||
|
@@ -25,11 +25,20 @@ const AddUser = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container">
|
||||
<div className="container" data-testid="container">
|
||||
{errorAlert != null ? (
|
||||
<div className="row">
|
||||
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
||||
<div className="alert alert-danger">{errorAlert}</div>
|
||||
<div className="alert alert-danger">
|
||||
{errorAlert}
|
||||
<button
|
||||
type="button"
|
||||
className="close"
|
||||
onClick={() => setErrorAlert(null)}
|
||||
>
|
||||
<span>×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -49,6 +58,7 @@ const AddUser = (props) => {
|
||||
id="add-user-textarea"
|
||||
rows="3"
|
||||
placeholder="usernames separated by line"
|
||||
data-testid="user-textarea"
|
||||
onBlur={(e) => {
|
||||
let split_users = e.target.value.split("\n");
|
||||
setUsers(split_users);
|
||||
@@ -57,10 +67,11 @@ const AddUser = (props) => {
|
||||
<br></br>
|
||||
<input
|
||||
className="form-check-input"
|
||||
data-testid="check"
|
||||
type="checkbox"
|
||||
value=""
|
||||
id="admin-check"
|
||||
onChange={(e) => setAdmin(e.target.checked)}
|
||||
checked={admin}
|
||||
onChange={() => setAdmin(!admin)}
|
||||
/>
|
||||
<span> </span>
|
||||
<label className="form-check-label">Admin</label>
|
||||
@@ -74,6 +85,7 @@ const AddUser = (props) => {
|
||||
<span> </span>
|
||||
<button
|
||||
id="submit"
|
||||
data-testid="submit"
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
let filtered_users = users.filter(
|
||||
@@ -92,14 +104,16 @@ const AddUser = (props) => {
|
||||
? updateUsers(0, limit)
|
||||
.then((data) => dispatchPageChange(data, 0))
|
||||
.then(() => history.push("/"))
|
||||
.catch((err) => console.log(err))
|
||||
.catch(() =>
|
||||
setErrorAlert(`Failed to update users.`)
|
||||
)
|
||||
: setErrorAlert(
|
||||
`[${data.status}] Failed to create user. ${
|
||||
`Failed to create user. ${
|
||||
data.status == 409 ? "User already exists." : ""
|
||||
}`
|
||||
)
|
||||
)
|
||||
.catch((err) => console.log(err));
|
||||
.catch(() => setErrorAlert(`Failed to create user.`));
|
||||
}}
|
||||
>
|
||||
Add Users
|
||||
|
@@ -1,12 +1,15 @@
|
||||
import React from "react";
|
||||
import Enzyme, { mount } from "enzyme";
|
||||
import AddUser from "./AddUser";
|
||||
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||
import { createStore } from "redux";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
// eslint-disable-next-line
|
||||
import regeneratorRuntime from "regenerator-runtime";
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
import AddUser from "./AddUser";
|
||||
|
||||
jest.mock("react-redux", () => ({
|
||||
...jest.requireActual("react-redux"),
|
||||
@@ -14,64 +17,123 @@ jest.mock("react-redux", () => ({
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("AddUser Component: ", () => {
|
||||
var mockAsync = () =>
|
||||
jest
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
|
||||
var mockAsync = (result) =>
|
||||
jest.fn().mockImplementation(() => Promise.resolve(result));
|
||||
|
||||
var addUserJsx = (callbackSpy) => (
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<AddUser
|
||||
addUsers={callbackSpy}
|
||||
failRegexEvent={callbackSpy}
|
||||
updateUsers={callbackSpy}
|
||||
history={{ push: () => {} }}
|
||||
/>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
var mockAsyncRejection = () =>
|
||||
jest.fn().mockImplementation(() => Promise.reject());
|
||||
|
||||
var mockAppState = () => ({
|
||||
limit: 3,
|
||||
var addUserJsx = (spy, spy2, spy3) => (
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<AddUser
|
||||
addUsers={spy}
|
||||
failRegexEvent={spy2 || spy}
|
||||
updateUsers={spy3 || spy2 || spy}
|
||||
history={{ push: () => {} }}
|
||||
/>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
var mockAppState = () => ({
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useDispatch.mockImplementation(() => {
|
||||
return () => {};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useDispatch.mockImplementation(() => {
|
||||
return () => {};
|
||||
});
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback(mockAppState());
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useDispatch.mockClear();
|
||||
});
|
||||
|
||||
it("Renders", () => {
|
||||
let component = mount(addUserJsx(mockAsync()));
|
||||
expect(component.find(".container").length).toBe(1);
|
||||
});
|
||||
|
||||
it("Removes users when they fail Regex", () => {
|
||||
let callbackSpy = mockAsync(),
|
||||
component = mount(addUserJsx(callbackSpy)),
|
||||
textarea = component.find("textarea").first();
|
||||
textarea.simulate("blur", { target: { value: "foo\nbar\n!!*&*" } });
|
||||
let submit = component.find("#submit");
|
||||
submit.simulate("click");
|
||||
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar"], false);
|
||||
});
|
||||
|
||||
it("Correctly submits admin", () => {
|
||||
let callbackSpy = mockAsync(),
|
||||
component = mount(addUserJsx(callbackSpy)),
|
||||
input = component.find("input").first();
|
||||
input.simulate("change", { target: { checked: true } });
|
||||
let submit = component.find("#submit");
|
||||
submit.simulate("click");
|
||||
expect(callbackSpy).toHaveBeenCalledWith([], true);
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback(mockAppState());
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useDispatch.mockClear();
|
||||
});
|
||||
|
||||
test("Renders", async () => {
|
||||
await act(async () => {
|
||||
render(addUserJsx());
|
||||
});
|
||||
expect(screen.getByTestId("container")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Removes users when they fail Regex", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(addUserJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let textarea = screen.getByTestId("user-textarea");
|
||||
let submit = screen.getByTestId("submit");
|
||||
|
||||
fireEvent.blur(textarea, { target: { value: "foo\nbar\n!!*&*" } });
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar"], false);
|
||||
});
|
||||
|
||||
test("Correctly submits admin", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(addUserJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let textarea = screen.getByTestId("user-textarea");
|
||||
let submit = screen.getByTestId("submit");
|
||||
let check = screen.getByTestId("check");
|
||||
|
||||
userEvent.click(check);
|
||||
fireEvent.blur(textarea, { target: { value: "foo" } });
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenCalledWith(["foo"], true);
|
||||
});
|
||||
|
||||
test("Shows a UI error dialogue when user creation fails", async () => {
|
||||
let callbackSpy = mockAsyncRejection();
|
||||
|
||||
await act(async () => {
|
||||
render(addUserJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let submit = screen.getByTestId("submit");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText("Failed to create user.");
|
||||
|
||||
expect(errorDialog).toBeVisible();
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Shows a more specific UI error dialogue when user creation returns an improper status code", async () => {
|
||||
let callbackSpy = mockAsync({ status: 409 });
|
||||
|
||||
await act(async () => {
|
||||
render(addUserJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let submit = screen.getByTestId("submit");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText(
|
||||
"Failed to create user. User already exists."
|
||||
);
|
||||
|
||||
expect(errorDialog).toBeVisible();
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
@@ -24,11 +24,20 @@ const CreateGroup = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container">
|
||||
<div className="container" data-testid="container">
|
||||
{errorAlert != null ? (
|
||||
<div className="row">
|
||||
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
||||
<div className="alert alert-danger">{errorAlert}</div>
|
||||
<div className="alert alert-danger">
|
||||
{errorAlert}
|
||||
<button
|
||||
type="button"
|
||||
className="close"
|
||||
onClick={() => setErrorAlert(null)}
|
||||
>
|
||||
<span>×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -44,6 +53,7 @@ const CreateGroup = (props) => {
|
||||
<div className="input-group">
|
||||
<input
|
||||
className="group-name-input"
|
||||
data-testid="group-input"
|
||||
type="text"
|
||||
id="group-name"
|
||||
value={groupName}
|
||||
@@ -61,6 +71,7 @@ const CreateGroup = (props) => {
|
||||
<span> </span>
|
||||
<button
|
||||
id="submit"
|
||||
data-testid="submit"
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
createGroup(groupName)
|
||||
@@ -69,16 +80,18 @@ const CreateGroup = (props) => {
|
||||
? updateGroups(0, limit)
|
||||
.then((data) => dispatchPageUpdate(data, 0))
|
||||
.then(() => history.push("/groups"))
|
||||
.catch((err) => console.log(err))
|
||||
.catch(() =>
|
||||
setErrorAlert(`Could not update groups list.`)
|
||||
)
|
||||
: setErrorAlert(
|
||||
`[${data.status}] Failed to create group. ${
|
||||
`Failed to create group. ${
|
||||
data.status == 409
|
||||
? "Group already exists."
|
||||
: ""
|
||||
}`
|
||||
);
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
.catch(() => setErrorAlert(`Failed to create group.`));
|
||||
}}
|
||||
>
|
||||
Create
|
||||
|
@@ -1,13 +1,14 @@
|
||||
import React from "react";
|
||||
import Enzyme, { mount } from "enzyme";
|
||||
import CreateGroup from "./CreateGroup";
|
||||
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||
import { createStore } from "redux";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
// eslint-disable-next-line
|
||||
import regeneratorRuntime from "regenerator-runtime";
|
||||
import CreateGroup from "./CreateGroup";
|
||||
|
||||
jest.mock("react-redux", () => ({
|
||||
...jest.requireActual("react-redux"),
|
||||
@@ -15,52 +16,100 @@ jest.mock("react-redux", () => ({
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("CreateGroup Component: ", () => {
|
||||
var mockAsync = (result) =>
|
||||
jest.fn().mockImplementation(() => Promise.resolve(result));
|
||||
var mockAsync = (result) =>
|
||||
jest.fn().mockImplementation(() => Promise.resolve(result));
|
||||
|
||||
var createGroupJsx = (callbackSpy) => (
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<CreateGroup
|
||||
createGroup={callbackSpy}
|
||||
updateGroups={callbackSpy}
|
||||
history={{ push: () => {} }}
|
||||
/>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
var mockAsyncRejection = () =>
|
||||
jest.fn().mockImplementation(() => Promise.reject());
|
||||
|
||||
var mockAppState = () => ({
|
||||
limit: 3,
|
||||
var createGroupJsx = (callbackSpy) => (
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<CreateGroup
|
||||
createGroup={callbackSpy}
|
||||
updateGroups={callbackSpy}
|
||||
history={{ push: () => {} }}
|
||||
/>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
var mockAppState = () => ({
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useDispatch.mockImplementation(() => {
|
||||
return () => () => {};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useDispatch.mockImplementation(() => {
|
||||
return () => () => {};
|
||||
});
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback(mockAppState());
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useDispatch.mockClear();
|
||||
});
|
||||
|
||||
it("Renders", () => {
|
||||
let component = mount(createGroupJsx());
|
||||
expect(component.find(".container").length).toBe(1);
|
||||
});
|
||||
|
||||
it("Calls createGroup on submit", () => {
|
||||
let callbackSpy = mockAsync({ status: 200 }),
|
||||
component = mount(createGroupJsx(callbackSpy)),
|
||||
input = component.find("input").first(),
|
||||
submit = component.find("#submit").first();
|
||||
input.simulate("change", { target: { value: "" } });
|
||||
submit.simulate("click");
|
||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, "");
|
||||
expect(component.find(".alert.alert-danger").length).toBe(0);
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback(mockAppState());
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useDispatch.mockClear();
|
||||
});
|
||||
|
||||
test("Renders", async () => {
|
||||
await act(async () => {
|
||||
render(createGroupJsx());
|
||||
});
|
||||
expect(screen.getByTestId("container")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Calls createGroup on submit", async () => {
|
||||
let callbackSpy = mockAsync({ status: 200 });
|
||||
|
||||
await act(async () => {
|
||||
render(createGroupJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let input = screen.getByTestId("group-input");
|
||||
let submit = screen.getByTestId("submit");
|
||||
|
||||
userEvent.type(input, "groupname");
|
||||
await act(async () => fireEvent.click(submit));
|
||||
|
||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, "groupname");
|
||||
});
|
||||
|
||||
test("Shows a UI error dialogue when group creation fails", async () => {
|
||||
let callbackSpy = mockAsyncRejection();
|
||||
|
||||
await act(async () => {
|
||||
render(createGroupJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let submit = screen.getByTestId("submit");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText("Failed to create group.");
|
||||
|
||||
expect(errorDialog).toBeVisible();
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Shows a more specific UI error dialogue when user creation returns an improper status code", async () => {
|
||||
let callbackSpy = mockAsync({ status: 409 });
|
||||
|
||||
await act(async () => {
|
||||
render(createGroupJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let submit = screen.getByTestId("submit");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText(
|
||||
"Failed to create group. Group already exists."
|
||||
);
|
||||
|
||||
expect(errorDialog).toBeVisible();
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
@@ -19,14 +19,7 @@ const EditUser = (props) => {
|
||||
});
|
||||
};
|
||||
|
||||
var {
|
||||
editUser,
|
||||
deleteUser,
|
||||
failRegexEvent,
|
||||
noChangeEvent,
|
||||
updateUsers,
|
||||
history,
|
||||
} = props;
|
||||
var { editUser, deleteUser, noChangeEvent, updateUsers, history } = props;
|
||||
|
||||
if (props.location.state == undefined) {
|
||||
props.history.push("/");
|
||||
@@ -40,11 +33,20 @@ const EditUser = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container">
|
||||
<div className="container" data-testid="container">
|
||||
{errorAlert != null ? (
|
||||
<div className="row">
|
||||
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
||||
<div className="alert alert-danger">{errorAlert}</div>
|
||||
<div className="alert alert-danger">
|
||||
{errorAlert}
|
||||
<button
|
||||
type="button"
|
||||
className="close"
|
||||
onClick={() => setErrorAlert(null)}
|
||||
>
|
||||
<span>×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -61,6 +63,7 @@ const EditUser = (props) => {
|
||||
<div className="form-group">
|
||||
<textarea
|
||||
className="form-control"
|
||||
data-testid="edit-username-input"
|
||||
id="exampleFormControlTextarea1"
|
||||
rows="3"
|
||||
placeholder="updated username"
|
||||
@@ -81,20 +84,26 @@ const EditUser = (props) => {
|
||||
<br></br>
|
||||
<button
|
||||
id="delete-user"
|
||||
data-testid="delete-user"
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
deleteUser(username)
|
||||
.then((data) => {
|
||||
data.status < 300
|
||||
? updateUsers(0, limit)
|
||||
.then((data) => dispatchPageChange(data, 0))
|
||||
.then(() => history.push("/"))
|
||||
.catch((err) => console.log(err))
|
||||
: setErrorAlert(
|
||||
`[${data.status}] Failed to edit user.`
|
||||
);
|
||||
.catch(() =>
|
||||
setErrorAlert(
|
||||
`Could not update users list.`
|
||||
)
|
||||
)
|
||||
: setErrorAlert(`Failed to edit user.`);
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
.catch(() => {
|
||||
setErrorAlert(`Failed to edit user.`);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete user
|
||||
@@ -109,8 +118,10 @@ const EditUser = (props) => {
|
||||
<span> </span>
|
||||
<button
|
||||
id="submit"
|
||||
data-testid="submit"
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (updatedUsername == "" && admin == has_admin) {
|
||||
noChangeEvent();
|
||||
return;
|
||||
@@ -129,17 +140,20 @@ const EditUser = (props) => {
|
||||
? updateUsers(0, limit)
|
||||
.then((data) => dispatchPageChange(data, 0))
|
||||
.then(() => history.push("/"))
|
||||
.catch((err) => console.log(err))
|
||||
: setErrorAlert(
|
||||
`[${data.status}] Failed to edit user.`
|
||||
);
|
||||
.catch(() =>
|
||||
setErrorAlert(
|
||||
`Could not update users list.`
|
||||
)
|
||||
)
|
||||
: setErrorAlert(`Failed to edit user.`);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
.catch(() => {
|
||||
setErrorAlert(`Failed to edit user.`);
|
||||
});
|
||||
} else {
|
||||
setUpdatedUsername("");
|
||||
failRegexEvent();
|
||||
setErrorAlert(
|
||||
`Failed to edit user. Make sure the username does not contain special characters.`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
editUser(username, username, admin)
|
||||
@@ -148,13 +162,13 @@ const EditUser = (props) => {
|
||||
? updateUsers(0, limit)
|
||||
.then((data) => dispatchPageChange(data, 0))
|
||||
.then(() => history.push("/"))
|
||||
.catch((err) => console.log(err))
|
||||
: setErrorAlert(
|
||||
`[${data.status}] Failed to edit user.`
|
||||
);
|
||||
.catch(() =>
|
||||
setErrorAlert(`Could not update users list.`)
|
||||
)
|
||||
: setErrorAlert(`Failed to edit user.`);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
.catch(() => {
|
||||
setErrorAlert(`Failed to edit user.`);
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
@@ -1,12 +1,14 @@
|
||||
import React from "react";
|
||||
import Enzyme, { mount } from "enzyme";
|
||||
import EditUser from "./EditUser";
|
||||
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||
import { createStore } from "redux";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
// eslint-disable-next-line
|
||||
import regeneratorRuntime from "regenerator-runtime";
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
import EditUser from "./EditUser";
|
||||
|
||||
jest.mock("react-redux", () => ({
|
||||
...jest.requireActual("react-redux"),
|
||||
@@ -14,67 +16,124 @@ jest.mock("react-redux", () => ({
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("EditUser Component: ", () => {
|
||||
var mockAsync = () =>
|
||||
jest
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
|
||||
var mockSync = () => jest.fn();
|
||||
var mockAsync = (data) =>
|
||||
jest.fn().mockImplementation(() => Promise.resolve(data));
|
||||
|
||||
var editUserJsx = (callbackSpy, empty) => (
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<EditUser
|
||||
location={
|
||||
empty ? {} : { state: { username: "foo", has_admin: false } }
|
||||
}
|
||||
deleteUser={callbackSpy}
|
||||
editUser={callbackSpy}
|
||||
updateUsers={callbackSpy}
|
||||
history={{ push: () => {} }}
|
||||
failRegexEvent={callbackSpy}
|
||||
noChangeEvent={callbackSpy}
|
||||
/>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
var mockAsyncRejection = () =>
|
||||
jest.fn().mockImplementation(() => Promise.reject());
|
||||
|
||||
var mockAppState = () => ({
|
||||
limit: 3,
|
||||
var editUserJsx = (callbackSpy, empty) => (
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<EditUser
|
||||
location={empty ? {} : { state: { username: "foo", has_admin: false } }}
|
||||
deleteUser={callbackSpy}
|
||||
editUser={callbackSpy}
|
||||
updateUsers={callbackSpy}
|
||||
history={{ push: () => {} }}
|
||||
failRegexEvent={callbackSpy}
|
||||
noChangeEvent={callbackSpy}
|
||||
/>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
var mockAppState = () => ({
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useDispatch.mockImplementation(() => {
|
||||
return () => {};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useDispatch.mockImplementation(() => {
|
||||
return () => {};
|
||||
});
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback(mockAppState());
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useDispatch.mockClear();
|
||||
});
|
||||
|
||||
it("Calls the delete user function when the button is pressed", () => {
|
||||
let callbackSpy = mockAsync(),
|
||||
component = mount(editUserJsx(callbackSpy)),
|
||||
deleteUser = component.find("#delete-user");
|
||||
deleteUser.simulate("click");
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Submits the edits when the button is pressed", () => {
|
||||
let callbackSpy = mockSync(),
|
||||
component = mount(editUserJsx(callbackSpy)),
|
||||
submit = component.find("#submit");
|
||||
submit.simulate("click");
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Doesn't render when no data is provided", () => {
|
||||
let callbackSpy = mockSync(),
|
||||
component = mount(editUserJsx(callbackSpy, true));
|
||||
expect(component.find(".container").length).toBe(0);
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback(mockAppState());
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useDispatch.mockClear();
|
||||
});
|
||||
|
||||
test("Renders", async () => {
|
||||
let callbackSpy = mockAsync({ key: "value", status: 200 });
|
||||
|
||||
await act(async () => {
|
||||
render(editUserJsx(callbackSpy));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("container")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Calls the delete user function when the button is pressed", async () => {
|
||||
let callbackSpy = mockAsync({ key: "value", status: 200 });
|
||||
|
||||
await act(async () => {
|
||||
render(editUserJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let deleteUser = screen.getByTestId("delete-user");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteUser);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Submits the edits when the button is pressed", async () => {
|
||||
let callbackSpy = mockAsync({ key: "value", status: 200 });
|
||||
|
||||
await act(async () => {
|
||||
render(editUserJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let submit = screen.getByTestId("submit");
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Shows a UI error dialogue when user edit fails", async () => {
|
||||
let callbackSpy = mockAsyncRejection();
|
||||
|
||||
await act(async () => {
|
||||
render(editUserJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let submit = screen.getByTestId("submit");
|
||||
let usernameInput = screen.getByTestId("edit-username-input");
|
||||
|
||||
fireEvent.blur(usernameInput, { target: { value: "whatever" } });
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText("Failed to edit user.");
|
||||
|
||||
expect(errorDialog).toBeVisible();
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Shows a UI error dialogue when user edit returns an improper status code", async () => {
|
||||
let callbackSpy = mockAsync({ status: 409 });
|
||||
|
||||
await act(async () => {
|
||||
render(editUserJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let submit = screen.getByTestId("submit");
|
||||
let usernameInput = screen.getByTestId("edit-username-input");
|
||||
|
||||
fireEvent.blur(usernameInput, { target: { value: "whatever" } });
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText("Failed to edit user.");
|
||||
|
||||
expect(errorDialog).toBeVisible();
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
@@ -7,6 +7,7 @@ import GroupSelect from "../GroupSelect/GroupSelect";
|
||||
const GroupEdit = (props) => {
|
||||
var [selected, setSelected] = useState([]),
|
||||
[changed, setChanged] = useState(false),
|
||||
[errorAlert, setErrorAlert] = useState(null),
|
||||
limit = useSelector((state) => state.limit);
|
||||
|
||||
var dispatch = useDispatch();
|
||||
@@ -41,7 +42,25 @@ const GroupEdit = (props) => {
|
||||
if (!group_data) return <div></div>;
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="container" data-testid="container">
|
||||
{errorAlert != null ? (
|
||||
<div className="row">
|
||||
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
||||
<div className="alert alert-danger">
|
||||
{errorAlert}
|
||||
<button
|
||||
type="button"
|
||||
className="close"
|
||||
onClick={() => setErrorAlert(null)}
|
||||
>
|
||||
<span>×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="row">
|
||||
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
||||
<h3>Editing Group {group_data.name}</h3>
|
||||
@@ -65,6 +84,7 @@ const GroupEdit = (props) => {
|
||||
<span> </span>
|
||||
<button
|
||||
id="submit"
|
||||
data-testid="submit"
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
// check for changes
|
||||
@@ -89,29 +109,43 @@ const GroupEdit = (props) => {
|
||||
);
|
||||
|
||||
Promise.all(promiseQueue)
|
||||
.then(() => {
|
||||
updateGroups(0, limit)
|
||||
.then((data) => dispatchPageUpdate(data, 0))
|
||||
.then(() => history.push("/groups"));
|
||||
.then((data) => {
|
||||
// ensure status of all requests are < 300
|
||||
let allPassed =
|
||||
data.map((e) => e.status).filter((e) => e >= 300).length ==
|
||||
0;
|
||||
|
||||
allPassed
|
||||
? updateGroups(0, limit)
|
||||
.then((data) => dispatchPageUpdate(data, 0))
|
||||
.then(() => history.push("/groups"))
|
||||
: setErrorAlert(`Failed to edit group.`);
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
.catch(() => {
|
||||
console.log("outer");
|
||||
setErrorAlert(`Failed to edit group.`);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
id="delete-group"
|
||||
data-testid="delete-group"
|
||||
className="btn btn-danger"
|
||||
style={{ float: "right" }}
|
||||
onClick={() => {
|
||||
var groupName = group_data.name;
|
||||
deleteGroup(groupName)
|
||||
.then(() => {
|
||||
updateGroups(0, limit)
|
||||
.then((data) => dispatchPageUpdate(data, 0))
|
||||
.then(() => history.push("/groups"));
|
||||
// TODO add error if res not ok
|
||||
.then((data) => {
|
||||
data.status < 300
|
||||
? updateGroups(0, limit)
|
||||
.then((data) => dispatchPageUpdate(data, 0))
|
||||
.then(() => history.push("/groups"))
|
||||
: setErrorAlert(`Failed to delete group.`);
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
.catch(() => setErrorAlert(`Failed to delete group.`));
|
||||
}}
|
||||
>
|
||||
Delete Group
|
||||
|
@@ -1,100 +1,228 @@
|
||||
import React from "react";
|
||||
import Enzyme, { mount } from "enzyme";
|
||||
import GroupEdit from "./GroupEdit";
|
||||
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Provider, useSelector } from "react-redux";
|
||||
import { createStore } from "redux";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line
|
||||
// eslint-disable-next-line
|
||||
import regeneratorRuntime from "regenerator-runtime";
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
import GroupEdit from "./GroupEdit";
|
||||
|
||||
jest.mock("react-redux", () => ({
|
||||
...jest.requireActual("react-redux"),
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("GroupEdit Component: ", () => {
|
||||
var mockAsync = () => jest.fn().mockImplementation(() => Promise.resolve());
|
||||
var mockAsync = (data) =>
|
||||
jest.fn().mockImplementation(() => Promise.resolve(data));
|
||||
|
||||
var okPacket = new Promise((resolve) => resolve(true));
|
||||
var mockAsyncRejection = () =>
|
||||
jest.fn().mockImplementation(() => Promise.reject());
|
||||
|
||||
var groupEditJsx = (callbackSpy) => (
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<GroupEdit
|
||||
location={{
|
||||
state: {
|
||||
group_data: { users: ["foo"], name: "group" },
|
||||
callback: () => {},
|
||||
},
|
||||
}}
|
||||
addToGroup={callbackSpy}
|
||||
removeFromGroup={callbackSpy}
|
||||
deleteGroup={callbackSpy}
|
||||
history={{ push: () => callbackSpy }}
|
||||
updateGroups={callbackSpy}
|
||||
validateUser={jest.fn().mockImplementation(() => okPacket)}
|
||||
/>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
var okPacket = new Promise((resolve) => resolve(true));
|
||||
|
||||
var mockAppState = () => ({
|
||||
limit: 3,
|
||||
});
|
||||
var groupEditJsx = (callbackSpy) => (
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<GroupEdit
|
||||
location={{
|
||||
state: {
|
||||
group_data: { users: ["foo"], name: "group" },
|
||||
callback: () => {},
|
||||
},
|
||||
}}
|
||||
addToGroup={callbackSpy}
|
||||
removeFromGroup={callbackSpy}
|
||||
deleteGroup={callbackSpy}
|
||||
history={{ push: () => callbackSpy }}
|
||||
updateGroups={callbackSpy}
|
||||
validateUser={jest.fn().mockImplementation(() => okPacket)}
|
||||
/>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback(mockAppState());
|
||||
});
|
||||
});
|
||||
var mockAppState = () => ({
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSelector.mockClear();
|
||||
});
|
||||
|
||||
it("Adds user from input to user selectables on button click", async () => {
|
||||
let callbackSpy = mockAsync(),
|
||||
component = mount(groupEditJsx(callbackSpy)),
|
||||
input = component.find("#username-input"),
|
||||
validateUser = component.find("#validate-user"),
|
||||
submit = component.find("#submit");
|
||||
|
||||
input.simulate("change", { target: { value: "bar" } });
|
||||
validateUser.simulate("click");
|
||||
await act(() => okPacket);
|
||||
submit.simulate("click");
|
||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["bar"], "group");
|
||||
});
|
||||
|
||||
it("Removes a user recently added from input from the selectables list", () => {
|
||||
let callbackSpy = mockAsync(),
|
||||
component = mount(groupEditJsx(callbackSpy)),
|
||||
unsubmittedUser = component.find(".item.selected").last();
|
||||
unsubmittedUser.simulate("click");
|
||||
expect(component.find(".item").length).toBe(1);
|
||||
});
|
||||
|
||||
it("Grays out a user, already in the group, when unselected and calls deleteUser on submit", () => {
|
||||
let callbackSpy = mockAsync(),
|
||||
component = mount(groupEditJsx(callbackSpy)),
|
||||
groupUser = component.find(".item.selected").first();
|
||||
groupUser.simulate("click");
|
||||
expect(component.find(".item.unselected").length).toBe(1);
|
||||
expect(component.find(".item").length).toBe(1);
|
||||
// test deleteUser call
|
||||
let submit = component.find("#submit");
|
||||
submit.simulate("click");
|
||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["foo"], "group");
|
||||
});
|
||||
|
||||
it("Calls deleteGroup on button click", () => {
|
||||
let callbackSpy = mockAsync(),
|
||||
component = mount(groupEditJsx(callbackSpy)),
|
||||
deleteGroup = component.find("#delete-group").first();
|
||||
deleteGroup.simulate("click");
|
||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, "group");
|
||||
beforeEach(() => {
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback(mockAppState());
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSelector.mockClear();
|
||||
});
|
||||
|
||||
test("Renders", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(groupEditJsx(callbackSpy));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("container")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Adds user from input to user selectables on button click", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(groupEditJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let input = screen.getByTestId("username-input");
|
||||
let validateUser = screen.getByTestId("validate-user");
|
||||
let submit = screen.getByTestId("submit");
|
||||
|
||||
userEvent.type(input, "bar");
|
||||
fireEvent.click(validateUser);
|
||||
await act(async () => okPacket);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["bar"], "group");
|
||||
});
|
||||
|
||||
test("Removes a user recently added from input from the selectables list", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(groupEditJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let selectedUser = screen.getByText("foo");
|
||||
fireEvent.click(selectedUser);
|
||||
|
||||
let unselectedUser = screen.getByText("foo");
|
||||
|
||||
expect(unselectedUser.className).toBe("item unselected");
|
||||
});
|
||||
|
||||
test("Grays out a user, already in the group, when unselected and calls deleteUser on submit", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(groupEditJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let submit = screen.getByTestId("submit");
|
||||
|
||||
let groupUser = screen.getByText("foo");
|
||||
fireEvent.click(groupUser);
|
||||
|
||||
let unselectedUser = screen.getByText("foo");
|
||||
expect(unselectedUser.className).toBe("item unselected");
|
||||
|
||||
// test deleteUser call
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["foo"], "group");
|
||||
});
|
||||
|
||||
test("Calls deleteGroup on button click", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(groupEditJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let deleteGroup = screen.getByTestId("delete-group");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteGroup);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, "group");
|
||||
});
|
||||
|
||||
test("Shows a UI error dialogue when group edit fails", async () => {
|
||||
let callbackSpy = mockAsyncRejection();
|
||||
|
||||
await act(async () => {
|
||||
render(groupEditJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let groupUser = screen.getByText("foo");
|
||||
fireEvent.click(groupUser);
|
||||
|
||||
let submit = screen.getByTestId("submit");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText("Failed to edit group.");
|
||||
|
||||
expect(errorDialog).toBeVisible();
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Shows a UI error dialogue when group edit returns an improper status code", async () => {
|
||||
let callbackSpy = mockAsync({ status: 403 });
|
||||
|
||||
await act(async () => {
|
||||
render(groupEditJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let groupUser = screen.getByText("foo");
|
||||
fireEvent.click(groupUser);
|
||||
|
||||
let submit = screen.getByTestId("submit");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText("Failed to edit group.");
|
||||
|
||||
expect(errorDialog).toBeVisible();
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Shows a UI error dialogue when group delete fails", async () => {
|
||||
let callbackSpy = mockAsyncRejection();
|
||||
|
||||
await act(async () => {
|
||||
render(groupEditJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let deleteGroup = screen.getByTestId("delete-group");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteGroup);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText("Failed to delete group.");
|
||||
|
||||
expect(errorDialog).toBeVisible();
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Shows a UI error dialogue when group delete returns an improper status code", async () => {
|
||||
let callbackSpy = mockAsync({ status: 403 });
|
||||
|
||||
await act(async () => {
|
||||
render(groupEditJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let deleteGroup = screen.getByTestId("delete-group");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteGroup);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText("Failed to delete group.");
|
||||
|
||||
expect(errorDialog).toBeVisible();
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
@@ -24,6 +24,7 @@ const GroupSelect = (props) => {
|
||||
<div className="input-group">
|
||||
<input
|
||||
id="username-input"
|
||||
data-testid="username-input"
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Add by username"
|
||||
@@ -35,6 +36,7 @@ const GroupSelect = (props) => {
|
||||
<span className="input-group-btn">
|
||||
<button
|
||||
id="validate-user"
|
||||
data-testid="validate-user"
|
||||
className="btn btn-default"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
@@ -19,7 +19,7 @@ const Groups = (props) => {
|
||||
var { updateGroups, history } = props;
|
||||
|
||||
if (!groups_data || !user_data) {
|
||||
return <div></div>;
|
||||
return <div data-testid="no-show"></div>;
|
||||
}
|
||||
|
||||
const dispatchPageChange = (data, page) => {
|
||||
@@ -39,7 +39,7 @@ const Groups = (props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="container" data-testid="container">
|
||||
<div className="row">
|
||||
<div className="col-md-12 col-lg-10 col-lg-offset-1">
|
||||
<div className="panel panel-default">
|
||||
|
@@ -1,12 +1,14 @@
|
||||
import React from "react";
|
||||
import Enzyme, { mount } from "enzyme";
|
||||
import Groups from "./Groups";
|
||||
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||
import { createStore } from "redux";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
// eslint-disable-next-line
|
||||
import regeneratorRuntime from "regenerator-runtime";
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
import Groups from "./Groups";
|
||||
|
||||
jest.mock("react-redux", () => ({
|
||||
...jest.requireActual("react-redux"),
|
||||
@@ -14,52 +16,75 @@ jest.mock("react-redux", () => ({
|
||||
useDispatch: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("Groups Component: ", () => {
|
||||
var mockAsync = () =>
|
||||
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
|
||||
var mockAsync = () =>
|
||||
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
|
||||
|
||||
var groupsJsx = (callbackSpy) => (
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<Groups location={{ search: "0" }} updateGroups={callbackSpy} />
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
var groupsJsx = (callbackSpy) => (
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<Groups location={{ search: "0" }} updateGroups={callbackSpy} />
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
var mockAppState = () => ({
|
||||
user_data: JSON.parse(
|
||||
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
|
||||
),
|
||||
groups_data: JSON.parse(
|
||||
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
|
||||
),
|
||||
var mockAppState = () => ({
|
||||
user_data: JSON.parse(
|
||||
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
|
||||
),
|
||||
groups_data: JSON.parse(
|
||||
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
|
||||
),
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback(mockAppState());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback(mockAppState());
|
||||
});
|
||||
useDispatch.mockImplementation(() => {
|
||||
return () => {};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSelector.mockClear();
|
||||
});
|
||||
|
||||
it("Renders groups_data prop into links", () => {
|
||||
let callbackSpy = mockAsync(),
|
||||
component = mount(groupsJsx(callbackSpy)),
|
||||
links = component.find("li");
|
||||
expect(links.length).toBe(2);
|
||||
});
|
||||
|
||||
it("Renders nothing if required data is not available", () => {
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback({});
|
||||
});
|
||||
let component = mount(groupsJsx());
|
||||
expect(component.html()).toBe("<div></div>");
|
||||
useDispatch.mockImplementation(() => {
|
||||
return () => {};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSelector.mockClear();
|
||||
});
|
||||
|
||||
test("Renders", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(groupsJsx(callbackSpy));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("container")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Renders groups_data prop into links", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(groupsJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let testgroup = screen.getByText("testgroup");
|
||||
let testgroup2 = screen.getByText("testgroup2");
|
||||
|
||||
expect(testgroup).toBeVisible();
|
||||
expect(testgroup2).toBeVisible();
|
||||
});
|
||||
|
||||
test("Renders nothing if required data is not available", async () => {
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback({});
|
||||
});
|
||||
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(groupsJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let noShow = screen.getByTestId("no-show");
|
||||
expect(noShow).toBeVisible();
|
||||
});
|
||||
|
@@ -1,8 +1,19 @@
|
||||
import React, { useState } from "react";
|
||||
import regeneratorRuntime from "regenerator-runtime";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import { Button } from "react-bootstrap";
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Row,
|
||||
FormControl,
|
||||
Card,
|
||||
CardGroup,
|
||||
Collapse,
|
||||
} from "react-bootstrap";
|
||||
import ReactObjectTableViewer from "react-object-table-viewer";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
||||
|
||||
@@ -10,7 +21,16 @@ import "./server-dashboard.css";
|
||||
import { timeSince } from "../../util/timeSince";
|
||||
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
||||
|
||||
const AccessServerButton = ({ url }) => (
|
||||
<a href={url || ""}>
|
||||
<button className="btn btn-primary btn-xs" style={{ marginRight: 20 }}>
|
||||
Access Server
|
||||
</button>
|
||||
</a>
|
||||
);
|
||||
|
||||
const ServerDashboard = (props) => {
|
||||
let base_url = window.base_url;
|
||||
// sort methods
|
||||
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
|
||||
usernameAsc = (e) => e.sort((a, b) => (a.name < b.name ? 1 : -1)),
|
||||
@@ -27,15 +47,19 @@ const ServerDashboard = (props) => {
|
||||
runningAsc = (e) => e.sort((a) => (a.server == null ? -1 : 1)),
|
||||
runningDesc = (e) => e.sort((a) => (a.server == null ? 1 : -1));
|
||||
|
||||
var [errorAlert, setErrorAlert] = useState(null);
|
||||
var [sortMethod, setSortMethod] = useState(null);
|
||||
var [disabledButtons, setDisabledButtons] = useState({});
|
||||
const [collapseStates, setCollapseStates] = useState({});
|
||||
|
||||
var user_data = useSelector((state) => state.user_data),
|
||||
user_page = useSelector((state) => state.user_page),
|
||||
limit = useSelector((state) => state.limit),
|
||||
name_filter = useSelector((state) => state.name_filter),
|
||||
page = parseInt(new URLSearchParams(props.location.search).get("page"));
|
||||
|
||||
page = isNaN(page) ? 0 : page;
|
||||
var slice = [page * limit, limit];
|
||||
var slice = [page * limit, limit, name_filter];
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -49,35 +73,304 @@ const ServerDashboard = (props) => {
|
||||
history,
|
||||
} = props;
|
||||
|
||||
var dispatchPageUpdate = (data, page) => {
|
||||
var dispatchPageUpdate = (data, page, name_filter) => {
|
||||
dispatch({
|
||||
type: "USER_PAGE",
|
||||
value: {
|
||||
data: data,
|
||||
page: page,
|
||||
name_filter: name_filter,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!user_data) {
|
||||
return <div></div>;
|
||||
return <div data-testid="no-show"></div>;
|
||||
}
|
||||
|
||||
if (page != user_page) {
|
||||
updateUsers(...slice).then((data) => dispatchPageUpdate(data, page));
|
||||
updateUsers(...slice).then((data) =>
|
||||
dispatchPageUpdate(data, page, name_filter)
|
||||
);
|
||||
}
|
||||
|
||||
var debounce = require("lodash.debounce");
|
||||
const handleSearch = debounce(async (event) => {
|
||||
// setNameFilter(event.target.value);
|
||||
updateUsers(page * limit, limit, event.target.value).then((data) =>
|
||||
dispatchPageUpdate(data, page, name_filter)
|
||||
);
|
||||
}, 300);
|
||||
|
||||
if (sortMethod != null) {
|
||||
user_data = sortMethod(user_data);
|
||||
}
|
||||
|
||||
const StopServerButton = ({ serverName, userName }) => {
|
||||
var [isDisabled, setIsDisabled] = useState(false);
|
||||
return (
|
||||
<button
|
||||
className="btn btn-danger btn-xs stop-button"
|
||||
disabled={isDisabled}
|
||||
onClick={() => {
|
||||
setIsDisabled(true);
|
||||
stopServer(userName, serverName)
|
||||
.then((res) => {
|
||||
if (res.status < 300) {
|
||||
updateUsers(...slice)
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data, page, name_filter);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsDisabled(false);
|
||||
setErrorAlert(`Failed to update users list.`);
|
||||
});
|
||||
} else {
|
||||
setErrorAlert(`Failed to stop server.`);
|
||||
setIsDisabled(false);
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorAlert(`Failed to stop server.`);
|
||||
setIsDisabled(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Stop Server
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const StartServerButton = ({ serverName, userName }) => {
|
||||
var [isDisabled, setIsDisabled] = useState(false);
|
||||
return (
|
||||
<button
|
||||
className="btn btn-success btn-xs start-button"
|
||||
disabled={isDisabled}
|
||||
onClick={() => {
|
||||
setIsDisabled(true);
|
||||
startServer(userName, serverName)
|
||||
.then((res) => {
|
||||
if (res.status < 300) {
|
||||
updateUsers(...slice)
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data, page, name_filter);
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorAlert(`Failed to update users list.`);
|
||||
setIsDisabled(false);
|
||||
});
|
||||
} else {
|
||||
setErrorAlert(`Failed to start server.`);
|
||||
setIsDisabled(false);
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorAlert(`Failed to start server.`);
|
||||
setIsDisabled(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Start Server
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const EditUserCell = ({ user }) => {
|
||||
return (
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-primary btn-xs"
|
||||
style={{ marginRight: 20 }}
|
||||
onClick={() =>
|
||||
history.push({
|
||||
pathname: "/edit-user",
|
||||
state: {
|
||||
username: user.name,
|
||||
has_admin: user.admin,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit User
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
};
|
||||
|
||||
const serverRow = (user, server) => {
|
||||
const { servers, ...userNoServers } = user;
|
||||
const serverNameDash = server.name ? `-${server.name}` : "";
|
||||
const userServerName = user.name + serverNameDash;
|
||||
const open = collapseStates[userServerName] || false;
|
||||
return [
|
||||
<tr key={`${userServerName}-row`} className="user-row">
|
||||
<td data-testid="user-row-name">
|
||||
<span>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setCollapseStates({
|
||||
...collapseStates,
|
||||
[userServerName]: !open,
|
||||
})
|
||||
}
|
||||
aria-controls={`${userServerName}-collapse`}
|
||||
aria-expanded={open}
|
||||
data-testid={`${userServerName}-collapse-button`}
|
||||
variant={open ? "secondary" : "primary"}
|
||||
size="sm"
|
||||
>
|
||||
<span className="caret"></span>
|
||||
</Button>{" "}
|
||||
</span>
|
||||
<span data-testid={`user-name-div-${userServerName}`}>
|
||||
{user.name}
|
||||
</span>
|
||||
</td>
|
||||
<td data-testid="user-row-admin">{user.admin ? "admin" : ""}</td>
|
||||
|
||||
<td data-testid="user-row-server">
|
||||
{server.name ? (
|
||||
<p className="text-secondary">{server.name}</p>
|
||||
) : (
|
||||
<p style={{ color: "lightgrey" }}>[MAIN]</p>
|
||||
)}
|
||||
</td>
|
||||
<td data-testid="user-row-last-activity">
|
||||
{server.last_activity ? timeSince(server.last_activity) : "Never"}
|
||||
</td>
|
||||
<td data-testid="user-row-server-activity">
|
||||
{server.started ? (
|
||||
// Stop Single-user server
|
||||
<>
|
||||
<StopServerButton serverName={server.name} userName={user.name} />
|
||||
<AccessServerButton url={server.url} />
|
||||
</>
|
||||
) : (
|
||||
// Start Single-user server
|
||||
<>
|
||||
<StartServerButton
|
||||
serverName={server.name}
|
||||
userName={user.name}
|
||||
style={{ marginRight: 20 }}
|
||||
/>
|
||||
<a
|
||||
href={`${base_url}spawn/${user.name}${
|
||||
server.name && "/" + server.name
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className="btn btn-secondary btn-xs"
|
||||
style={{ marginRight: 20 }}
|
||||
>
|
||||
Spawn Page
|
||||
</button>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<EditUserCell user={user} />
|
||||
</tr>,
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
style={{ padding: 0 }}
|
||||
data-testid={`${userServerName}-td`}
|
||||
>
|
||||
<Collapse in={open} data-testid={`${userServerName}-collapse`}>
|
||||
<CardGroup
|
||||
id={`${userServerName}-card-group`}
|
||||
style={{ width: "100%", margin: "0 auto", float: "none" }}
|
||||
>
|
||||
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
|
||||
<Card.Title>User</Card.Title>
|
||||
<ReactObjectTableViewer
|
||||
className="table-striped table-bordered admin-table-head"
|
||||
style={{
|
||||
padding: "3px 6px",
|
||||
margin: "auto",
|
||||
}}
|
||||
keyStyle={{
|
||||
padding: "4px",
|
||||
}}
|
||||
valueStyle={{
|
||||
padding: "4px",
|
||||
}}
|
||||
data={userNoServers}
|
||||
/>
|
||||
</Card>
|
||||
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
|
||||
<Card.Title>Server</Card.Title>
|
||||
<ReactObjectTableViewer
|
||||
className="table-striped table-bordered admin-table-head"
|
||||
style={{
|
||||
padding: "3px 6px",
|
||||
margin: "auto",
|
||||
}}
|
||||
keyStyle={{
|
||||
padding: "4px",
|
||||
}}
|
||||
valueStyle={{
|
||||
padding: "4px",
|
||||
}}
|
||||
data={server}
|
||||
/>
|
||||
</Card>
|
||||
</CardGroup>
|
||||
</Collapse>
|
||||
</td>
|
||||
</tr>,
|
||||
];
|
||||
};
|
||||
|
||||
let servers = user_data.flatMap((user) => {
|
||||
let userServers = Object.values({
|
||||
"": user.server || {},
|
||||
...(user.servers || {}),
|
||||
});
|
||||
return userServers.map((server) => [user, server]);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="manage-groups" style={{ float: "right", margin: "20px" }}>
|
||||
<Link to="/groups">{"> Manage Groups"}</Link>
|
||||
</div>
|
||||
<div className="container" data-testid="container">
|
||||
{errorAlert != null ? (
|
||||
<div className="row">
|
||||
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
||||
<div className="alert alert-danger">
|
||||
{errorAlert}
|
||||
<button
|
||||
type="button"
|
||||
className="close"
|
||||
onClick={() => setErrorAlert(null)}
|
||||
>
|
||||
<span>×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="server-dashboard-container">
|
||||
<table className="table table-striped table-bordered table-hover">
|
||||
<Row>
|
||||
<Col md={4}>
|
||||
<FormControl
|
||||
type="text"
|
||||
name="user_search"
|
||||
placeholder="Search users"
|
||||
aria-label="user-search"
|
||||
defaultValue={name_filter}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col md="auto" style={{ float: "right", margin: 15 }}>
|
||||
<Link to="/groups">{"> Manage Groups"}</Link>
|
||||
</Col>
|
||||
</Row>
|
||||
<table className="table table-bordered table-hover">
|
||||
<thead className="admin-table-head">
|
||||
<tr>
|
||||
<th id="user-header">
|
||||
@@ -85,6 +378,7 @@ const ServerDashboard = (props) => {
|
||||
<SortHandler
|
||||
sorts={{ asc: usernameAsc, desc: usernameDesc }}
|
||||
callback={(method) => setSortMethod(() => method)}
|
||||
testid="user-sort"
|
||||
/>
|
||||
</th>
|
||||
<th id="admin-header">
|
||||
@@ -92,6 +386,15 @@ const ServerDashboard = (props) => {
|
||||
<SortHandler
|
||||
sorts={{ asc: adminAsc, desc: adminDesc }}
|
||||
callback={(method) => setSortMethod(() => method)}
|
||||
testid="admin-sort"
|
||||
/>
|
||||
</th>
|
||||
<th id="server-header">
|
||||
Server{" "}
|
||||
<SortHandler
|
||||
sorts={{ asc: usernameAsc, desc: usernameDesc }}
|
||||
callback={(method) => setSortMethod(() => method)}
|
||||
testid="server-sort"
|
||||
/>
|
||||
</th>
|
||||
<th id="last-activity-header">
|
||||
@@ -99,6 +402,7 @@ const ServerDashboard = (props) => {
|
||||
<SortHandler
|
||||
sorts={{ asc: dateAsc, desc: dateDesc }}
|
||||
callback={(method) => setSortMethod(() => method)}
|
||||
testid="last-activity-sort"
|
||||
/>
|
||||
</th>
|
||||
<th id="running-status-header">
|
||||
@@ -106,6 +410,7 @@ const ServerDashboard = (props) => {
|
||||
<SortHandler
|
||||
sorts={{ asc: runningAsc, desc: runningDesc }}
|
||||
callback={(method) => setSortMethod(() => method)}
|
||||
testid="running-status-sort"
|
||||
/>
|
||||
</th>
|
||||
<th id="actions-header">Actions</th>
|
||||
@@ -125,17 +430,33 @@ const ServerDashboard = (props) => {
|
||||
<Button
|
||||
variant="primary"
|
||||
className="start-all"
|
||||
data-testid="start-all"
|
||||
onClick={() => {
|
||||
Promise.all(startAll(user_data.map((e) => e.name)))
|
||||
.then((res) => {
|
||||
updateUsers(...slice)
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data, page);
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
let failedServers = res.filter((e) => !e.ok);
|
||||
if (failedServers.length > 0) {
|
||||
setErrorAlert(
|
||||
`Failed to start ${failedServers.length} ${
|
||||
failedServers.length > 1 ? "servers" : "server"
|
||||
}. ${
|
||||
failedServers.length > 1 ? "Are they " : "Is it "
|
||||
} already running?`
|
||||
);
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
.then((res) => {
|
||||
updateUsers(...slice)
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data, page, name_filter);
|
||||
})
|
||||
.catch(() =>
|
||||
setErrorAlert(`Failed to update users list.`)
|
||||
);
|
||||
return res;
|
||||
})
|
||||
.catch(() => setErrorAlert(`Failed to start servers.`));
|
||||
}}
|
||||
>
|
||||
Start All
|
||||
@@ -145,17 +466,33 @@ const ServerDashboard = (props) => {
|
||||
<Button
|
||||
variant="danger"
|
||||
className="stop-all"
|
||||
data-testid="stop-all"
|
||||
onClick={() => {
|
||||
Promise.all(stopAll(user_data.map((e) => e.name)))
|
||||
.then((res) => {
|
||||
updateUsers(...slice)
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data, page);
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
let failedServers = res.filter((e) => !e.ok);
|
||||
if (failedServers.length > 0) {
|
||||
setErrorAlert(
|
||||
`Failed to stop ${failedServers.length} ${
|
||||
failedServers.length > 1 ? "servers" : "server"
|
||||
}. ${
|
||||
failedServers.length > 1 ? "Are they " : "Is it "
|
||||
} already stopped?`
|
||||
);
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
.then((res) => {
|
||||
updateUsers(...slice)
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data, page, name_filter);
|
||||
})
|
||||
.catch(() =>
|
||||
setErrorAlert(`Failed to update users list.`)
|
||||
);
|
||||
return res;
|
||||
})
|
||||
.catch(() => setErrorAlert(`Failed to stop servers.`));
|
||||
}}
|
||||
>
|
||||
Stop All
|
||||
@@ -172,70 +509,7 @@ const ServerDashboard = (props) => {
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{user_data.map((e, i) => (
|
||||
<tr key={i + "row"} className="user-row">
|
||||
<td>{e.name}</td>
|
||||
<td>{e.admin ? "admin" : ""}</td>
|
||||
<td>
|
||||
{e.last_activity ? timeSince(e.last_activity) : "Never"}
|
||||
</td>
|
||||
<td>
|
||||
{e.server != null ? (
|
||||
// Stop Single-user server
|
||||
<button
|
||||
className="btn btn-danger btn-xs stop-button"
|
||||
onClick={() =>
|
||||
stopServer(e.name)
|
||||
.then((res) => {
|
||||
updateUsers(...slice).then((data) => {
|
||||
dispatchPageUpdate(data, page);
|
||||
});
|
||||
return res;
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
}
|
||||
>
|
||||
Stop Server
|
||||
</button>
|
||||
) : (
|
||||
// Start Single-user server
|
||||
<button
|
||||
className="btn btn-primary btn-xs start-button"
|
||||
onClick={() =>
|
||||
startServer(e.name)
|
||||
.then((res) => {
|
||||
updateUsers(...slice).then((data) => {
|
||||
dispatchPageUpdate(data, page);
|
||||
});
|
||||
return res;
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
}
|
||||
>
|
||||
Start Server
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{/* Edit User */}
|
||||
<button
|
||||
className="btn btn-primary btn-xs"
|
||||
style={{ marginRight: 20 }}
|
||||
onClick={() =>
|
||||
history.push({
|
||||
pathname: "/edit-user",
|
||||
state: {
|
||||
username: e.name,
|
||||
has_admin: e.admin,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
edit user
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{servers.flatMap(([user, server]) => serverRow(user, server))}
|
||||
</tbody>
|
||||
</table>
|
||||
<PaginationFooter
|
||||
@@ -269,13 +543,14 @@ ServerDashboard.propTypes = {
|
||||
};
|
||||
|
||||
const SortHandler = (props) => {
|
||||
var { sorts, callback } = props;
|
||||
var { sorts, callback, testid } = props;
|
||||
|
||||
var [direction, setDirection] = useState(undefined);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="sort-icon"
|
||||
data-testid={testid}
|
||||
onClick={() => {
|
||||
if (!direction) {
|
||||
callback(sorts.desc);
|
||||
@@ -303,6 +578,7 @@ const SortHandler = (props) => {
|
||||
SortHandler.propTypes = {
|
||||
sorts: PropTypes.object,
|
||||
callback: PropTypes.func,
|
||||
testid: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ServerDashboard;
|
||||
|
@@ -1,161 +1,523 @@
|
||||
import React from "react";
|
||||
import Enzyme, { mount } from "enzyme";
|
||||
import ServerDashboard from "./ServerDashboard";
|
||||
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { HashRouter, Switch } from "react-router-dom";
|
||||
import { Provider, useSelector } from "react-redux";
|
||||
import { createStore } from "redux";
|
||||
// eslint-disable-next-line
|
||||
import regeneratorRuntime from "regenerator-runtime";
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
import ServerDashboard from "./ServerDashboard";
|
||||
import * as sinon from "sinon";
|
||||
|
||||
let clock;
|
||||
|
||||
jest.mock("react-redux", () => ({
|
||||
...jest.requireActual("react-redux"),
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("ServerDashboard Component: ", () => {
|
||||
var serverDashboardJsx = (callbackSpy) => (
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<Switch>
|
||||
<ServerDashboard
|
||||
updateUsers={callbackSpy}
|
||||
shutdownHub={callbackSpy}
|
||||
startServer={callbackSpy}
|
||||
stopServer={callbackSpy}
|
||||
startAll={callbackSpy}
|
||||
stopAll={callbackSpy}
|
||||
/>
|
||||
</Switch>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
var serverDashboardJsx = (spy) => (
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<Switch>
|
||||
<ServerDashboard
|
||||
updateUsers={spy}
|
||||
shutdownHub={spy}
|
||||
startServer={spy}
|
||||
stopServer={spy}
|
||||
startAll={spy}
|
||||
stopAll={spy}
|
||||
/>
|
||||
</Switch>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
var mockAsync = () =>
|
||||
jest
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ k: "v" }) })
|
||||
);
|
||||
var mockAsync = (data) =>
|
||||
jest.fn().mockImplementation(() => Promise.resolve(data ? data : { k: "v" }));
|
||||
|
||||
var mockAppState = () => ({
|
||||
user_data: JSON.parse(
|
||||
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
|
||||
),
|
||||
});
|
||||
var mockAsyncRejection = () =>
|
||||
jest.fn().mockImplementation(() => Promise.reject());
|
||||
|
||||
beforeEach(() => {
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback(mockAppState());
|
||||
});
|
||||
});
|
||||
var mockAppState = () => ({
|
||||
user_data: JSON.parse(
|
||||
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
|
||||
),
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSelector.mockClear();
|
||||
});
|
||||
|
||||
it("Renders users from props.user_data into table", () => {
|
||||
let component = mount(serverDashboardJsx(mockAsync())),
|
||||
userRows = component.find(".user-row");
|
||||
expect(userRows.length).toBe(2);
|
||||
});
|
||||
|
||||
it("Renders correctly the status of a single-user server", () => {
|
||||
let component = mount(serverDashboardJsx(mockAsync())),
|
||||
userRows = component.find(".user-row");
|
||||
// Renders .stop-button when server is started
|
||||
// Should be 1 since user foo is started
|
||||
expect(userRows.at(0).find(".stop-button").length).toBe(1);
|
||||
// Renders .start-button when server is stopped
|
||||
// Should be 1 since user bar is stopped
|
||||
expect(userRows.at(1).find(".start-button").length).toBe(1);
|
||||
});
|
||||
|
||||
it("Invokes the startServer event on button click", () => {
|
||||
let callbackSpy = mockAsync(),
|
||||
component = mount(serverDashboardJsx(callbackSpy)),
|
||||
startBtn = component.find(".start-button");
|
||||
startBtn.simulate("click");
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Invokes the stopServer event on button click", () => {
|
||||
let callbackSpy = mockAsync(),
|
||||
component = mount(serverDashboardJsx(callbackSpy)),
|
||||
stopBtn = component.find(".stop-button");
|
||||
stopBtn.simulate("click");
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Invokes the shutdownHub event on button click", () => {
|
||||
let callbackSpy = mockAsync(),
|
||||
component = mount(serverDashboardJsx(callbackSpy)),
|
||||
shutdownBtn = component.find("#shutdown-button").first();
|
||||
shutdownBtn.simulate("click");
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Sorts according to username", () => {
|
||||
let component = mount(serverDashboardJsx(mockAsync())).find(
|
||||
"ServerDashboard"
|
||||
),
|
||||
handler = component.find("SortHandler").first();
|
||||
handler.simulate("click");
|
||||
let first = component.find(".user-row").first();
|
||||
expect(first.html().includes("bar")).toBe(true);
|
||||
handler.simulate("click");
|
||||
first = component.find(".user-row").first();
|
||||
expect(first.html().includes("foo")).toBe(true);
|
||||
});
|
||||
|
||||
it("Sorts according to admin", () => {
|
||||
let component = mount(serverDashboardJsx(mockAsync())).find(
|
||||
"ServerDashboard"
|
||||
),
|
||||
handler = component.find("SortHandler").at(1);
|
||||
handler.simulate("click");
|
||||
let first = component.find(".user-row").first();
|
||||
expect(first.html().includes("admin")).toBe(true);
|
||||
handler.simulate("click");
|
||||
first = component.find(".user-row").first();
|
||||
expect(first.html().includes("admin")).toBe(false);
|
||||
});
|
||||
|
||||
it("Sorts according to last activity", () => {
|
||||
let component = mount(serverDashboardJsx(mockAsync())).find(
|
||||
"ServerDashboard"
|
||||
),
|
||||
handler = component.find("SortHandler").at(2);
|
||||
handler.simulate("click");
|
||||
let first = component.find(".user-row").first();
|
||||
// foo used most recently
|
||||
expect(first.html().includes("foo")).toBe(true);
|
||||
handler.simulate("click");
|
||||
first = component.find(".user-row").first();
|
||||
// invert sort - bar used least recently
|
||||
expect(first.html().includes("bar")).toBe(true);
|
||||
});
|
||||
|
||||
it("Sorts according to server status (running/not running)", () => {
|
||||
let component = mount(serverDashboardJsx(mockAsync())).find(
|
||||
"ServerDashboard"
|
||||
),
|
||||
handler = component.find("SortHandler").at(3);
|
||||
handler.simulate("click");
|
||||
let first = component.find(".user-row").first();
|
||||
// foo running
|
||||
expect(first.html().includes("foo")).toBe(true);
|
||||
handler.simulate("click");
|
||||
first = component.find(".user-row").first();
|
||||
// invert sort - bar not running
|
||||
expect(first.html().includes("bar")).toBe(true);
|
||||
});
|
||||
|
||||
it("Renders nothing if required data is not available", () => {
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback({});
|
||||
});
|
||||
let component = mount(serverDashboardJsx(jest.fn()));
|
||||
expect(component.html()).toBe("<div></div>");
|
||||
beforeEach(() => {
|
||||
clock = sinon.useFakeTimers();
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback(mockAppState());
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSelector.mockClear();
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
test("Renders", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx(callbackSpy));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("container")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Renders users from props.user_data into table", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let foo = screen.getByTestId("user-name-div-foo");
|
||||
let bar = screen.getByTestId("user-name-div-bar");
|
||||
|
||||
expect(foo).toBeVisible();
|
||||
expect(bar).toBeVisible();
|
||||
});
|
||||
|
||||
test("Renders correctly the status of a single-user server", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let start = screen.getByText("Start Server");
|
||||
let stop = screen.getByText("Stop Server");
|
||||
|
||||
expect(start).toBeVisible();
|
||||
expect(stop).toBeVisible();
|
||||
});
|
||||
|
||||
test("Invokes the startServer event on button click", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let start = screen.getByText("Start Server");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(start);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Invokes the stopServer event on button click", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let stop = screen.getByText("Stop Server");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(stop);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Invokes the shutdownHub event on button click", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let shutdown = screen.getByText("Shutdown Hub");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(shutdown);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Sorts according to username", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let handler = screen.getByTestId("user-sort");
|
||||
fireEvent.click(handler);
|
||||
|
||||
let first = screen.getAllByTestId("user-row-name")[0];
|
||||
expect(first.textContent).toContain("bar");
|
||||
|
||||
fireEvent.click(handler);
|
||||
|
||||
first = screen.getAllByTestId("user-row-name")[0];
|
||||
expect(first.textContent).toContain("foo");
|
||||
});
|
||||
|
||||
test("Sorts according to admin", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let handler = screen.getByTestId("admin-sort");
|
||||
fireEvent.click(handler);
|
||||
|
||||
let first = screen.getAllByTestId("user-row-admin")[0];
|
||||
expect(first.textContent).toBe("admin");
|
||||
|
||||
fireEvent.click(handler);
|
||||
|
||||
first = screen.getAllByTestId("user-row-admin")[0];
|
||||
expect(first.textContent).toBe("");
|
||||
});
|
||||
|
||||
test("Sorts according to last activity", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let handler = screen.getByTestId("last-activity-sort");
|
||||
fireEvent.click(handler);
|
||||
|
||||
let first = screen.getAllByTestId("user-row-name")[0];
|
||||
expect(first.textContent).toContain("foo");
|
||||
|
||||
fireEvent.click(handler);
|
||||
|
||||
first = screen.getAllByTestId("user-row-name")[0];
|
||||
expect(first.textContent).toContain("bar");
|
||||
});
|
||||
|
||||
test("Sorts according to server status (running/not running)", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let handler = screen.getByTestId("running-status-sort");
|
||||
fireEvent.click(handler);
|
||||
|
||||
let first = screen.getAllByTestId("user-row-name")[0];
|
||||
expect(first.textContent).toContain("foo");
|
||||
|
||||
fireEvent.click(handler);
|
||||
|
||||
first = screen.getAllByTestId("user-row-name")[0];
|
||||
expect(first.textContent).toContain("bar");
|
||||
});
|
||||
|
||||
test("Shows server details with button click", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx(callbackSpy));
|
||||
});
|
||||
let button = screen.getByTestId("foo-collapse-button");
|
||||
let collapse = screen.getByTestId("foo-collapse");
|
||||
let collapseBar = screen.getByTestId("bar-collapse");
|
||||
|
||||
// expect().toBeVisible does not work here with collapse.
|
||||
expect(collapse).toHaveClass("collapse");
|
||||
expect(collapse).not.toHaveClass("show");
|
||||
expect(collapseBar).not.toHaveClass("show");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(button);
|
||||
});
|
||||
clock.tick(400);
|
||||
|
||||
expect(collapse).toHaveClass("collapse show");
|
||||
expect(collapseBar).not.toHaveClass("show");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(button);
|
||||
});
|
||||
clock.tick(400);
|
||||
|
||||
expect(collapse).toHaveClass("collapse");
|
||||
expect(collapse).not.toHaveClass("show");
|
||||
expect(collapseBar).not.toHaveClass("show");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(button);
|
||||
});
|
||||
clock.tick(400);
|
||||
|
||||
expect(collapse).toHaveClass("collapse show");
|
||||
expect(collapseBar).not.toHaveClass("show");
|
||||
});
|
||||
|
||||
test("Renders nothing if required data is not available", async () => {
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback({});
|
||||
});
|
||||
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let noShow = screen.getByTestId("no-show");
|
||||
|
||||
expect(noShow).toBeVisible();
|
||||
});
|
||||
|
||||
test("Shows a UI error dialogue when start all servers fails", async () => {
|
||||
let spy = mockAsync();
|
||||
let rejectSpy = mockAsyncRejection;
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<Switch>
|
||||
<ServerDashboard
|
||||
updateUsers={spy}
|
||||
shutdownHub={spy}
|
||||
startServer={spy}
|
||||
stopServer={spy}
|
||||
startAll={rejectSpy}
|
||||
stopAll={spy}
|
||||
/>
|
||||
</Switch>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
});
|
||||
|
||||
let startAll = screen.getByTestId("start-all");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(startAll);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText("Failed to start servers.");
|
||||
|
||||
expect(errorDialog).toBeVisible();
|
||||
});
|
||||
|
||||
test("Shows a UI error dialogue when stop all servers fails", async () => {
|
||||
let spy = mockAsync();
|
||||
let rejectSpy = mockAsyncRejection;
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<Switch>
|
||||
<ServerDashboard
|
||||
updateUsers={spy}
|
||||
shutdownHub={spy}
|
||||
startServer={spy}
|
||||
stopServer={spy}
|
||||
startAll={spy}
|
||||
stopAll={rejectSpy}
|
||||
/>
|
||||
</Switch>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
});
|
||||
|
||||
let stopAll = screen.getByTestId("stop-all");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(stopAll);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText("Failed to stop servers.");
|
||||
|
||||
expect(errorDialog).toBeVisible();
|
||||
});
|
||||
|
||||
test("Shows a UI error dialogue when start user server fails", async () => {
|
||||
let spy = mockAsync();
|
||||
let rejectSpy = mockAsyncRejection();
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<Switch>
|
||||
<ServerDashboard
|
||||
updateUsers={spy}
|
||||
shutdownHub={spy}
|
||||
startServer={rejectSpy}
|
||||
stopServer={spy}
|
||||
startAll={spy}
|
||||
stopAll={spy}
|
||||
/>
|
||||
</Switch>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
});
|
||||
|
||||
let start = screen.getByText("Start Server");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(start);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText("Failed to start server.");
|
||||
|
||||
expect(errorDialog).toBeVisible();
|
||||
});
|
||||
|
||||
test("Shows a UI error dialogue when start user server returns an improper status code", async () => {
|
||||
let spy = mockAsync();
|
||||
let rejectSpy = mockAsync({ status: 403 });
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<Switch>
|
||||
<ServerDashboard
|
||||
updateUsers={spy}
|
||||
shutdownHub={spy}
|
||||
startServer={rejectSpy}
|
||||
stopServer={spy}
|
||||
startAll={spy}
|
||||
stopAll={spy}
|
||||
/>
|
||||
</Switch>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
});
|
||||
|
||||
let start = screen.getByText("Start Server");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(start);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText("Failed to start server.");
|
||||
|
||||
expect(errorDialog).toBeVisible();
|
||||
});
|
||||
|
||||
test("Shows a UI error dialogue when stop user servers fails", async () => {
|
||||
let spy = mockAsync();
|
||||
let rejectSpy = mockAsyncRejection();
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<Switch>
|
||||
<ServerDashboard
|
||||
updateUsers={spy}
|
||||
shutdownHub={spy}
|
||||
startServer={spy}
|
||||
stopServer={rejectSpy}
|
||||
startAll={spy}
|
||||
stopAll={spy}
|
||||
/>
|
||||
</Switch>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
});
|
||||
|
||||
let stop = screen.getByText("Stop Server");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(stop);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText("Failed to stop server.");
|
||||
|
||||
expect(errorDialog).toBeVisible();
|
||||
});
|
||||
|
||||
test("Shows a UI error dialogue when stop user server returns an improper status code", async () => {
|
||||
let spy = mockAsync();
|
||||
let rejectSpy = mockAsync({ status: 403 });
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<Switch>
|
||||
<ServerDashboard
|
||||
updateUsers={spy}
|
||||
shutdownHub={spy}
|
||||
startServer={spy}
|
||||
stopServer={rejectSpy}
|
||||
startAll={spy}
|
||||
stopAll={spy}
|
||||
/>
|
||||
</Switch>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
});
|
||||
|
||||
let stop = screen.getByText("Stop Server");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(stop);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText("Failed to stop server.");
|
||||
|
||||
expect(errorDialog).toBeVisible();
|
||||
});
|
||||
|
||||
test("Search for user calls updateUsers with name filter", async () => {
|
||||
let spy = mockAsync();
|
||||
let mockUpdateUsers = jest.fn((offset, limit, name_filter) => {
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
await act(async () => {
|
||||
render(
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<HashRouter>
|
||||
<Switch>
|
||||
<ServerDashboard
|
||||
updateUsers={mockUpdateUsers}
|
||||
shutdownHub={spy}
|
||||
startServer={spy}
|
||||
stopServer={spy}
|
||||
startAll={spy}
|
||||
stopAll={spy}
|
||||
/>
|
||||
</Switch>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
});
|
||||
|
||||
let search = screen.getByLabelText("user-search");
|
||||
|
||||
userEvent.type(search, "a");
|
||||
expect(search.value).toEqual("a");
|
||||
clock.tick(400);
|
||||
expect(mockUpdateUsers.mock.calls[1][2]).toEqual("a");
|
||||
expect(mockUpdateUsers.mock.calls).toHaveLength(2);
|
||||
|
||||
userEvent.type(search, "b");
|
||||
expect(search.value).toEqual("ab");
|
||||
clock.tick(400);
|
||||
expect(mockUpdateUsers.mock.calls[2][2]).toEqual("ab");
|
||||
expect(mockUpdateUsers.mock.calls).toHaveLength(3);
|
||||
});
|
||||
|
@@ -1,5 +1,7 @@
|
||||
export const jhapiRequest = (endpoint, method, data) => {
|
||||
return fetch("/hub/api" + endpoint, {
|
||||
let base_url = window.base_url,
|
||||
api_url = `${base_url}hub/api`;
|
||||
return fetch(api_url + endpoint, {
|
||||
method: method,
|
||||
json: true,
|
||||
headers: {
|
||||
|
@@ -2,17 +2,20 @@ import { withProps } from "recompose";
|
||||
import { jhapiRequest } from "./jhapiUtil";
|
||||
|
||||
const withAPI = withProps(() => ({
|
||||
updateUsers: (offset, limit) =>
|
||||
jhapiRequest(`/users?offset=${offset}&limit=${limit}`, "GET").then((data) =>
|
||||
data.json()
|
||||
),
|
||||
updateUsers: (offset, limit, name_filter) =>
|
||||
jhapiRequest(
|
||||
`/users?offset=${offset}&limit=${limit}&name_filter=${name_filter || ""}`,
|
||||
"GET"
|
||||
).then((data) => data.json()),
|
||||
updateGroups: (offset, limit) =>
|
||||
jhapiRequest(`/groups?offset=${offset}&limit=${limit}`, "GET").then(
|
||||
(data) => data.json()
|
||||
),
|
||||
shutdownHub: () => jhapiRequest("/shutdown", "POST"),
|
||||
startServer: (name) => jhapiRequest("/users/" + name + "/server", "POST"),
|
||||
stopServer: (name) => jhapiRequest("/users/" + name + "/server", "DELETE"),
|
||||
startServer: (name, serverName = "") =>
|
||||
jhapiRequest("/users/" + name + "/servers/" + (serverName || ""), "POST"),
|
||||
stopServer: (name, serverName = "") =>
|
||||
jhapiRequest("/users/" + name + "/servers/" + (serverName || ""), "DELETE"),
|
||||
startAll: (names) =>
|
||||
names.map((e) => jhapiRequest("/users/" + e + "/server", "POST")),
|
||||
stopAll: (names) =>
|
||||
@@ -36,13 +39,14 @@ const withAPI = withProps(() => ({
|
||||
jhapiRequest("/users/" + username, "GET")
|
||||
.then((data) => data.status)
|
||||
.then((data) => (data > 200 ? false : true)),
|
||||
failRegexEvent: () =>
|
||||
alert(
|
||||
"Cannot change username - either contains special characters or is too short."
|
||||
),
|
||||
noChangeEvent: () => {
|
||||
returns;
|
||||
// Temporarily Unused
|
||||
failRegexEvent: () => {
|
||||
return null;
|
||||
},
|
||||
noChangeEvent: () => {
|
||||
return null;
|
||||
},
|
||||
//
|
||||
refreshGroupsData: () =>
|
||||
jhapiRequest("/groups", "GET").then((data) => data.json()),
|
||||
refreshUserData: () =>
|
||||
|
4999
jsx/yarn.lock
4999
jsx/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
# version_info updated by running `tbump`
|
||||
version_info = (2, 0, 0, "rc5", "")
|
||||
version_info = (2, 3, 0, "", "")
|
||||
|
||||
# pep 440 version: no dot before beta/rc, but before .dev
|
||||
# 0.1.0rc1
|
||||
|
@@ -55,8 +55,15 @@ def run_migrations_offline():
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
connectable = config.attributes.get('connection', None)
|
||||
|
||||
if connectable is None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
else:
|
||||
context.configure(
|
||||
connection=connectable, target_metadata=target_metadata, literal_binds=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
@@ -69,11 +76,14 @@ def run_migrations_online():
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
connectable = config.attributes.get('connection', None)
|
||||
|
||||
if connectable is None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
@@ -16,6 +16,7 @@ from tornado import web
|
||||
from .. import orm
|
||||
from .. import roles
|
||||
from .. import scopes
|
||||
from ..utils import get_browser_protocol
|
||||
from ..utils import token_authenticated
|
||||
from .base import APIHandler
|
||||
from .base import BaseHandler
|
||||
@@ -115,7 +116,10 @@ class OAuthHandler:
|
||||
# make absolute local redirects full URLs
|
||||
# to satisfy oauthlib's absolute URI requirement
|
||||
redirect_uri = (
|
||||
self.request.protocol + "://" + self.request.headers['Host'] + redirect_uri
|
||||
get_browser_protocol(self.request)
|
||||
+ "://"
|
||||
+ self.request.host
|
||||
+ redirect_uri
|
||||
)
|
||||
parsed_url = urlparse(uri)
|
||||
query_list = parse_qsl(parsed_url.query, keep_blank_values=True)
|
||||
|
@@ -14,6 +14,7 @@ from tornado import web
|
||||
|
||||
from .. import orm
|
||||
from ..handlers import BaseHandler
|
||||
from ..utils import get_browser_protocol
|
||||
from ..utils import isoformat
|
||||
from ..utils import url_path_join
|
||||
|
||||
@@ -58,7 +59,10 @@ class APIHandler(BaseHandler):
|
||||
|
||||
- allow unspecified host/referer (e.g. scripts)
|
||||
"""
|
||||
host = self.request.headers.get("Host")
|
||||
host_header = self.app.forwarded_host_header or "Host"
|
||||
host = self.request.headers.get(host_header)
|
||||
if host and "," in host:
|
||||
host = host.split(",", 1)[0].strip()
|
||||
referer = self.request.headers.get("Referer")
|
||||
|
||||
# If no header is provided, assume it comes from a script/curl.
|
||||
@@ -70,13 +74,25 @@ class APIHandler(BaseHandler):
|
||||
self.log.warning("Blocking API request with no referer")
|
||||
return False
|
||||
|
||||
host_path = url_path_join(host, self.hub.base_url)
|
||||
referer_path = referer.split('://', 1)[-1]
|
||||
if not (referer_path + '/').startswith(host_path):
|
||||
proto = get_browser_protocol(self.request)
|
||||
|
||||
full_host = f"{proto}://{host}{self.hub.base_url}"
|
||||
host_url = urlparse(full_host)
|
||||
referer_url = urlparse(referer)
|
||||
# resolve default ports for http[s]
|
||||
referer_port = referer_url.port or (
|
||||
443 if referer_url.scheme == 'https' else 80
|
||||
)
|
||||
host_port = host_url.port or (443 if host_url.scheme == 'https' else 80)
|
||||
if (
|
||||
referer_url.scheme != host_url.scheme
|
||||
or referer_url.hostname != host_url.hostname
|
||||
or referer_port != host_port
|
||||
or not (referer_url.path + "/").startswith(host_url.path)
|
||||
):
|
||||
self.log.warning(
|
||||
"Blocking Cross Origin API request. Referer: %s, Host: %s",
|
||||
referer,
|
||||
host_path,
|
||||
f"Blocking Cross Origin API request. Referer: {referer},"
|
||||
f" {host_header}: {host}, Host URL: {full_host}",
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
@@ -33,6 +33,11 @@ class _GroupAPIHandler(APIHandler):
|
||||
raise web.HTTPError(404, "No such group: %s", group_name)
|
||||
return group
|
||||
|
||||
def check_authenticator_managed_groups(self):
|
||||
"""Raise error on group-management APIs if Authenticator is managing groups"""
|
||||
if self.authenticator.manage_groups:
|
||||
raise web.HTTPError(400, "Group management via API is disabled")
|
||||
|
||||
|
||||
class GroupListAPIHandler(_GroupAPIHandler):
|
||||
@needs_scope('list:groups')
|
||||
@@ -45,7 +50,7 @@ class GroupListAPIHandler(_GroupAPIHandler):
|
||||
# the only valid filter is group=...
|
||||
# don't expand invalid !server=x to all groups!
|
||||
self.log.warning(
|
||||
"Invalid filter on list:group for {self.current_user}: {sub_scope}"
|
||||
f"Invalid filter on list:group for {self.current_user}: {sub_scope}"
|
||||
)
|
||||
raise web.HTTPError(403)
|
||||
query = query.filter(orm.Group.name.in_(sub_scope['group']))
|
||||
@@ -68,6 +73,9 @@ class GroupListAPIHandler(_GroupAPIHandler):
|
||||
@needs_scope('admin:groups')
|
||||
async def post(self):
|
||||
"""POST creates Multiple groups"""
|
||||
|
||||
self.check_authenticator_managed_groups()
|
||||
|
||||
model = self.get_json_body()
|
||||
if not model or not isinstance(model, dict) or not model.get('groups'):
|
||||
raise web.HTTPError(400, "Must specify at least one group to create")
|
||||
@@ -106,6 +114,7 @@ class GroupAPIHandler(_GroupAPIHandler):
|
||||
@needs_scope('admin:groups')
|
||||
async def post(self, group_name):
|
||||
"""POST creates a group by name"""
|
||||
self.check_authenticator_managed_groups()
|
||||
model = self.get_json_body()
|
||||
if model is None:
|
||||
model = {}
|
||||
@@ -132,6 +141,7 @@ class GroupAPIHandler(_GroupAPIHandler):
|
||||
@needs_scope('delete:groups')
|
||||
def delete(self, group_name):
|
||||
"""Delete a group by name"""
|
||||
self.check_authenticator_managed_groups()
|
||||
group = self.find_group(group_name)
|
||||
self.log.info("Deleting group %s", group_name)
|
||||
self.db.delete(group)
|
||||
@@ -145,6 +155,7 @@ class GroupUsersAPIHandler(_GroupAPIHandler):
|
||||
@needs_scope('groups')
|
||||
def post(self, group_name):
|
||||
"""POST adds users to a group"""
|
||||
self.check_authenticator_managed_groups()
|
||||
group = self.find_group(group_name)
|
||||
data = self.get_json_body()
|
||||
self._check_group_model(data)
|
||||
@@ -163,6 +174,7 @@ class GroupUsersAPIHandler(_GroupAPIHandler):
|
||||
@needs_scope('groups')
|
||||
async def delete(self, group_name):
|
||||
"""DELETE removes users from a group"""
|
||||
self.check_authenticator_managed_groups()
|
||||
group = self.find_group(group_name)
|
||||
data = self.get_json_body()
|
||||
self._check_group_model(data)
|
||||
|
@@ -47,9 +47,8 @@ class ShutdownAPIHandler(APIHandler):
|
||||
self.set_status(202)
|
||||
self.finish(json.dumps({"message": "Shutting down Hub"}))
|
||||
|
||||
# stop the eventloop, which will trigger cleanup
|
||||
loop = IOLoop.current()
|
||||
loop.add_callback(loop.stop)
|
||||
# instruct the app to stop, which will trigger cleanup
|
||||
app.stop()
|
||||
|
||||
|
||||
class RootAPIHandler(APIHandler):
|
||||
|
@@ -84,6 +84,7 @@ class UserListAPIHandler(APIHandler):
|
||||
@needs_scope('list:users')
|
||||
def get(self):
|
||||
state_filter = self.get_argument("state", None)
|
||||
name_filter = self.get_argument("name_filter", None)
|
||||
offset, limit = self.get_api_pagination()
|
||||
|
||||
# post_filter
|
||||
@@ -130,7 +131,7 @@ class UserListAPIHandler(APIHandler):
|
||||
if not set(sub_scope).issubset({'group', 'user'}):
|
||||
# don't expand invalid !server=x filter to all users!
|
||||
self.log.warning(
|
||||
"Invalid filter on list:user for {self.current_user}: {sub_scope}"
|
||||
f"Invalid filter on list:user for {self.current_user}: {sub_scope}"
|
||||
)
|
||||
raise web.HTTPError(403)
|
||||
filters = []
|
||||
@@ -148,6 +149,9 @@ class UserListAPIHandler(APIHandler):
|
||||
else:
|
||||
query = query.filter(or_(*filters))
|
||||
|
||||
if name_filter:
|
||||
query = query.filter(orm.User.name.ilike(f'%{name_filter}%'))
|
||||
|
||||
full_query = query
|
||||
query = query.order_by(orm.User.id.asc()).offset(offset).limit(limit)
|
||||
|
||||
@@ -515,7 +519,7 @@ class UserServerAPIHandler(APIHandler):
|
||||
user_name, self.named_server_limit_per_user
|
||||
),
|
||||
)
|
||||
spawner = user.spawners[server_name]
|
||||
spawner = user.get_spawner(server_name, replace_failed=True)
|
||||
pending = spawner.pending
|
||||
if pending == 'spawn':
|
||||
self.set_header('Content-Type', 'text/plain')
|
||||
@@ -714,7 +718,12 @@ class SpawnProgressAPIHandler(APIHandler):
|
||||
# check if spawner has just failed
|
||||
f = spawn_future
|
||||
if f and f.done() and f.exception():
|
||||
failed_event['message'] = "Spawn failed: %s" % f.exception()
|
||||
exc = f.exception()
|
||||
message = getattr(exc, "jupyterhub_message", str(exc))
|
||||
failed_event['message'] = f"Spawn failed: {message}"
|
||||
html_message = getattr(exc, "jupyterhub_html_message", "")
|
||||
if html_message:
|
||||
failed_event['html_message'] = html_message
|
||||
await self.send_event(failed_event)
|
||||
return
|
||||
else:
|
||||
@@ -747,7 +756,12 @@ class SpawnProgressAPIHandler(APIHandler):
|
||||
# what happened? Maybe spawn failed?
|
||||
f = spawn_future
|
||||
if f and f.done() and f.exception():
|
||||
failed_event['message'] = "Spawn failed: %s" % f.exception()
|
||||
exc = f.exception()
|
||||
message = getattr(exc, "jupyterhub_message", str(exc))
|
||||
failed_event['message'] = f"Spawn failed: {message}"
|
||||
html_message = getattr(exc, "jupyterhub_html_message", "")
|
||||
if html_message:
|
||||
failed_event['html_message'] = html_message
|
||||
else:
|
||||
self.log.warning(
|
||||
"Server %s didn't start for unknown reason", spawner._log_name
|
||||
|
@@ -791,6 +791,16 @@ class JupyterHub(Application):
|
||||
self.proxy_api_ip or '127.0.0.1', self.proxy_api_port or self.port + 1
|
||||
)
|
||||
|
||||
forwarded_host_header = Unicode(
|
||||
'',
|
||||
help="""Alternate header to use as the Host (e.g., X-Forwarded-Host)
|
||||
when determining whether a request is cross-origin
|
||||
|
||||
This may be useful when JupyterHub is running behind a proxy that rewrites
|
||||
the Host header.
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
hub_port = Integer(
|
||||
8081,
|
||||
help="""The internal port for the Hub process.
|
||||
@@ -1893,6 +1903,7 @@ class JupyterHub(Application):
|
||||
user = orm.User.find(db, name)
|
||||
if user is None:
|
||||
user = orm.User(name=name, admin=True)
|
||||
roles.assign_default_roles(self.db, entity=user)
|
||||
new_users.append(user)
|
||||
db.add(user)
|
||||
else:
|
||||
@@ -1983,12 +1994,16 @@ class JupyterHub(Application):
|
||||
self.log.info(f"Creating user {username}")
|
||||
user = orm.User(name=username)
|
||||
self.db.add(user)
|
||||
roles.assign_default_roles(self.db, entity=user)
|
||||
self.db.commit()
|
||||
return user
|
||||
|
||||
async def init_groups(self):
|
||||
"""Load predefined groups into the database"""
|
||||
db = self.db
|
||||
|
||||
if self.authenticator.manage_groups and self.load_groups:
|
||||
raise ValueError("Group management has been offloaded to the authenticator")
|
||||
for name, usernames in self.load_groups.items():
|
||||
group = orm.Group.find(db, name)
|
||||
if group is None:
|
||||
@@ -2004,14 +2019,25 @@ class JupyterHub(Application):
|
||||
|
||||
async def init_role_creation(self):
|
||||
"""Load default and predefined roles into the database"""
|
||||
self.log.debug('Loading default roles to database')
|
||||
self.log.debug('Loading roles into database')
|
||||
default_roles = roles.get_default_roles()
|
||||
config_role_names = [r['name'] for r in self.load_roles]
|
||||
|
||||
init_roles = default_roles
|
||||
default_roles_dict = {role["name"]: role for role in default_roles}
|
||||
init_roles = []
|
||||
roles_with_new_permissions = []
|
||||
for role_spec in self.load_roles:
|
||||
role_name = role_spec['name']
|
||||
if role_name in default_roles_dict:
|
||||
self.log.debug(f"Overriding default role {role_name}")
|
||||
# merge custom role spec with default role spec when overriding
|
||||
# so the new role can be partially defined
|
||||
default_role_spec = default_roles_dict.pop(role_name)
|
||||
merged_role_spec = {}
|
||||
merged_role_spec.update(default_role_spec)
|
||||
merged_role_spec.update(role_spec)
|
||||
role_spec = merged_role_spec
|
||||
|
||||
# Check for duplicates
|
||||
if config_role_names.count(role_name) > 1:
|
||||
raise ValueError(
|
||||
@@ -2022,10 +2048,13 @@ class JupyterHub(Application):
|
||||
old_role = orm.Role.find(self.db, name=role_name)
|
||||
if old_role:
|
||||
if not set(role_spec['scopes']).issubset(old_role.scopes):
|
||||
app_log.warning(
|
||||
self.log.warning(
|
||||
"Role %s has obtained extra permissions" % role_name
|
||||
)
|
||||
roles_with_new_permissions.append(role_name)
|
||||
|
||||
# make sure we load any default roles not overridden
|
||||
init_roles = list(default_roles_dict.values()) + init_roles
|
||||
if roles_with_new_permissions:
|
||||
unauthorized_oauth_tokens = (
|
||||
self.db.query(orm.APIToken)
|
||||
@@ -2037,7 +2066,7 @@ class JupyterHub(Application):
|
||||
.filter(orm.APIToken.client_id != 'jupyterhub')
|
||||
)
|
||||
for token in unauthorized_oauth_tokens:
|
||||
app_log.warning(
|
||||
self.log.warning(
|
||||
"Deleting OAuth token %s; one of its roles obtained new permissions that were not authorized by user"
|
||||
% token
|
||||
)
|
||||
@@ -2045,14 +2074,19 @@ class JupyterHub(Application):
|
||||
self.db.commit()
|
||||
|
||||
init_role_names = [r['name'] for r in init_roles]
|
||||
if not orm.Role.find(self.db, name='admin'):
|
||||
if (
|
||||
self.db.query(orm.Role).first() is None
|
||||
and self.db.query(orm.User).first() is not None
|
||||
):
|
||||
# apply rbac-upgrade default role assignment if there are users in the db,
|
||||
# but not any roles
|
||||
self._rbac_upgrade = True
|
||||
else:
|
||||
self._rbac_upgrade = False
|
||||
for role in self.db.query(orm.Role).filter(
|
||||
orm.Role.name.notin_(init_role_names)
|
||||
):
|
||||
app_log.info(f"Deleting role {role.name}")
|
||||
self.log.warning(f"Deleting role {role.name}")
|
||||
self.db.delete(role)
|
||||
self.db.commit()
|
||||
for role in init_roles:
|
||||
@@ -2068,71 +2102,89 @@ class JupyterHub(Application):
|
||||
if config_admin_users:
|
||||
for role_spec in self.load_roles:
|
||||
if role_spec['name'] == 'admin':
|
||||
app_log.warning(
|
||||
self.log.warning(
|
||||
"Configuration specifies both admin_users and users in the admin role specification. "
|
||||
"If admin role is present in config, c.Authenticator.admin_users should not be used."
|
||||
)
|
||||
app_log.info(
|
||||
self.log.info(
|
||||
"Merging admin_users set with users list in admin role"
|
||||
)
|
||||
role_spec['users'] = set(role_spec.get('users', []))
|
||||
role_spec['users'] |= config_admin_users
|
||||
self.log.debug('Loading predefined roles from config file to database')
|
||||
self.log.debug('Loading role assignments from config')
|
||||
has_admin_role_spec = {role_bearer: False for role_bearer in admin_role_objects}
|
||||
for predef_role in self.load_roles:
|
||||
predef_role_obj = orm.Role.find(db, name=predef_role['name'])
|
||||
if predef_role['name'] == 'admin':
|
||||
for role_spec in self.load_roles:
|
||||
role = orm.Role.find(db, name=role_spec['name'])
|
||||
role_name = role_spec["name"]
|
||||
if role_name == 'admin':
|
||||
for kind in admin_role_objects:
|
||||
has_admin_role_spec[kind] = kind in predef_role
|
||||
has_admin_role_spec[kind] = kind in role_spec
|
||||
if has_admin_role_spec[kind]:
|
||||
app_log.info(f"Admin role specifies static {kind} list")
|
||||
self.log.info(f"Admin role specifies static {kind} list")
|
||||
else:
|
||||
app_log.info(
|
||||
self.log.info(
|
||||
f"Admin role does not specify {kind}, preserving admin membership in database"
|
||||
)
|
||||
# add users, services, and/or groups,
|
||||
# tokens need to be checked for permissions
|
||||
for kind in kinds:
|
||||
orm_role_bearers = []
|
||||
if kind in predef_role.keys():
|
||||
for bname in predef_role[kind]:
|
||||
if kind in role_spec:
|
||||
for name in role_spec[kind]:
|
||||
if kind == 'users':
|
||||
bname = self.authenticator.normalize_username(bname)
|
||||
name = self.authenticator.normalize_username(name)
|
||||
if not (
|
||||
await maybe_future(
|
||||
self.authenticator.check_allowed(bname, None)
|
||||
self.authenticator.check_allowed(name, None)
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
"Username %r is not in Authenticator.allowed_users"
|
||||
% bname
|
||||
f"Username {name} is not in Authenticator.allowed_users"
|
||||
)
|
||||
Class = orm.get_class(kind)
|
||||
orm_obj = Class.find(db, bname)
|
||||
orm_obj = Class.find(db, name)
|
||||
if orm_obj is not None:
|
||||
orm_role_bearers.append(orm_obj)
|
||||
else:
|
||||
app_log.info(
|
||||
f"Found unexisting {kind} {bname} in role definition {predef_role['name']}"
|
||||
self.log.info(
|
||||
f"Found unexisting {kind} {name} in role definition {role_name}"
|
||||
)
|
||||
if kind == 'users':
|
||||
orm_obj = await self._get_or_create_user(bname)
|
||||
orm_obj = await self._get_or_create_user(name)
|
||||
orm_role_bearers.append(orm_obj)
|
||||
elif kind == 'groups':
|
||||
group = orm.Group(name=bname)
|
||||
group = orm.Group(name=name)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
orm_role_bearers.append(group)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"{kind} {bname} defined in config role definition {predef_role['name']} but not present in database"
|
||||
f"{kind} {name} defined in config role definition {role_name} but not present in database"
|
||||
)
|
||||
# Ensure all with admin role have admin flag
|
||||
if predef_role['name'] == 'admin':
|
||||
if role_name == 'admin':
|
||||
orm_obj.admin = True
|
||||
setattr(predef_role_obj, kind, orm_role_bearers)
|
||||
# explicitly defined list
|
||||
# ensure membership list is exact match (adds and revokes permissions)
|
||||
setattr(role, kind, orm_role_bearers)
|
||||
else:
|
||||
# no defined members
|
||||
# leaving 'users' undefined in overrides of the default 'user' role
|
||||
# should not clear membership on startup
|
||||
# since allowed users could be managed by the authenticator
|
||||
if kind == "users" and role_name == "user":
|
||||
# Default user lists can be managed by the Authenticator,
|
||||
# if unspecified in role config
|
||||
pass
|
||||
else:
|
||||
# otherwise, omitting a member category is equivalent to specifying an empty list
|
||||
setattr(role, kind, [])
|
||||
|
||||
db.commit()
|
||||
if self.authenticator.allowed_users:
|
||||
self.log.debug(
|
||||
f"Assigning {len(self.authenticator.allowed_users)} allowed_users to the user role"
|
||||
)
|
||||
allowed_users = db.query(orm.User).filter(
|
||||
orm.User.name.in_(self.authenticator.allowed_users)
|
||||
)
|
||||
@@ -2149,8 +2201,8 @@ class JupyterHub(Application):
|
||||
db.commit()
|
||||
# make sure that on hub upgrade, all users, services and tokens have at least one role (update with default)
|
||||
if getattr(self, '_rbac_upgrade', False):
|
||||
app_log.warning(
|
||||
"No admin role found; assuming hub upgrade. Initializing default roles for all entities"
|
||||
self.log.warning(
|
||||
"No roles found; assuming hub upgrade. Initializing default roles for all entities"
|
||||
)
|
||||
for kind in kinds:
|
||||
roles.check_for_default_roles(db, kind)
|
||||
@@ -3098,7 +3150,12 @@ class JupyterHub(Application):
|
||||
self.last_activity_callback = pc
|
||||
pc.start()
|
||||
|
||||
self.log.info("JupyterHub is now running at %s", self.proxy.public_url)
|
||||
if self.proxy.should_start:
|
||||
self.log.info("JupyterHub is now running at %s", self.proxy.public_url)
|
||||
else:
|
||||
self.log.info(
|
||||
"JupyterHub is now running, internal Hub API at %s", self.hub.url
|
||||
)
|
||||
# Use atexit for Windows, it doesn't have signal handling support
|
||||
if _mswindows:
|
||||
atexit.register(self.atexit)
|
||||
@@ -3184,9 +3241,15 @@ class JupyterHub(Application):
|
||||
loop.make_current()
|
||||
loop.run_sync(self.cleanup)
|
||||
|
||||
async def shutdown_cancel_tasks(self, sig):
|
||||
async def shutdown_cancel_tasks(self, sig=None):
|
||||
"""Cancel all other tasks of the event loop and initiate cleanup"""
|
||||
self.log.critical("Received signal %s, initiating shutdown...", sig.name)
|
||||
if sig is None:
|
||||
self.log.critical("Initiating shutdown...")
|
||||
else:
|
||||
self.log.critical("Received signal %s, initiating shutdown...", sig.name)
|
||||
|
||||
await self.cleanup()
|
||||
|
||||
tasks = [t for t in asyncio_all_tasks() if t is not asyncio_current_task()]
|
||||
|
||||
if tasks:
|
||||
@@ -3203,7 +3266,6 @@ class JupyterHub(Application):
|
||||
tasks = [t for t in asyncio_all_tasks()]
|
||||
for t in tasks:
|
||||
self.log.debug("Task status: %s", t)
|
||||
await self.cleanup()
|
||||
asyncio.get_event_loop().stop()
|
||||
|
||||
def stop(self):
|
||||
@@ -3211,7 +3273,7 @@ class JupyterHub(Application):
|
||||
return
|
||||
if self.http_server:
|
||||
self.http_server.stop()
|
||||
self.io_loop.add_callback(self.io_loop.stop)
|
||||
self.io_loop.add_callback(self.shutdown_cancel_tasks)
|
||||
|
||||
async def start_show_config(self):
|
||||
"""Async wrapper around base start_show_config method"""
|
||||
|
@@ -582,9 +582,13 @@ class Authenticator(LoggingConfigurable):
|
||||
or None if Authentication failed.
|
||||
|
||||
The Authenticator may return a dict instead, which MUST have a
|
||||
key `name` holding the username, and MAY have two optional keys
|
||||
set: `auth_state`, a dictionary of of auth state that will be
|
||||
persisted; and `admin`, the admin setting value for the user.
|
||||
key `name` holding the username, and MAY have additional keys:
|
||||
|
||||
- `auth_state`, a dictionary of of auth state that will be
|
||||
persisted;
|
||||
- `admin`, the admin setting value for the user
|
||||
- `groups`, the list of group names the user should be a member of,
|
||||
if Authenticator.manage_groups is True.
|
||||
"""
|
||||
|
||||
def pre_spawn_start(self, user, spawner):
|
||||
@@ -635,6 +639,19 @@ class Authenticator(LoggingConfigurable):
|
||||
"""
|
||||
self.allowed_users.discard(user.name)
|
||||
|
||||
manage_groups = Bool(
|
||||
False,
|
||||
config=True,
|
||||
help="""Let authenticator manage user groups
|
||||
|
||||
If True, Authenticator.authenticate and/or .refresh_user
|
||||
may return a list of group names in the 'groups' field,
|
||||
which will be assigned to the user.
|
||||
|
||||
All group-assignment APIs are disabled if this is True.
|
||||
""",
|
||||
)
|
||||
|
||||
auto_login = Bool(
|
||||
False,
|
||||
config=True,
|
||||
@@ -958,16 +975,24 @@ class PAMAuthenticator(LocalAuthenticator):
|
||||
).tag(config=True)
|
||||
|
||||
open_sessions = Bool(
|
||||
True,
|
||||
False,
|
||||
help="""
|
||||
Whether to open a new PAM session when spawners are started.
|
||||
|
||||
This may trigger things like mounting shared filsystems,
|
||||
loading credentials, etc. depending on system configuration,
|
||||
but it does not always work.
|
||||
This may trigger things like mounting shared filesystems,
|
||||
loading credentials, etc. depending on system configuration.
|
||||
|
||||
The lifecycle of PAM sessions is not correct,
|
||||
so many PAM session configurations will not work.
|
||||
|
||||
If any errors are encountered when opening/closing PAM sessions,
|
||||
this is automatically set to False.
|
||||
|
||||
.. versionchanged:: 2.2
|
||||
|
||||
Due to longstanding problems in the session lifecycle,
|
||||
this is now disabled by default.
|
||||
You may opt-in to opening sessions by setting this to True.
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
|
@@ -45,10 +45,12 @@ from ..metrics import ServerSpawnStatus
|
||||
from ..metrics import ServerStopStatus
|
||||
from ..metrics import TOTAL_USERS
|
||||
from ..objects import Server
|
||||
from ..scopes import needs_scope
|
||||
from ..spawner import LocalProcessSpawner
|
||||
from ..user import User
|
||||
from ..utils import AnyTimeoutError
|
||||
from ..utils import get_accepted_mimetype
|
||||
from ..utils import get_browser_protocol
|
||||
from ..utils import maybe_future
|
||||
from ..utils import url_path_join
|
||||
|
||||
@@ -524,10 +526,16 @@ class BaseHandler(RequestHandler):
|
||||
path=url_path_join(self.base_url, 'services'),
|
||||
**kwargs,
|
||||
)
|
||||
# clear tornado cookie
|
||||
# clear_cookie only accepts a subset of set_cookie's kwargs
|
||||
clear_xsrf_cookie_kwargs = {
|
||||
key: value
|
||||
for key, value in self.settings.get('xsrf_cookie_kwargs', {}).items()
|
||||
if key in {"path", "domain"}
|
||||
}
|
||||
|
||||
self.clear_cookie(
|
||||
'_xsrf',
|
||||
**self.settings.get('xsrf_cookie_kwargs', {}),
|
||||
**clear_xsrf_cookie_kwargs,
|
||||
)
|
||||
# Reset _jupyterhub_user
|
||||
self._jupyterhub_user = None
|
||||
@@ -632,33 +640,34 @@ class BaseHandler(RequestHandler):
|
||||
next_url = self.get_argument('next', default='')
|
||||
# protect against some browsers' buggy handling of backslash as slash
|
||||
next_url = next_url.replace('\\', '%5C')
|
||||
if (next_url + '/').startswith(
|
||||
(
|
||||
f'{self.request.protocol}://{self.request.host}/',
|
||||
f'//{self.request.host}/',
|
||||
)
|
||||
) or (
|
||||
proto = get_browser_protocol(self.request)
|
||||
host = self.request.host
|
||||
if next_url.startswith("///"):
|
||||
# strip more than 2 leading // down to 2
|
||||
# because urlparse treats that as empty netloc,
|
||||
# whereas browsers treat more than two leading // the same as //,
|
||||
# so netloc is the first non-/ bit
|
||||
next_url = "//" + next_url.lstrip("/")
|
||||
parsed_next_url = urlparse(next_url)
|
||||
|
||||
if (next_url + '/').startswith((f'{proto}://{host}/', f'//{host}/',)) or (
|
||||
self.subdomain_host
|
||||
and urlparse(next_url).netloc
|
||||
and ("." + urlparse(next_url).netloc).endswith(
|
||||
and parsed_next_url.netloc
|
||||
and ("." + parsed_next_url.netloc).endswith(
|
||||
"." + urlparse(self.subdomain_host).netloc
|
||||
)
|
||||
):
|
||||
# treat absolute URLs for our host as absolute paths:
|
||||
# below, redirects that aren't strictly paths
|
||||
parsed = urlparse(next_url)
|
||||
next_url = parsed.path
|
||||
if parsed.query:
|
||||
next_url = next_url + '?' + parsed.query
|
||||
if parsed.fragment:
|
||||
next_url = next_url + '#' + parsed.fragment
|
||||
# below, redirects that aren't strictly paths are rejected
|
||||
next_url = parsed_next_url.path
|
||||
if parsed_next_url.query:
|
||||
next_url = next_url + '?' + parsed_next_url.query
|
||||
if parsed_next_url.fragment:
|
||||
next_url = next_url + '#' + parsed_next_url.fragment
|
||||
parsed_next_url = urlparse(next_url)
|
||||
|
||||
# if it still has host info, it didn't match our above check for *this* host
|
||||
if next_url and (
|
||||
'://' in next_url
|
||||
or next_url.startswith('//')
|
||||
or not next_url.startswith('/')
|
||||
):
|
||||
if next_url and (parsed_next_url.netloc or not next_url.startswith('/')):
|
||||
self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
|
||||
next_url = ''
|
||||
|
||||
@@ -771,15 +780,25 @@ class BaseHandler(RequestHandler):
|
||||
# Only set `admin` if the authenticator returned an explicit value.
|
||||
if admin is not None and admin != user.admin:
|
||||
user.admin = admin
|
||||
roles.assign_default_roles(self.db, entity=user)
|
||||
self.db.commit()
|
||||
# always ensure default roles ('user', 'admin' if admin) are assigned
|
||||
# after a successful login
|
||||
roles.assign_default_roles(self.db, entity=user)
|
||||
|
||||
# apply authenticator-managed groups
|
||||
if self.authenticator.manage_groups:
|
||||
group_names = authenticated.get("groups")
|
||||
if group_names is not None:
|
||||
user.sync_groups(group_names)
|
||||
|
||||
# always set auth_state and commit,
|
||||
# because there could be key-rotation or clearing of previous values
|
||||
# going on.
|
||||
if not self.authenticator.enable_auth_state:
|
||||
# auth_state is not enabled. Force None.
|
||||
auth_state = None
|
||||
|
||||
await user.save_auth_state(auth_state)
|
||||
|
||||
return user
|
||||
|
||||
async def login_user(self, data=None):
|
||||
@@ -793,6 +812,7 @@ class BaseHandler(RequestHandler):
|
||||
self.set_login_cookie(user)
|
||||
self.statsd.incr('login.success')
|
||||
self.statsd.timing('login.authenticate.success', auth_timer.ms)
|
||||
|
||||
self.log.info("User logged in: %s", user.name)
|
||||
user._auth_refreshed = time.monotonic()
|
||||
return user
|
||||
@@ -1381,6 +1401,9 @@ class UserUrlHandler(BaseHandler):
|
||||
Note that this only occurs if bob's server is not already running.
|
||||
"""
|
||||
|
||||
# accept token auth for API requests that are probably to non-running servers
|
||||
_accept_token_auth = True
|
||||
|
||||
def _fail_api_request(self, user_name='', server_name=''):
|
||||
"""Fail an API request to a not-running server"""
|
||||
self.log.warning(
|
||||
@@ -1445,54 +1468,24 @@ class UserUrlHandler(BaseHandler):
|
||||
delete = non_get
|
||||
|
||||
@web.authenticated
|
||||
@needs_scope("access:servers")
|
||||
async def get(self, user_name, user_path):
|
||||
if not user_path:
|
||||
user_path = '/'
|
||||
current_user = self.current_user
|
||||
|
||||
if (
|
||||
current_user
|
||||
and current_user.name != user_name
|
||||
and current_user.admin
|
||||
and self.settings.get('admin_access', False)
|
||||
):
|
||||
# allow admins to spawn on behalf of users
|
||||
if user_name != current_user.name:
|
||||
user = self.find_user(user_name)
|
||||
if user is None:
|
||||
# no such user
|
||||
raise web.HTTPError(404, "No such user %s" % user_name)
|
||||
raise web.HTTPError(404, f"No such user {user_name}")
|
||||
self.log.info(
|
||||
"Admin %s requesting spawn on behalf of %s",
|
||||
current_user.name,
|
||||
user.name,
|
||||
f"User {current_user.name} requesting spawn on behalf of {user.name}"
|
||||
)
|
||||
admin_spawn = True
|
||||
should_spawn = True
|
||||
redirect_to_self = False
|
||||
else:
|
||||
user = current_user
|
||||
admin_spawn = False
|
||||
# For non-admins, spawn if the user requested is the current user
|
||||
# otherwise redirect users to their own server
|
||||
should_spawn = current_user and current_user.name == user_name
|
||||
redirect_to_self = not should_spawn
|
||||
|
||||
if redirect_to_self:
|
||||
# logged in as a different non-admin user, redirect to user's own server
|
||||
# this is only a stop-gap for a common mistake,
|
||||
# because the same request will be a 403
|
||||
# if the requested server is running
|
||||
self.statsd.incr('redirects.user_to_user', 1)
|
||||
self.log.warning(
|
||||
"User %s requested server for %s, which they don't own",
|
||||
current_user.name,
|
||||
user_name,
|
||||
)
|
||||
target = url_path_join(current_user.url, user_path or '')
|
||||
if self.request.query:
|
||||
target = url_concat(target, parse_qsl(self.request.query))
|
||||
self.redirect(target)
|
||||
return
|
||||
|
||||
# If people visit /user/:user_name directly on the Hub,
|
||||
# the redirects will just loop, because the proxy is bypassed.
|
||||
@@ -1536,14 +1529,10 @@ class UserUrlHandler(BaseHandler):
|
||||
|
||||
# if request is expecting JSON, assume it's an API request and fail with 503
|
||||
# because it won't like the redirect to the pending page
|
||||
if (
|
||||
get_accepted_mimetype(
|
||||
self.request.headers.get('Accept', ''),
|
||||
choices=['application/json', 'text/html'],
|
||||
)
|
||||
== 'application/json'
|
||||
or 'api' in user_path.split('/')
|
||||
):
|
||||
if get_accepted_mimetype(
|
||||
self.request.headers.get('Accept', ''),
|
||||
choices=['application/json', 'text/html'],
|
||||
) == 'application/json' or 'api' in user_path.split('/'):
|
||||
self._fail_api_request(user_name, server_name)
|
||||
return
|
||||
|
||||
@@ -1625,7 +1614,7 @@ class UserUrlHandler(BaseHandler):
|
||||
if redirects:
|
||||
self.log.warning("Redirect loop detected on %s", self.request.uri)
|
||||
# add capped exponential backoff where cap is 10s
|
||||
await asyncio.sleep(min(1 * (2 ** redirects), 10))
|
||||
await asyncio.sleep(min(1 * (2**redirects), 10))
|
||||
# rewrite target url with new `redirects` query value
|
||||
url_parts = urlparse(target)
|
||||
query_parts = parse_qs(url_parts.query)
|
||||
|
@@ -12,6 +12,8 @@ class MetricsHandler(BaseHandler):
|
||||
Handler to serve Prometheus metrics
|
||||
"""
|
||||
|
||||
_accept_token_auth = True
|
||||
|
||||
@metrics_authentication
|
||||
async def get(self):
|
||||
self.set_header('Content-Type', CONTENT_TYPE_LATEST)
|
||||
|
@@ -106,22 +106,27 @@ class SpawnHandler(BaseHandler):
|
||||
)
|
||||
|
||||
@web.authenticated
|
||||
async def get(self, for_user=None, server_name=''):
|
||||
def get(self, user_name=None, server_name=''):
|
||||
"""GET renders form for spawning with user-specified options
|
||||
|
||||
or triggers spawn via redirect if there is no form.
|
||||
"""
|
||||
# two-stage to get the right signature for @require_scopes filter on user_name
|
||||
if user_name is None:
|
||||
user_name = self.current_user.name
|
||||
if server_name is None:
|
||||
server_name = ""
|
||||
return self._get(user_name=user_name, server_name=server_name)
|
||||
|
||||
@needs_scope("servers")
|
||||
async def _get(self, user_name, server_name):
|
||||
for_user = user_name
|
||||
|
||||
user = current_user = self.current_user
|
||||
if for_user is not None and for_user != user.name:
|
||||
if not user.admin:
|
||||
raise web.HTTPError(
|
||||
403, "Only admins can spawn on behalf of other users"
|
||||
)
|
||||
|
||||
if for_user != user.name:
|
||||
user = self.find_user(for_user)
|
||||
if user is None:
|
||||
raise web.HTTPError(404, "No such user: %s" % for_user)
|
||||
raise web.HTTPError(404, f"No such user: {for_user}")
|
||||
|
||||
if server_name:
|
||||
if not self.allow_named_servers:
|
||||
@@ -141,15 +146,12 @@ class SpawnHandler(BaseHandler):
|
||||
)
|
||||
|
||||
if not self.allow_named_servers and user.running:
|
||||
url = self.get_next_url(user, default=user.server_url(server_name))
|
||||
url = self.get_next_url(user, default=user.server_url(""))
|
||||
self.log.info("User is running: %s", user.name)
|
||||
self.redirect(url)
|
||||
return
|
||||
|
||||
if server_name is None:
|
||||
server_name = ''
|
||||
|
||||
spawner = user.spawners[server_name]
|
||||
spawner = user.get_spawner(server_name, replace_failed=True)
|
||||
|
||||
pending_url = self._get_pending_url(user, server_name)
|
||||
|
||||
@@ -189,7 +191,6 @@ class SpawnHandler(BaseHandler):
|
||||
spawner._log_name,
|
||||
)
|
||||
options = await maybe_future(spawner.options_from_query(query_options))
|
||||
pending_url = self._get_pending_url(user, server_name)
|
||||
return await self._wrap_spawn_single_user(
|
||||
user, server_name, spawner, pending_url, options
|
||||
)
|
||||
@@ -219,19 +220,24 @@ class SpawnHandler(BaseHandler):
|
||||
)
|
||||
|
||||
@web.authenticated
|
||||
async def post(self, for_user=None, server_name=''):
|
||||
def post(self, user_name=None, server_name=''):
|
||||
"""POST spawns with user-specified options"""
|
||||
if user_name is None:
|
||||
user_name = self.current_user.name
|
||||
if server_name is None:
|
||||
server_name = ""
|
||||
return self._post(user_name=user_name, server_name=server_name)
|
||||
|
||||
@needs_scope("servers")
|
||||
async def _post(self, user_name, server_name):
|
||||
for_user = user_name
|
||||
user = current_user = self.current_user
|
||||
if for_user is not None and for_user != user.name:
|
||||
if not user.admin:
|
||||
raise web.HTTPError(
|
||||
403, "Only admins can spawn on behalf of other users"
|
||||
)
|
||||
if for_user != user.name:
|
||||
user = self.find_user(for_user)
|
||||
if user is None:
|
||||
raise web.HTTPError(404, "No such user: %s" % for_user)
|
||||
|
||||
spawner = user.spawners[server_name]
|
||||
spawner = user.get_spawner(server_name, replace_failed=True)
|
||||
|
||||
if spawner.ready:
|
||||
raise web.HTTPError(400, "%s is already running" % (spawner._log_name))
|
||||
@@ -249,7 +255,7 @@ class SpawnHandler(BaseHandler):
|
||||
self.log.debug(
|
||||
"Triggering spawn with supplied form options for %s", spawner._log_name
|
||||
)
|
||||
options = await maybe_future(spawner.options_from_form(form_options))
|
||||
options = await maybe_future(spawner.run_options_from_form(form_options))
|
||||
pending_url = self._get_pending_url(user, server_name)
|
||||
return await self._wrap_spawn_single_user(
|
||||
user, server_name, spawner, pending_url, options
|
||||
@@ -308,10 +314,13 @@ class SpawnHandler(BaseHandler):
|
||||
# otherwise it may cause a redirect loop
|
||||
if f.done() and f.exception():
|
||||
exc = f.exception()
|
||||
self.log.exception(f"Error starting server {spawner._log_name}: {exc}")
|
||||
if isinstance(exc, web.HTTPError):
|
||||
# allow custom HTTPErrors to pass through
|
||||
raise exc
|
||||
raise web.HTTPError(
|
||||
500,
|
||||
"Error in Authenticator.pre_spawn_start: %s %s"
|
||||
% (type(exc).__name__, str(exc)),
|
||||
f"Unhandled error starting server {spawner._log_name}",
|
||||
)
|
||||
return self.redirect(pending_url)
|
||||
|
||||
@@ -334,13 +343,11 @@ class SpawnPendingHandler(BaseHandler):
|
||||
"""
|
||||
|
||||
@web.authenticated
|
||||
async def get(self, for_user, server_name=''):
|
||||
@needs_scope("servers")
|
||||
async def get(self, user_name, server_name=''):
|
||||
for_user = user_name
|
||||
user = current_user = self.current_user
|
||||
if for_user is not None and for_user != current_user.name:
|
||||
if not current_user.admin:
|
||||
raise web.HTTPError(
|
||||
403, "Only admins can spawn on behalf of other users"
|
||||
)
|
||||
if for_user != current_user.name:
|
||||
user = self.find_user(for_user)
|
||||
if user is None:
|
||||
raise web.HTTPError(404, "No such user: %s" % for_user)
|
||||
@@ -362,13 +369,9 @@ class SpawnPendingHandler(BaseHandler):
|
||||
auth_state = await user.get_auth_state()
|
||||
|
||||
# First, check for previous failure.
|
||||
if (
|
||||
not spawner.active
|
||||
and spawner._spawn_future
|
||||
and spawner._spawn_future.done()
|
||||
and spawner._spawn_future.exception()
|
||||
):
|
||||
# Condition: spawner not active and _spawn_future exists and contains an Exception
|
||||
if not spawner.active and spawner._failed:
|
||||
# Condition: spawner not active and last spawn failed
|
||||
# (failure is available as spawner._spawn_future.exception()).
|
||||
# Implicit spawn on /user/:name is not allowed if the user's last spawn failed.
|
||||
# We should point the user to Home if the most recent spawn failed.
|
||||
exc = spawner._spawn_future.exception()
|
||||
@@ -384,6 +387,7 @@ class SpawnPendingHandler(BaseHandler):
|
||||
server_name=server_name,
|
||||
spawn_url=spawn_url,
|
||||
failed=True,
|
||||
failed_html_message=getattr(exc, 'jupyterhub_html_message', ''),
|
||||
failed_message=getattr(exc, 'jupyterhub_message', ''),
|
||||
exception=exc,
|
||||
)
|
||||
@@ -465,6 +469,7 @@ class AdminHandler(BaseHandler):
|
||||
named_server_limit_per_user=self.named_server_limit_per_user,
|
||||
server_version=f'{__version__} {self.version_hash}',
|
||||
api_page_limit=self.settings["api_page_default_limit"],
|
||||
base_url=self.settings["base_url"],
|
||||
)
|
||||
self.finish(html)
|
||||
|
||||
@@ -493,7 +498,7 @@ class TokenPageHandler(BaseHandler):
|
||||
continue
|
||||
if not token.client_id:
|
||||
# token should have been deleted when client was deleted
|
||||
self.log.warning("Deleting stale oauth token {token}")
|
||||
self.log.warning(f"Deleting stale oauth token {token}")
|
||||
self.db.delete(token)
|
||||
self.db.commit()
|
||||
continue
|
||||
|
@@ -2,6 +2,7 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import re
|
||||
from functools import wraps
|
||||
from itertools import chain
|
||||
|
||||
from sqlalchemy import func
|
||||
@@ -44,6 +45,7 @@ def get_default_roles():
|
||||
'access:services',
|
||||
'access:servers',
|
||||
'read:roles',
|
||||
'read:metrics',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -239,26 +241,6 @@ def _check_scopes(*args, rolename=None):
|
||||
)
|
||||
|
||||
|
||||
def _overwrite_role(role, role_dict):
|
||||
"""Overwrites role's description and/or scopes with role_dict if role not 'admin'"""
|
||||
for attr in role_dict.keys():
|
||||
if attr == 'description' or attr == 'scopes':
|
||||
if role.name == 'admin':
|
||||
admin_role_spec = [
|
||||
r for r in get_default_roles() if r['name'] == 'admin'
|
||||
][0]
|
||||
if role_dict[attr] != admin_role_spec[attr]:
|
||||
raise ValueError(
|
||||
'admin role description or scopes cannot be overwritten'
|
||||
)
|
||||
else:
|
||||
if role_dict[attr] != getattr(role, attr):
|
||||
setattr(role, attr, role_dict[attr])
|
||||
app_log.info(
|
||||
'Role %r %r attribute has been changed', role.name, attr
|
||||
)
|
||||
|
||||
|
||||
_role_name_pattern = re.compile(r'^[a-z][a-z0-9\-_~\.]{1,253}[a-z0-9]$')
|
||||
|
||||
|
||||
@@ -293,6 +275,17 @@ def create_role(db, role_dict):
|
||||
description = role_dict.get('description')
|
||||
scopes = role_dict.get('scopes')
|
||||
|
||||
if name == "admin":
|
||||
for _role in get_default_roles():
|
||||
if _role["name"] == "admin":
|
||||
admin_spec = _role
|
||||
break
|
||||
for key in ["description", "scopes"]:
|
||||
if key in role_dict and role_dict[key] != admin_spec[key]:
|
||||
raise ValueError(
|
||||
f"Cannot override admin role admin.{key} = {role_dict[key]}"
|
||||
)
|
||||
|
||||
# check if the provided scopes exist
|
||||
if scopes:
|
||||
_check_scopes(*scopes, rolename=role_dict['name'])
|
||||
@@ -306,8 +299,22 @@ def create_role(db, role_dict):
|
||||
if role_dict not in default_roles:
|
||||
app_log.info('Role %s added to database', name)
|
||||
else:
|
||||
_overwrite_role(role, role_dict)
|
||||
|
||||
for attr in ["description", "scopes"]:
|
||||
try:
|
||||
new_value = role_dict[attr]
|
||||
except KeyError:
|
||||
continue
|
||||
old_value = getattr(role, attr)
|
||||
if new_value != old_value:
|
||||
setattr(role, attr, new_value)
|
||||
app_log.info(
|
||||
f'Role attribute {role.name}.{attr} has been changed',
|
||||
)
|
||||
app_log.debug(
|
||||
f'Role attribute {role.name}.{attr} changed from %r to %r',
|
||||
old_value,
|
||||
new_value,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -327,78 +334,61 @@ def delete_role(db, rolename):
|
||||
raise KeyError('Cannot remove role %r that does not exist', rolename)
|
||||
|
||||
|
||||
def existing_only(func):
|
||||
"""Decorator for checking if objects and roles exist"""
|
||||
def _existing_only(func):
|
||||
"""Decorator for checking if roles exist"""
|
||||
|
||||
def _check_existence(db, entity, rolename):
|
||||
role = orm.Role.find(db, rolename)
|
||||
if entity is None:
|
||||
raise ValueError(
|
||||
f"{entity!r} of kind {type(entity).__name__!r} does not exist"
|
||||
)
|
||||
elif role is None:
|
||||
raise ValueError("Role %r does not exist" % rolename)
|
||||
else:
|
||||
func(db, entity, role)
|
||||
@wraps(func)
|
||||
def _check_existence(db, entity, role=None, *, rolename=None):
|
||||
if isinstance(role, str):
|
||||
rolename = role
|
||||
if rolename is not None:
|
||||
# if given as a str, lookup role by name
|
||||
role = orm.Role.find(db, rolename)
|
||||
if role is None:
|
||||
raise ValueError(f"Role {rolename} does not exist")
|
||||
|
||||
return func(db, entity, role)
|
||||
|
||||
return _check_existence
|
||||
|
||||
|
||||
@existing_only
|
||||
def grant_role(db, entity, rolename):
|
||||
@_existing_only
|
||||
def grant_role(db, entity, role):
|
||||
"""Adds a role for users, services, groups or tokens"""
|
||||
if isinstance(entity, orm.APIToken):
|
||||
entity_repr = entity
|
||||
else:
|
||||
entity_repr = entity.name
|
||||
|
||||
if rolename not in entity.roles:
|
||||
entity.roles.append(rolename)
|
||||
if role not in entity.roles:
|
||||
entity.roles.append(role)
|
||||
db.commit()
|
||||
app_log.info(
|
||||
'Adding role %s for %s: %s',
|
||||
rolename.name,
|
||||
role.name,
|
||||
type(entity).__name__,
|
||||
entity_repr,
|
||||
)
|
||||
|
||||
|
||||
@existing_only
|
||||
def strip_role(db, entity, rolename):
|
||||
@_existing_only
|
||||
def strip_role(db, entity, role):
|
||||
"""Removes a role for users, services, groups or tokens"""
|
||||
if isinstance(entity, orm.APIToken):
|
||||
entity_repr = entity
|
||||
else:
|
||||
entity_repr = entity.name
|
||||
if rolename in entity.roles:
|
||||
entity.roles.remove(rolename)
|
||||
if role in entity.roles:
|
||||
entity.roles.remove(role)
|
||||
db.commit()
|
||||
app_log.info(
|
||||
'Removing role %s for %s: %s',
|
||||
rolename.name,
|
||||
role.name,
|
||||
type(entity).__name__,
|
||||
entity_repr,
|
||||
)
|
||||
|
||||
|
||||
def _switch_default_role(db, obj, admin):
|
||||
"""Switch between default user/service and admin roles for users/services"""
|
||||
user_role = orm.Role.find(db, 'user')
|
||||
admin_role = orm.Role.find(db, 'admin')
|
||||
|
||||
def add_and_remove(db, obj, current_role, new_role):
|
||||
if current_role in obj.roles:
|
||||
strip_role(db, entity=obj, rolename=current_role.name)
|
||||
# only add new default role if the user has no other roles
|
||||
if len(obj.roles) < 1:
|
||||
grant_role(db, entity=obj, rolename=new_role.name)
|
||||
|
||||
if admin:
|
||||
add_and_remove(db, obj, user_role, admin_role)
|
||||
else:
|
||||
add_and_remove(db, obj, admin_role, user_role)
|
||||
|
||||
|
||||
def _token_allowed_role(db, token, role):
|
||||
"""Checks if requested role for token does not grant the token
|
||||
higher permissions than the token's owner has
|
||||
@@ -413,22 +403,21 @@ def _token_allowed_role(db, token, role):
|
||||
if owner is None:
|
||||
raise ValueError(f"Owner not found for {token}")
|
||||
|
||||
if role in owner.roles:
|
||||
# shortcut: token is assigned an exact role the owner has
|
||||
return True
|
||||
|
||||
expanded_scopes = _get_subscopes(role, owner=owner)
|
||||
|
||||
implicit_permissions = {'inherit', 'read:inherit'}
|
||||
explicit_scopes = expanded_scopes - implicit_permissions
|
||||
# ignore horizontal filters
|
||||
no_filter_scopes = {
|
||||
scope.split('!', 1)[0] if '!' in scope else scope for scope in explicit_scopes
|
||||
}
|
||||
# find the owner's scopes
|
||||
expanded_owner_scopes = expand_roles_to_scopes(owner)
|
||||
# ignore horizontal filters
|
||||
no_filter_owner_scopes = {
|
||||
scope.split('!', 1)[0] if '!' in scope else scope
|
||||
for scope in expanded_owner_scopes
|
||||
}
|
||||
disallowed_scopes = no_filter_scopes.difference(no_filter_owner_scopes)
|
||||
allowed_scopes = scopes._intersect_expanded_scopes(
|
||||
explicit_scopes, expanded_owner_scopes, db
|
||||
)
|
||||
disallowed_scopes = explicit_scopes.difference(allowed_scopes)
|
||||
|
||||
if not disallowed_scopes:
|
||||
# no scopes requested outside owner's own scopes
|
||||
return True
|
||||
@@ -441,23 +430,36 @@ def _token_allowed_role(db, token, role):
|
||||
|
||||
def assign_default_roles(db, entity):
|
||||
"""Assigns default role(s) to an entity:
|
||||
users and services get 'user' role, or admin role if they have admin flag
|
||||
|
||||
tokens get 'token' role
|
||||
|
||||
users and services get 'admin' role if they are admin (removed if they are not)
|
||||
|
||||
users always get 'user' role
|
||||
"""
|
||||
if isinstance(entity, orm.Group):
|
||||
pass
|
||||
elif isinstance(entity, orm.APIToken):
|
||||
return
|
||||
|
||||
if isinstance(entity, orm.APIToken):
|
||||
app_log.debug('Assigning default role to token')
|
||||
default_token_role = orm.Role.find(db, 'token')
|
||||
if not entity.roles and (entity.user or entity.service) is not None:
|
||||
default_token_role.tokens.append(entity)
|
||||
app_log.info('Added role %s to token %s', default_token_role.name, entity)
|
||||
db.commit()
|
||||
# users and services can have 'user' or 'admin' roles as default
|
||||
# users and services all have 'user' role by default
|
||||
# and optionally 'admin' as well
|
||||
else:
|
||||
kind = type(entity).__name__
|
||||
app_log.debug(f'Assigning default role to {kind} {entity.name}')
|
||||
_switch_default_role(db, entity, entity.admin)
|
||||
if entity.admin:
|
||||
grant_role(db, entity=entity, rolename="admin")
|
||||
else:
|
||||
admin_role = orm.Role.find(db, 'admin')
|
||||
if admin_role in entity.roles:
|
||||
strip_role(db, entity=entity, rolename="admin")
|
||||
if kind == "User":
|
||||
grant_role(db, entity=entity, rolename="user")
|
||||
|
||||
|
||||
def update_roles(db, entity, roles):
|
||||
|
@@ -131,6 +131,9 @@ scope_definitions = {
|
||||
'description': 'Read information about the proxy’s routing table, sync the Hub with the proxy and notify the Hub about a new proxy.'
|
||||
},
|
||||
'shutdown': {'description': 'Shutdown the hub.'},
|
||||
'read:metrics': {
|
||||
'description': "Read prometheus metrics.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
@@ -3,10 +3,24 @@
|
||||
Tokens are sent to the Hub for verification.
|
||||
The Hub replies with a JSON model describing the authenticated user.
|
||||
|
||||
``HubAuth`` can be used in any application, even outside tornado.
|
||||
This contains two levels of authentication:
|
||||
|
||||
``HubAuthenticated`` is a mixin class for tornado handlers that should
|
||||
authenticate with the Hub.
|
||||
- :class:`HubOAuth` - Use OAuth 2 to authenticate browsers with the Hub.
|
||||
This should be used for any service that should respond to browser requests
|
||||
(i.e. most services).
|
||||
|
||||
- :class:`HubAuth` - token-only authentication, for a service that only need to handle token-authenticated API requests
|
||||
|
||||
The ``Auth`` classes (:class:`HubAuth`, :class:`HubOAuth`)
|
||||
can be used in any application, even outside tornado.
|
||||
They contain reference implementations of talking to the Hub API
|
||||
to resolve a token to a user.
|
||||
|
||||
The ``Authenticated`` classes (:class:`HubAuthenticated`, :class:`HubOAuthenticated`)
|
||||
are mixins for tornado handlers that should authenticate with the Hub.
|
||||
|
||||
If you are using OAuth, you will also need to register an oauth callback handler to complete the oauth process.
|
||||
A tornado implementation is provided in :class:`HubOAuthCallbackHandler`.
|
||||
|
||||
"""
|
||||
import base64
|
||||
@@ -39,6 +53,7 @@ from traitlets import validate
|
||||
from traitlets.config import SingletonConfigurable
|
||||
|
||||
from ..scopes import _intersect_expanded_scopes
|
||||
from ..utils import get_browser_protocol
|
||||
from ..utils import url_path_join
|
||||
|
||||
|
||||
@@ -212,6 +227,7 @@ class HubAuth(SingletonConfigurable):
|
||||
help="""The base API URL of the Hub.
|
||||
|
||||
Typically `http://hub-ip:hub-port/hub/api`
|
||||
Default: $JUPYTERHUB_API_URL
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
@@ -227,7 +243,10 @@ class HubAuth(SingletonConfigurable):
|
||||
os.getenv('JUPYTERHUB_API_TOKEN', ''),
|
||||
help="""API key for accessing Hub API.
|
||||
|
||||
Generate with `jupyterhub token [username]` or add to JupyterHub.services config.
|
||||
Default: $JUPYTERHUB_API_TOKEN
|
||||
|
||||
Loaded from services configuration in jupyterhub_config.
|
||||
Will be auto-generated for hub-managed services.
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
@@ -236,6 +255,7 @@ class HubAuth(SingletonConfigurable):
|
||||
help="""The URL prefix for the Hub itself.
|
||||
|
||||
Typically /hub/
|
||||
Default: $JUPYTERHUB_BASE_URL
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
@@ -481,11 +501,17 @@ class HubAuth(SingletonConfigurable):
|
||||
auth_header_name = 'Authorization'
|
||||
auth_header_pat = re.compile(r'(?:token|bearer)\s+(.+)', re.IGNORECASE)
|
||||
|
||||
def get_token(self, handler):
|
||||
"""Get the user token from a request
|
||||
def get_token(self, handler, in_cookie=True):
|
||||
"""Get the token authenticating a request
|
||||
|
||||
.. versionchanged:: 2.2
|
||||
in_cookie added.
|
||||
Previously, only URL params and header were considered.
|
||||
Pass `in_cookie=False` to preserve that behavior.
|
||||
|
||||
- in URL parameters: ?token=<token>
|
||||
- in header: Authorization: token <token>
|
||||
- in cookie (stored after oauth), if in_cookie is True
|
||||
"""
|
||||
|
||||
user_token = handler.get_argument('token', '')
|
||||
@@ -496,8 +522,14 @@ class HubAuth(SingletonConfigurable):
|
||||
)
|
||||
if m:
|
||||
user_token = m.group(1)
|
||||
if not user_token and in_cookie:
|
||||
user_token = self._get_token_cookie(handler)
|
||||
return user_token
|
||||
|
||||
def _get_token_cookie(self, handler):
|
||||
"""Base class doesn't store tokens in cookies"""
|
||||
return None
|
||||
|
||||
def _get_user_cookie(self, handler):
|
||||
"""Get the user model from a cookie"""
|
||||
# overridden in HubOAuth to store the access token after oauth
|
||||
@@ -533,8 +565,10 @@ class HubAuth(SingletonConfigurable):
|
||||
handler._cached_hub_user = user_model = None
|
||||
session_id = self.get_session_id(handler)
|
||||
|
||||
# check token first
|
||||
token = self.get_token(handler)
|
||||
# check token first, ignoring cookies
|
||||
# because some checks are different when a request
|
||||
# is token-authenticated (CORS-related)
|
||||
token = self.get_token(handler, in_cookie=False)
|
||||
if token:
|
||||
user_model = self.user_for_token(token, session_id=session_id)
|
||||
if user_model:
|
||||
@@ -594,11 +628,18 @@ class HubOAuth(HubAuth):
|
||||
"""
|
||||
return self.cookie_name + '-oauth-state'
|
||||
|
||||
def _get_user_cookie(self, handler):
|
||||
def _get_token_cookie(self, handler):
|
||||
"""Base class doesn't store tokens in cookies"""
|
||||
token = handler.get_secure_cookie(self.cookie_name)
|
||||
if token:
|
||||
# decode cookie bytes
|
||||
token = token.decode('ascii', 'replace')
|
||||
return token
|
||||
|
||||
def _get_user_cookie(self, handler):
|
||||
token = self._get_token_cookie(handler)
|
||||
session_id = self.get_session_id(handler)
|
||||
if token:
|
||||
token = token.decode('ascii', 'replace')
|
||||
user_model = self.user_for_token(token, session_id=session_id)
|
||||
if user_model is None:
|
||||
app_log.warning("Token stored in cookie may have expired")
|
||||
@@ -753,7 +794,7 @@ class HubOAuth(HubAuth):
|
||||
# OAuth that doesn't complete shouldn't linger too long.
|
||||
'max_age': 600,
|
||||
}
|
||||
if handler.request.protocol == 'https':
|
||||
if get_browser_protocol(handler.request) == 'https':
|
||||
kwargs['secure'] = True
|
||||
# load user cookie overrides
|
||||
kwargs.update(self.cookie_options)
|
||||
@@ -793,7 +834,7 @@ class HubOAuth(HubAuth):
|
||||
def set_cookie(self, handler, access_token):
|
||||
"""Set a cookie recording OAuth result"""
|
||||
kwargs = {'path': self.base_url, 'httponly': True}
|
||||
if handler.request.protocol == 'https':
|
||||
if get_browser_protocol(handler.request) == 'https':
|
||||
kwargs['secure'] = True
|
||||
# load user cookie overrides
|
||||
kwargs.update(self.cookie_options)
|
||||
@@ -854,8 +895,6 @@ class HubAuthenticated:
|
||||
Examples::
|
||||
|
||||
class MyHandler(HubAuthenticated, web.RequestHandler):
|
||||
hub_users = {'inara', 'mal'}
|
||||
|
||||
def initialize(self, hub_auth):
|
||||
self.hub_auth = hub_auth
|
||||
|
||||
@@ -865,6 +904,7 @@ class HubAuthenticated:
|
||||
|
||||
"""
|
||||
|
||||
# deprecated, pre-2.0 allow sets
|
||||
hub_services = None # set of allowed services
|
||||
hub_users = None # set of allowed users
|
||||
hub_groups = None # set of allowed groups
|
||||
@@ -960,6 +1000,10 @@ class HubAuthenticated:
|
||||
raise UserNotAllowed(model)
|
||||
|
||||
# proceed with the pre-2.0 way if hub_scopes is not set
|
||||
warnings.warn(
|
||||
"hub_scopes ($JUPYTERHUB not set, proceeding with pre-2.0 authentication",
|
||||
DeprecationWarning,
|
||||
)
|
||||
|
||||
if self.allow_admin and model.get('admin', False):
|
||||
app_log.debug("Allowing Hub admin %s", name)
|
||||
|
@@ -16,7 +16,6 @@ import random
|
||||
import secrets
|
||||
import sys
|
||||
import warnings
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from importlib import import_module
|
||||
from textwrap import dedent
|
||||
@@ -493,7 +492,7 @@ class SingleUserNotebookAppMixin(Configurable):
|
||||
i,
|
||||
RETRIES,
|
||||
)
|
||||
await asyncio.sleep(min(2 ** i, 16))
|
||||
await asyncio.sleep(min(2**i, 16))
|
||||
else:
|
||||
break
|
||||
else:
|
||||
@@ -680,6 +679,7 @@ class SingleUserNotebookAppMixin(Configurable):
|
||||
s['hub_prefix'] = self.hub_prefix
|
||||
s['hub_host'] = self.hub_host
|
||||
s['hub_auth'] = self.hub_auth
|
||||
s['page_config_hook'] = self.page_config_hook
|
||||
csp_report_uri = s['csp_report_uri'] = self.hub_host + url_path_join(
|
||||
self.hub_prefix, 'security/csp-report'
|
||||
)
|
||||
@@ -707,6 +707,18 @@ class SingleUserNotebookAppMixin(Configurable):
|
||||
self.patch_default_headers()
|
||||
self.patch_templates()
|
||||
|
||||
def page_config_hook(self, handler, page_config):
|
||||
"""JupyterLab page config hook
|
||||
|
||||
Adds JupyterHub info to page config.
|
||||
|
||||
Places the JupyterHub API token in PageConfig.token.
|
||||
|
||||
Only has effect on jupyterlab_server >=2.9
|
||||
"""
|
||||
page_config["token"] = self.hub_auth.get_token(handler) or ""
|
||||
return page_config
|
||||
|
||||
def patch_default_headers(self):
|
||||
if hasattr(RequestHandler, '_orig_set_default_headers'):
|
||||
return
|
||||
|
@@ -11,6 +11,7 @@ import shutil
|
||||
import signal
|
||||
import sys
|
||||
import warnings
|
||||
from inspect import signature
|
||||
from subprocess import Popen
|
||||
from tempfile import mkdtemp
|
||||
from urllib.parse import urlparse
|
||||
@@ -96,10 +97,15 @@ class Spawner(LoggingConfigurable):
|
||||
|
||||
Used in logging for consistency with named servers.
|
||||
"""
|
||||
if self.name:
|
||||
return f'{self.user.name}:{self.name}'
|
||||
if self.user:
|
||||
user_name = self.user.name
|
||||
else:
|
||||
return self.user.name
|
||||
# no user, only happens in mock tests
|
||||
user_name = "(no user)"
|
||||
if self.name:
|
||||
return f"{user_name}:{self.name}"
|
||||
else:
|
||||
return user_name
|
||||
|
||||
@property
|
||||
def _failed(self):
|
||||
@@ -183,17 +189,38 @@ class Spawner(LoggingConfigurable):
|
||||
def last_activity(self):
|
||||
return self.orm_spawner.last_activity
|
||||
|
||||
# Spawner.server is a wrapper of the ORM orm_spawner.server
|
||||
# make sure it's always in sync with the underlying state
|
||||
# this is harder to do with traitlets,
|
||||
# which do not run on every access, only on set and first-get
|
||||
_server = None
|
||||
|
||||
@property
|
||||
def server(self):
|
||||
if hasattr(self, '_server'):
|
||||
# always check that we're in sync with orm_spawner
|
||||
if not self.orm_spawner:
|
||||
# no ORM spawner, nothing to check
|
||||
return self._server
|
||||
if self.orm_spawner and self.orm_spawner.server:
|
||||
return Server(orm_server=self.orm_spawner.server)
|
||||
|
||||
orm_server = self.orm_spawner.server
|
||||
|
||||
if orm_server is not None and (
|
||||
self._server is None or orm_server is not self._server.orm_server
|
||||
):
|
||||
# self._server is not connected to orm_spawner
|
||||
self._server = Server(orm_server=self.orm_spawner.server)
|
||||
elif orm_server is None:
|
||||
# no ORM server, clear it
|
||||
self._server = None
|
||||
return self._server
|
||||
|
||||
@server.setter
|
||||
def server(self, server):
|
||||
self._server = server
|
||||
if self.orm_spawner:
|
||||
if self.orm_spawner is not None:
|
||||
if server is not None and server.orm_server == self.orm_spawner.server:
|
||||
# no change
|
||||
return
|
||||
if self.orm_spawner.server is not None:
|
||||
# delete the old value
|
||||
db = inspect(self.orm_spawner.server).session
|
||||
@@ -201,7 +228,13 @@ class Spawner(LoggingConfigurable):
|
||||
if server is None:
|
||||
self.orm_spawner.server = None
|
||||
else:
|
||||
if server.orm_server is None:
|
||||
self.log.warning(f"No ORM server for {self._log_name}")
|
||||
self.orm_spawner.server = server.orm_server
|
||||
elif server is not None:
|
||||
self.log.warning(
|
||||
f"Setting Spawner.server for {self._log_name} with no underlying orm_spawner"
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@@ -424,6 +457,13 @@ class Spawner(LoggingConfigurable):
|
||||
def _default_options_from_form(self, form_data):
|
||||
return form_data
|
||||
|
||||
def run_options_from_form(self, form_data):
|
||||
sig = signature(self.options_from_form)
|
||||
if 'spawner' in sig.parameters:
|
||||
return self.options_from_form(form_data, spawner=self)
|
||||
else:
|
||||
return self.options_from_form(form_data)
|
||||
|
||||
def options_from_query(self, query_data):
|
||||
"""Interpret query arguments passed to /spawn
|
||||
|
||||
@@ -836,9 +876,6 @@ class Spawner(LoggingConfigurable):
|
||||
|
||||
if self.server:
|
||||
base_url = self.server.base_url
|
||||
if self.ip or self.port:
|
||||
self.server.ip = self.ip
|
||||
self.server.port = self.port
|
||||
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
|
||||
else:
|
||||
# this should only occur in mock/testing scenarios
|
||||
|
@@ -57,12 +57,14 @@ from .utils import add_user
|
||||
_db = None
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(items):
|
||||
def _pytest_collection_modifyitems(items):
|
||||
"""This function is automatically run by pytest passing all collected test
|
||||
functions.
|
||||
|
||||
We use it to add asyncio marker to all async tests and assert we don't use
|
||||
test functions that are async generators which wouldn't make sense.
|
||||
|
||||
It is no longer required with pytest-asyncio >= 0.17
|
||||
"""
|
||||
for item in items:
|
||||
if inspect.iscoroutinefunction(item.obj):
|
||||
@@ -70,6 +72,13 @@ def pytest_collection_modifyitems(items):
|
||||
assert not inspect.isasyncgenfunction(item.obj)
|
||||
|
||||
|
||||
if sys.version_info < (3, 7):
|
||||
# apply pytest-asyncio's 'auto' mode on Python 3.6.
|
||||
# 'auto' mode is new in pytest-asyncio 0.17,
|
||||
# which requires Python 3.7.
|
||||
pytest_collection_modifyitems = _pytest_collection_modifyitems
|
||||
|
||||
|
||||
@fixture(scope='module')
|
||||
def ssl_tmpdir(tmpdir_factory):
|
||||
return tmpdir_factory.mktemp('ssl')
|
||||
@@ -182,6 +191,8 @@ def cleanup_after(request, io_loop):
|
||||
if not MockHub.initialized():
|
||||
return
|
||||
app = MockHub.instance()
|
||||
if app.db_file.closed:
|
||||
return
|
||||
for uid, user in list(app.users.items()):
|
||||
for name, spawner in list(user.spawners.items()):
|
||||
if spawner.active:
|
||||
|
@@ -333,26 +333,28 @@ class MockHub(JupyterHub):
|
||||
roles.assign_default_roles(self.db, entity=user)
|
||||
self.db.commit()
|
||||
|
||||
def stop(self):
|
||||
super().stop()
|
||||
_stop_called = False
|
||||
|
||||
def stop(self):
|
||||
if self._stop_called:
|
||||
return
|
||||
self._stop_called = True
|
||||
# run cleanup in a background thread
|
||||
# to avoid multiple eventloops in the same thread errors from asyncio
|
||||
|
||||
def cleanup():
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
loop = IOLoop.current()
|
||||
loop.run_sync(self.cleanup)
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.run_until_complete(self.cleanup())
|
||||
loop.close()
|
||||
|
||||
pool = ThreadPoolExecutor(1)
|
||||
f = pool.submit(cleanup)
|
||||
# wait for cleanup to finish
|
||||
f.result()
|
||||
pool.shutdown()
|
||||
with ThreadPoolExecutor(1) as pool:
|
||||
f = pool.submit(cleanup)
|
||||
# wait for cleanup to finish
|
||||
f.result()
|
||||
|
||||
# ignore the call that will fire in atexit
|
||||
self.cleanup = lambda: None
|
||||
# prevent redundant atexit from running
|
||||
self._atexit_ran = True
|
||||
super().stop()
|
||||
self.db_file.close()
|
||||
|
||||
async def login_user(self, name):
|
||||
|
@@ -9,6 +9,7 @@ from datetime import timedelta
|
||||
from unittest import mock
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlunparse
|
||||
|
||||
from pytest import fixture
|
||||
from pytest import mark
|
||||
@@ -65,7 +66,15 @@ async def test_auth_api(app):
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
async def test_cors_checks(app):
|
||||
@mark.parametrize(
|
||||
"content_type, status",
|
||||
[
|
||||
("text/plain", 403),
|
||||
# accepted, but invalid
|
||||
("application/json; charset=UTF-8", 400),
|
||||
],
|
||||
)
|
||||
async def test_post_content_type(app, content_type, status):
|
||||
url = ujoin(public_host(app), app.hub.base_url)
|
||||
host = urlparse(url).netloc
|
||||
# add admin user
|
||||
@@ -74,42 +83,6 @@ async def test_cors_checks(app):
|
||||
user = add_user(app.db, name='admin', admin=True)
|
||||
cookies = await app.login_user('admin')
|
||||
|
||||
r = await api_request(
|
||||
app, 'users', headers={'Authorization': '', 'Referer': 'null'}, cookies=cookies
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
r = await api_request(
|
||||
app,
|
||||
'users',
|
||||
headers={
|
||||
'Authorization': '',
|
||||
'Referer': 'http://attack.com/csrf/vulnerability',
|
||||
},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
r = await api_request(
|
||||
app,
|
||||
'users',
|
||||
headers={'Authorization': '', 'Referer': url, 'Host': host},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
r = await api_request(
|
||||
app,
|
||||
'users',
|
||||
headers={
|
||||
'Authorization': '',
|
||||
'Referer': ujoin(url, 'foo/bar/baz/bat'),
|
||||
'Host': host,
|
||||
},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
r = await api_request(
|
||||
app,
|
||||
'users',
|
||||
@@ -117,24 +90,115 @@ async def test_cors_checks(app):
|
||||
data='{}',
|
||||
headers={
|
||||
"Authorization": "",
|
||||
"Content-Type": "text/plain",
|
||||
"Content-Type": content_type,
|
||||
},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert r.status_code == status
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"host, referer, extraheaders, status",
|
||||
[
|
||||
('$host', '$url', {}, 200),
|
||||
(None, None, {}, 200),
|
||||
(None, 'null', {}, 403),
|
||||
(None, 'http://attack.com/csrf/vulnerability', {}, 403),
|
||||
('$host', {"path": "/user/someuser"}, {}, 403),
|
||||
('$host', {"path": "{path}/foo/bar/subpath"}, {}, 200),
|
||||
# mismatch host
|
||||
("mismatch.com", "$url", {}, 403),
|
||||
# explicit host, matches
|
||||
("fake.example", {"netloc": "fake.example"}, {}, 200),
|
||||
# explicit port, matches implicit port
|
||||
("fake.example:80", {"netloc": "fake.example"}, {}, 200),
|
||||
# explicit port, mismatch
|
||||
("fake.example:81", {"netloc": "fake.example"}, {}, 403),
|
||||
# implicit ports, mismatch proto
|
||||
("fake.example", {"netloc": "fake.example", "scheme": "https"}, {}, 403),
|
||||
# explicit ports, match
|
||||
("fake.example:81", {"netloc": "fake.example:81"}, {}, 200),
|
||||
# Test proxy protocol defined headers taken into account by utils.get_browser_protocol
|
||||
(
|
||||
"fake.example",
|
||||
{"netloc": "fake.example", "scheme": "https"},
|
||||
{'X-Scheme': 'https'},
|
||||
200,
|
||||
),
|
||||
(
|
||||
"fake.example",
|
||||
{"netloc": "fake.example", "scheme": "https"},
|
||||
{'X-Forwarded-Proto': 'https'},
|
||||
200,
|
||||
),
|
||||
(
|
||||
"fake.example",
|
||||
{"netloc": "fake.example", "scheme": "https"},
|
||||
{
|
||||
'Forwarded': 'host=fake.example;proto=https,for=1.2.34;proto=http',
|
||||
'X-Scheme': 'http',
|
||||
},
|
||||
200,
|
||||
),
|
||||
(
|
||||
"fake.example",
|
||||
{"netloc": "fake.example", "scheme": "https"},
|
||||
{
|
||||
'Forwarded': 'host=fake.example;proto=http,for=1.2.34;proto=http',
|
||||
'X-Scheme': 'https',
|
||||
},
|
||||
403,
|
||||
),
|
||||
("fake.example", {"netloc": "fake.example"}, {'X-Scheme': 'https'}, 403),
|
||||
("fake.example", {"netloc": "fake.example"}, {'X-Scheme': 'https, http'}, 403),
|
||||
],
|
||||
)
|
||||
async def test_cors_check(request, app, host, referer, extraheaders, status):
|
||||
url = ujoin(public_host(app), app.hub.base_url)
|
||||
real_host = urlparse(url).netloc
|
||||
if host == "$host":
|
||||
host = real_host
|
||||
|
||||
if referer == '$url':
|
||||
referer = url
|
||||
elif isinstance(referer, dict):
|
||||
parsed_url = urlparse(url)
|
||||
# apply {}
|
||||
url_ns = {key: getattr(parsed_url, key) for key in parsed_url._fields}
|
||||
for key, value in referer.items():
|
||||
referer[key] = value.format(**url_ns)
|
||||
referer = urlunparse(parsed_url._replace(**referer))
|
||||
|
||||
# disable default auth header, cors is for cookie auth
|
||||
headers = {"Authorization": ""}
|
||||
if host is not None:
|
||||
headers['X-Forwarded-Host'] = host
|
||||
if referer is not None:
|
||||
headers['Referer'] = referer
|
||||
headers.update(extraheaders)
|
||||
|
||||
# add admin user
|
||||
user = find_user(app.db, 'admin')
|
||||
if user is None:
|
||||
user = add_user(app.db, name='admin', admin=True)
|
||||
cookies = await app.login_user('admin')
|
||||
|
||||
# test custom forwarded_host_header behavior
|
||||
app.forwarded_host_header = 'X-Forwarded-Host'
|
||||
|
||||
# reset the config after the test to avoid leaking state
|
||||
def reset_header():
|
||||
app.forwarded_host_header = ""
|
||||
|
||||
request.addfinalizer(reset_header)
|
||||
|
||||
r = await api_request(
|
||||
app,
|
||||
'users',
|
||||
method='post',
|
||||
data='{}',
|
||||
headers={
|
||||
"Authorization": "",
|
||||
"Content-Type": "application/json; charset=UTF-8",
|
||||
},
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 400 # accepted, but invalid
|
||||
assert r.status_code == status
|
||||
|
||||
|
||||
# --------------
|
||||
@@ -160,6 +224,8 @@ def normalize_user(user):
|
||||
"""
|
||||
for key in ('created', 'last_activity'):
|
||||
user[key] = normalize_timestamp(user[key])
|
||||
if 'roles' in user:
|
||||
user['roles'] = sorted(user['roles'])
|
||||
if 'servers' in user:
|
||||
for server in user['servers'].values():
|
||||
for key in ('started', 'last_activity'):
|
||||
@@ -212,7 +278,12 @@ async def test_get_users(app):
|
||||
}
|
||||
assert users == [
|
||||
fill_user(
|
||||
{'name': 'admin', 'admin': True, 'roles': ['admin'], 'auth_state': None}
|
||||
{
|
||||
'name': 'admin',
|
||||
'admin': True,
|
||||
'roles': ['admin', 'user'],
|
||||
'auth_state': None,
|
||||
}
|
||||
),
|
||||
fill_user(user_model),
|
||||
]
|
||||
@@ -400,6 +471,42 @@ async def test_get_users_state_filter(app, state):
|
||||
assert usernames == expected
|
||||
|
||||
|
||||
@mark.user
|
||||
async def test_get_users_name_filter(app):
|
||||
db = app.db
|
||||
|
||||
add_user(db, app=app, name='q')
|
||||
add_user(db, app=app, name='qr')
|
||||
add_user(db, app=app, name='qrs')
|
||||
add_user(db, app=app, name='qrst')
|
||||
added_usernames = {'q', 'qr', 'qrs', 'qrst'}
|
||||
|
||||
r = await api_request(app, 'users')
|
||||
assert r.status_code == 200
|
||||
response_users = [u.get("name") for u in r.json()]
|
||||
assert added_usernames.intersection(response_users) == added_usernames
|
||||
|
||||
r = await api_request(app, 'users?name_filter=q')
|
||||
assert r.status_code == 200
|
||||
response_users = [u.get("name") for u in r.json()]
|
||||
assert response_users == ['q', 'qr', 'qrs', 'qrst']
|
||||
|
||||
r = await api_request(app, 'users?name_filter=qr')
|
||||
assert r.status_code == 200
|
||||
response_users = [u.get("name") for u in r.json()]
|
||||
assert response_users == ['qr', 'qrs', 'qrst']
|
||||
|
||||
r = await api_request(app, 'users?name_filter=qrs')
|
||||
assert r.status_code == 200
|
||||
response_users = [u.get("name") for u in r.json()]
|
||||
assert response_users == ['qrs', 'qrst']
|
||||
|
||||
r = await api_request(app, 'users?name_filter=qrst')
|
||||
assert r.status_code == 200
|
||||
response_users = [u.get("name") for u in r.json()]
|
||||
assert response_users == ['qrst']
|
||||
|
||||
|
||||
@mark.user
|
||||
async def test_get_self(app):
|
||||
db = app.db
|
||||
@@ -597,7 +704,7 @@ async def test_add_multi_user_admin(app):
|
||||
assert user is not None
|
||||
assert user.name == name
|
||||
assert user.admin
|
||||
assert orm.Role.find(db, 'user') not in user.roles
|
||||
assert orm.Role.find(db, 'user') in user.roles
|
||||
assert orm.Role.find(db, 'admin') in user.roles
|
||||
|
||||
|
||||
@@ -637,7 +744,7 @@ async def test_add_admin(app):
|
||||
assert user.name == name
|
||||
assert user.admin
|
||||
# assert newadmin has default 'admin' role
|
||||
assert orm.Role.find(db, 'user') not in user.roles
|
||||
assert orm.Role.find(db, 'user') in user.roles
|
||||
assert orm.Role.find(db, 'admin') in user.roles
|
||||
|
||||
|
||||
@@ -672,7 +779,7 @@ async def test_make_admin(app):
|
||||
assert user is not None
|
||||
assert user.name == name
|
||||
assert user.admin
|
||||
assert orm.Role.find(db, 'user') not in user.roles
|
||||
assert orm.Role.find(db, 'user') in user.roles
|
||||
assert orm.Role.find(db, 'admin') in user.roles
|
||||
|
||||
|
||||
@@ -959,7 +1066,7 @@ async def test_never_spawn(app, no_patience, never_spawn):
|
||||
assert not app_user.spawner._spawn_pending
|
||||
status = await app_user.spawner.poll()
|
||||
assert status is not None
|
||||
# failed spawn should decrements pending count
|
||||
# failed spawn should decrement pending count
|
||||
assert app.users.count_active_users()['pending'] == 0
|
||||
|
||||
|
||||
@@ -968,9 +1075,16 @@ async def test_bad_spawn(app, bad_spawn):
|
||||
name = 'prim'
|
||||
user = add_user(db, app=app, name=name)
|
||||
r = await api_request(app, 'users', name, 'server', method='post')
|
||||
# check that we don't re-use spawners that failed
|
||||
user.spawners[''].reused = True
|
||||
assert r.status_code == 500
|
||||
assert app.users.count_active_users()['pending'] == 0
|
||||
|
||||
r = await api_request(app, 'users', name, 'server', method='post')
|
||||
# check that we don't re-use spawners that failed
|
||||
spawner = user.spawners['']
|
||||
assert not getattr(spawner, 'reused', False)
|
||||
|
||||
|
||||
async def test_spawn_nosuch_user(app):
|
||||
r = await api_request(app, 'users', "nosuchuser", 'server', method='post')
|
||||
@@ -1735,6 +1849,38 @@ async def test_group_add_delete_users(app):
|
||||
assert sorted(u.name for u in group.users) == sorted(names[2:])
|
||||
|
||||
|
||||
@mark.group
|
||||
async def test_auth_managed_groups(request, app, group, user):
|
||||
group.users.append(user)
|
||||
app.db.commit()
|
||||
app.authenticator.manage_groups = True
|
||||
request.addfinalizer(lambda: setattr(app.authenticator, "manage_groups", False))
|
||||
# create groups
|
||||
r = await api_request(app, 'groups', method='post')
|
||||
assert r.status_code == 400
|
||||
r = await api_request(app, 'groups/newgroup', method='post')
|
||||
assert r.status_code == 400
|
||||
# delete groups
|
||||
r = await api_request(app, f'groups/{group.name}', method='delete')
|
||||
assert r.status_code == 400
|
||||
# add users to group
|
||||
r = await api_request(
|
||||
app,
|
||||
f'groups/{group.name}/users',
|
||||
method='post',
|
||||
data=json.dumps({"users": [user.name]}),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
# remove users from group
|
||||
r = await api_request(
|
||||
app,
|
||||
f'groups/{group.name}/users',
|
||||
method='delete',
|
||||
data=json.dumps({"users": [user.name]}),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# -----------------
|
||||
# Service API tests
|
||||
# -----------------
|
||||
@@ -1958,14 +2104,23 @@ def test_shutdown(app):
|
||||
)
|
||||
return r
|
||||
|
||||
real_stop = loop.stop
|
||||
real_stop = loop.asyncio_loop.stop
|
||||
|
||||
def stop():
|
||||
stop.called = True
|
||||
loop.call_later(1, real_stop)
|
||||
|
||||
with mock.patch.object(loop, 'stop', stop):
|
||||
real_cleanup = app.cleanup
|
||||
|
||||
def cleanup():
|
||||
cleanup.called = True
|
||||
return real_cleanup()
|
||||
|
||||
app.cleanup = cleanup
|
||||
|
||||
with mock.patch.object(loop.asyncio_loop, 'stop', stop):
|
||||
r = loop.run_sync(shutdown, timeout=5)
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert cleanup.called
|
||||
assert stop.called
|
||||
|
@@ -6,7 +6,6 @@ import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from distutils.version import LooseVersion as V
|
||||
from subprocess import check_output
|
||||
from subprocess import PIPE
|
||||
from subprocess import Popen
|
||||
@@ -33,7 +32,7 @@ def test_help_all():
|
||||
assert '--JupyterHub.ip' in out
|
||||
|
||||
|
||||
@pytest.mark.skipif(V(traitlets.__version__) < V('5'), reason="requires traitlets 5")
|
||||
@pytest.mark.skipif(traitlets.version_info < (5,), reason="requires traitlets 5")
|
||||
def test_show_config(tmpdir):
|
||||
tmpdir.chdir()
|
||||
p = Popen(
|
||||
@@ -247,6 +246,7 @@ async def test_load_groups(tmpdir, request):
|
||||
kwargs['internal_certs_location'] = str(tmpdir)
|
||||
hub = MockHub(**kwargs)
|
||||
hub.init_db()
|
||||
await hub.init_role_creation()
|
||||
await hub.init_users()
|
||||
await hub.init_groups()
|
||||
db = hub.db
|
||||
|
@@ -7,6 +7,7 @@ from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
from requests import HTTPError
|
||||
from traitlets import Any
|
||||
from traitlets.config import Config
|
||||
|
||||
from .mocking import MockPAMAuthenticator
|
||||
@@ -14,6 +15,7 @@ from .mocking import MockStructGroup
|
||||
from .mocking import MockStructPasswd
|
||||
from .utils import add_user
|
||||
from .utils import async_requests
|
||||
from .utils import get_page
|
||||
from .utils import public_url
|
||||
from jupyterhub import auth
|
||||
from jupyterhub import crypto
|
||||
@@ -527,3 +529,71 @@ async def test_nullauthenticator(app):
|
||||
r = await async_requests.get(public_url(app))
|
||||
assert urlparse(r.url).path.endswith("/hub/login")
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
class MockGroupsAuthenticator(auth.Authenticator):
|
||||
authenticated_groups = Any()
|
||||
refresh_groups = Any()
|
||||
|
||||
manage_groups = True
|
||||
|
||||
def authenticate(self, handler, data):
|
||||
return {
|
||||
"name": data["username"],
|
||||
"groups": self.authenticated_groups,
|
||||
}
|
||||
|
||||
async def refresh_user(self, user, handler):
|
||||
return {
|
||||
"name": user.name,
|
||||
"groups": self.refresh_groups,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"authenticated_groups, refresh_groups",
|
||||
[
|
||||
(None, None),
|
||||
(["auth1"], None),
|
||||
(None, ["auth1"]),
|
||||
(["auth1"], ["auth1", "auth2"]),
|
||||
(["auth1", "auth2"], ["auth1"]),
|
||||
(["auth1", "auth2"], ["auth3"]),
|
||||
(["auth1", "auth2"], ["auth3"]),
|
||||
],
|
||||
)
|
||||
async def test_auth_managed_groups(
|
||||
app, user, group, authenticated_groups, refresh_groups
|
||||
):
|
||||
|
||||
authenticator = MockGroupsAuthenticator(
|
||||
parent=app,
|
||||
authenticated_groups=authenticated_groups,
|
||||
refresh_groups=refresh_groups,
|
||||
)
|
||||
|
||||
user.groups.append(group)
|
||||
app.db.commit()
|
||||
before_groups = [group.name]
|
||||
if authenticated_groups is None:
|
||||
expected_authenticated_groups = before_groups
|
||||
else:
|
||||
expected_authenticated_groups = authenticated_groups
|
||||
if refresh_groups is None:
|
||||
expected_refresh_groups = expected_authenticated_groups
|
||||
else:
|
||||
expected_refresh_groups = refresh_groups
|
||||
|
||||
with mock.patch.dict(app.tornado_settings, {"authenticator": authenticator}):
|
||||
cookies = await app.login_user(user.name)
|
||||
assert not app.db.dirty
|
||||
groups = sorted(g.name for g in user.groups)
|
||||
assert groups == expected_authenticated_groups
|
||||
|
||||
# force refresh_user on next request
|
||||
user._auth_refreshed -= 10 + app.authenticator.auth_refresh_age
|
||||
r = await get_page('home', app, cookies=cookies, allow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
assert not app.db.dirty
|
||||
groups = sorted(g.name for g in user.groups)
|
||||
assert groups == expected_refresh_groups
|
||||
|
@@ -1,9 +1,13 @@
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from .utils import add_user
|
||||
from .utils import api_request
|
||||
from .utils import get_page
|
||||
from jupyterhub import metrics
|
||||
from jupyterhub import orm
|
||||
from jupyterhub import roles
|
||||
|
||||
|
||||
async def test_total_users(app):
|
||||
@@ -32,3 +36,42 @@ async def test_total_users(app):
|
||||
|
||||
sample = metrics.TOTAL_USERS.collect()[0].samples[0]
|
||||
assert sample.value == num_users
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"authenticate_prometheus, authenticated, authorized, success",
|
||||
[
|
||||
(True, True, True, True),
|
||||
(True, True, False, False),
|
||||
(True, False, False, False),
|
||||
(False, True, True, True),
|
||||
(False, False, False, True),
|
||||
],
|
||||
)
|
||||
async def test_metrics_auth(
|
||||
app,
|
||||
authenticate_prometheus,
|
||||
authenticated,
|
||||
authorized,
|
||||
success,
|
||||
create_temp_role,
|
||||
user,
|
||||
):
|
||||
if authorized:
|
||||
role = create_temp_role(["read:metrics"])
|
||||
roles.grant_role(app.db, user, role)
|
||||
|
||||
headers = {}
|
||||
if authenticated:
|
||||
token = user.new_api_token()
|
||||
headers["Authorization"] = f"token {token}"
|
||||
|
||||
with mock.patch.dict(
|
||||
app.tornado_settings, {"authenticate_prometheus": authenticate_prometheus}
|
||||
):
|
||||
r = await get_page("metrics", app, headers=headers)
|
||||
if success:
|
||||
assert r.status_code == 200
|
||||
else:
|
||||
assert r.status_code == 403
|
||||
assert 'read:metrics' in r.text
|
||||
|
@@ -12,6 +12,7 @@ from tornado.escape import url_escape
|
||||
from tornado.httputil import url_concat
|
||||
|
||||
from .. import orm
|
||||
from .. import roles
|
||||
from .. import scopes
|
||||
from ..auth import Authenticator
|
||||
from ..handlers import BaseHandler
|
||||
@@ -20,7 +21,6 @@ from ..utils import url_path_join as ujoin
|
||||
from .mocking import FalsyCallableFormSpawner
|
||||
from .mocking import FormSpawner
|
||||
from .test_api import next_event
|
||||
from .utils import add_user
|
||||
from .utils import api_request
|
||||
from .utils import async_requests
|
||||
from .utils import AsyncSession
|
||||
@@ -48,16 +48,16 @@ async def test_root_auth(app):
|
||||
# if spawning was quick, there will be one more entry that's public_url(user)
|
||||
|
||||
|
||||
async def test_root_redirect(app):
|
||||
async def test_root_redirect(app, user):
|
||||
name = 'wash'
|
||||
cookies = await app.login_user(name)
|
||||
next_url = ujoin(app.base_url, 'user/other/test.ipynb')
|
||||
next_url = ujoin(app.base_url, f'user/{user.name}/test.ipynb')
|
||||
url = '/?' + urlencode({'next': next_url})
|
||||
r = await get_page(url, app, cookies=cookies)
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
|
||||
# serve "server not running" page, which has status 424
|
||||
assert r.status_code == 424
|
||||
assert path == ujoin(app.base_url, f'hub/user/{user.name}/test.ipynb')
|
||||
# preserves choice to requested user, which 404s as unavailable without access
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_root_default_url_noauth(app):
|
||||
@@ -128,11 +128,20 @@ async def test_admin_sort(app, sort):
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
async def test_spawn_redirect(app):
|
||||
@pytest.mark.parametrize("last_failed", [True, False])
|
||||
async def test_spawn_redirect(app, last_failed):
|
||||
name = 'wash'
|
||||
cookies = await app.login_user(name)
|
||||
u = app.users[orm.User.find(app.db, name)]
|
||||
|
||||
if last_failed:
|
||||
# mock a failed spawn
|
||||
last_spawner = u.spawners['']
|
||||
last_spawner._spawn_future = asyncio.Future()
|
||||
last_spawner._spawn_future.set_exception(RuntimeError("I failed!"))
|
||||
else:
|
||||
last_spawner = None
|
||||
|
||||
status = await u.spawner.poll()
|
||||
assert status is not None
|
||||
|
||||
@@ -141,6 +150,10 @@ async def test_spawn_redirect(app):
|
||||
r.raise_for_status()
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
|
||||
# ensure we got a new spawner
|
||||
assert u.spawners[''] is not last_spawner
|
||||
|
||||
# make sure we visited hub/spawn-pending after spawn
|
||||
# if spawn was really quick, we might get redirected all the way to the running server,
|
||||
# so check history instead of r.url
|
||||
@@ -203,13 +216,34 @@ async def test_spawn_handler_access(app):
|
||||
r.raise_for_status()
|
||||
|
||||
|
||||
async def test_spawn_admin_access(app, admin_access):
|
||||
"""GET /user/:name as admin with admin-access spawns user's server"""
|
||||
cookies = await app.login_user('admin')
|
||||
name = 'mariel'
|
||||
user = add_user(app.db, app=app, name=name)
|
||||
app.db.commit()
|
||||
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
|
||||
async def test_spawn_other_user(
|
||||
app, user, username, group, create_temp_role, has_access
|
||||
):
|
||||
"""GET /user/:name as another user with access to spawns user's server"""
|
||||
cookies = await app.login_user(username)
|
||||
requester = app.users[username]
|
||||
name = user.name
|
||||
|
||||
if has_access:
|
||||
if has_access == "group":
|
||||
group.users.append(user)
|
||||
app.db.commit()
|
||||
scopes = [
|
||||
f"access:servers!group={group.name}",
|
||||
f"servers!group={group.name}",
|
||||
]
|
||||
elif has_access == "all":
|
||||
scopes = ["access:servers", "servers"]
|
||||
elif has_access == "user":
|
||||
scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"]
|
||||
role = create_temp_role(scopes)
|
||||
roles.grant_role(app.db, requester, role)
|
||||
|
||||
r = await get_page('spawn/' + name, app, cookies=cookies)
|
||||
if not has_access:
|
||||
assert r.status_code == 404
|
||||
return
|
||||
r.raise_for_status()
|
||||
|
||||
while '/spawn-pending/' in r.url:
|
||||
@@ -237,6 +271,25 @@ async def test_spawn_page(app):
|
||||
assert FormSpawner.options_form in r.text
|
||||
|
||||
|
||||
async def test_spawn_page_after_failed(app, user):
|
||||
cookies = await app.login_user(user.name)
|
||||
|
||||
# mock a failed spawn
|
||||
last_spawner = user.spawners['']
|
||||
last_spawner._spawn_future = asyncio.Future()
|
||||
last_spawner._spawn_future.set_exception(RuntimeError("I failed!"))
|
||||
|
||||
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
||||
r = await get_page('spawn', app, cookies=cookies)
|
||||
spawner = user.spawners['']
|
||||
# make sure we didn't reuse last spawner
|
||||
assert isinstance(spawner, FormSpawner)
|
||||
assert spawner is not last_spawner
|
||||
assert r.url.endswith('/spawn')
|
||||
spawner = user.spawners['']
|
||||
assert FormSpawner.options_form in r.text
|
||||
|
||||
|
||||
async def test_spawn_page_falsy_callable(app):
|
||||
with mock.patch.dict(
|
||||
app.users.settings, {'spawner_class': FalsyCallableFormSpawner}
|
||||
@@ -248,14 +301,36 @@ async def test_spawn_page_falsy_callable(app):
|
||||
assert history[1] == ujoin(public_url(app), "hub/spawn-pending/erik")
|
||||
|
||||
|
||||
async def test_spawn_page_admin(app, admin_access):
|
||||
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
|
||||
async def test_spawn_page_access(
|
||||
app, has_access, group, username, user, create_temp_role
|
||||
):
|
||||
cookies = await app.login_user(username)
|
||||
requester = app.users[username]
|
||||
if has_access:
|
||||
if has_access == "group":
|
||||
group.users.append(user)
|
||||
app.db.commit()
|
||||
scopes = [
|
||||
f"access:servers!group={group.name}",
|
||||
f"servers!group={group.name}",
|
||||
]
|
||||
elif has_access == "all":
|
||||
scopes = ["access:servers", "servers"]
|
||||
elif has_access == "user":
|
||||
scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"]
|
||||
role = create_temp_role(scopes)
|
||||
roles.grant_role(app.db, requester, role)
|
||||
|
||||
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
||||
cookies = await app.login_user('admin')
|
||||
u = add_user(app.db, app=app, name='melanie')
|
||||
r = await get_page('spawn/' + u.name, app, cookies=cookies)
|
||||
assert r.url.endswith('/spawn/' + u.name)
|
||||
r = await get_page('spawn/' + user.name, app, cookies=cookies)
|
||||
if not has_access:
|
||||
assert r.status_code == 404
|
||||
return
|
||||
assert r.status_code == 200
|
||||
assert r.url.endswith('/spawn/' + user.name)
|
||||
assert FormSpawner.options_form in r.text
|
||||
assert f"Spawning server for {u.name}" in r.text
|
||||
assert f"Spawning server for {user.name}" in r.text
|
||||
|
||||
|
||||
async def test_spawn_with_query_arguments(app):
|
||||
@@ -322,18 +397,39 @@ async def test_spawn_form(app):
|
||||
}
|
||||
|
||||
|
||||
async def test_spawn_form_admin_access(app, admin_access):
|
||||
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
|
||||
async def test_spawn_form_other_user(
|
||||
app, username, user, group, create_temp_role, has_access
|
||||
):
|
||||
cookies = await app.login_user(username)
|
||||
requester = app.users[username]
|
||||
if has_access:
|
||||
if has_access == "group":
|
||||
group.users.append(user)
|
||||
app.db.commit()
|
||||
scopes = [
|
||||
f"access:servers!group={group.name}",
|
||||
f"servers!group={group.name}",
|
||||
]
|
||||
elif has_access == "all":
|
||||
scopes = ["access:servers", "servers"]
|
||||
elif has_access == "user":
|
||||
scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"]
|
||||
role = create_temp_role(scopes)
|
||||
roles.grant_role(app.db, requester, role)
|
||||
|
||||
with mock.patch.dict(app.tornado_settings, {'spawner_class': FormSpawner}):
|
||||
base_url = ujoin(public_host(app), app.hub.base_url)
|
||||
cookies = await app.login_user('admin')
|
||||
u = add_user(app.db, app=app, name='martha')
|
||||
next_url = ujoin(app.base_url, 'user', u.name, 'tree')
|
||||
next_url = ujoin(app.base_url, 'user', user.name, 'tree')
|
||||
|
||||
r = await async_requests.post(
|
||||
url_concat(ujoin(base_url, 'spawn', u.name), {'next': next_url}),
|
||||
url_concat(ujoin(base_url, 'spawn', user.name), {'next': next_url}),
|
||||
cookies=cookies,
|
||||
data={'bounds': ['-3', '3'], 'energy': '938MeV'},
|
||||
)
|
||||
if not has_access:
|
||||
assert r.status_code == 404
|
||||
return
|
||||
r.raise_for_status()
|
||||
|
||||
while '/spawn-pending/' in r.url:
|
||||
@@ -342,8 +438,8 @@ async def test_spawn_form_admin_access(app, admin_access):
|
||||
r.raise_for_status()
|
||||
|
||||
assert r.history
|
||||
assert r.url.startswith(public_url(app, u))
|
||||
assert u.spawner.user_options == {
|
||||
assert r.url.startswith(public_url(app, user))
|
||||
assert user.spawner.user_options == {
|
||||
'energy': '938MeV',
|
||||
'bounds': [-3, 3],
|
||||
'notspecified': 5,
|
||||
@@ -498,31 +594,54 @@ async def test_user_redirect_hook(app, username):
|
||||
assert redirected_url.path == ujoin(app.base_url, 'user', username, 'terminals/1')
|
||||
|
||||
|
||||
async def test_user_redirect_deprecated(app, username):
|
||||
"""redirecting from /user/someonelse/ URLs (deprecated)"""
|
||||
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
|
||||
async def test_other_user_url(app, username, user, group, create_temp_role, has_access):
|
||||
"""Test accessing /user/someonelse/ URLs when the server is not running
|
||||
|
||||
Used to redirect to your own server,
|
||||
which produced inconsistent behavior depending on whether the server was running.
|
||||
"""
|
||||
name = username
|
||||
cookies = await app.login_user(name)
|
||||
other_user = user
|
||||
requester = app.users[name]
|
||||
other_user_url = f"/user/{other_user.name}"
|
||||
if has_access:
|
||||
if has_access == "group":
|
||||
group.users.append(other_user)
|
||||
app.db.commit()
|
||||
scopes = [f"access:servers!group={group.name}"]
|
||||
elif has_access == "all":
|
||||
scopes = ["access:servers"]
|
||||
elif has_access == "user":
|
||||
scopes = [f"access:servers!user={other_user.name}"]
|
||||
role = create_temp_role(scopes)
|
||||
roles.grant_role(app.db, requester, role)
|
||||
status = 424
|
||||
else:
|
||||
# 404 - access denied without revealing if the user exists
|
||||
status = 404
|
||||
|
||||
r = await get_page('/user/baduser', app, cookies=cookies, hub=False)
|
||||
r = await get_page(other_user_url, app, cookies=cookies, hub=False)
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
|
||||
assert r.status_code == 424
|
||||
assert path == ujoin(app.base_url, f'hub/user/{other_user.name}/')
|
||||
assert r.status_code == status
|
||||
|
||||
r = await get_page('/user/baduser/test.ipynb', app, cookies=cookies, hub=False)
|
||||
r = await get_page(f'{other_user_url}/test.ipynb', app, cookies=cookies, hub=False)
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
|
||||
assert r.status_code == 424
|
||||
assert path == ujoin(app.base_url, f'hub/user/{other_user.name}/test.ipynb')
|
||||
assert r.status_code == status
|
||||
|
||||
r = await get_page('/user/baduser/test.ipynb', app, hub=False)
|
||||
r = await get_page(f'{other_user_url}/test.ipynb', app, hub=False)
|
||||
r.raise_for_status()
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, '/hub/login')
|
||||
query = urlparse(r.url).query
|
||||
assert query == urlencode(
|
||||
{'next': ujoin(app.base_url, '/hub/user/baduser/test.ipynb')}
|
||||
{'next': ujoin(app.base_url, f'/hub/user/{other_user.name}/test.ipynb')}
|
||||
)
|
||||
|
||||
|
||||
@@ -652,6 +771,10 @@ async def test_login_strip(app):
|
||||
(False, '/user/other', '/hub/user/other', None),
|
||||
(False, '/absolute', '/absolute', None),
|
||||
(False, '/has?query#andhash', '/has?query#andhash', None),
|
||||
# :// in query string or fragment
|
||||
(False, '/has?repo=https/host.git', '/has?repo=https/host.git', None),
|
||||
(False, '/has?repo=https://host.git', '/has?repo=https://host.git', None),
|
||||
(False, '/has#repo=https://host.git', '/has#repo=https://host.git', None),
|
||||
# next_url outside is not allowed
|
||||
(False, 'relative/path', '', None),
|
||||
(False, 'https://other.domain', '', None),
|
||||
@@ -691,7 +814,9 @@ async def test_login_redirect(app, running, next_url, location, params):
|
||||
if params:
|
||||
url = url_concat(url, params)
|
||||
if next_url:
|
||||
if '//' not in next_url and next_url.startswith('/'):
|
||||
if next_url.startswith('/') and not (
|
||||
next_url.startswith("//") or urlparse(next_url).netloc
|
||||
):
|
||||
next_url = ujoin(app.base_url, next_url, '')
|
||||
url = url_concat(url, dict(next=next_url))
|
||||
|
||||
@@ -1110,26 +1235,13 @@ async def test_server_not_running_api_request_legacy_status(app):
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
async def test_metrics_no_auth(app):
|
||||
r = await get_page("metrics", app)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
async def test_metrics_auth(app):
|
||||
cookies = await app.login_user('river')
|
||||
metrics_url = ujoin(public_host(app), app.hub.base_url, 'metrics')
|
||||
r = await get_page("metrics", app, cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
assert r.url == metrics_url
|
||||
|
||||
|
||||
async def test_health_check_request(app):
|
||||
r = await get_page('health', app)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
async def test_pre_spawn_start_exc_no_form(app):
|
||||
exc = "pre_spawn_start error"
|
||||
exc = "Unhandled error starting server"
|
||||
|
||||
# throw exception from pre_spawn_start
|
||||
async def mock_pre_spawn_start(user, spawner):
|
||||
|
@@ -443,7 +443,14 @@ async def test_scope_existence(tmpdir, request, role, response):
|
||||
|
||||
|
||||
@mark.role
|
||||
async def test_load_roles_users(tmpdir, request):
|
||||
@mark.parametrize(
|
||||
"explicit_allowed_users",
|
||||
[
|
||||
(True,),
|
||||
(False,),
|
||||
],
|
||||
)
|
||||
async def test_load_roles_users(tmpdir, request, explicit_allowed_users):
|
||||
"""Test loading predefined roles for users in app.py"""
|
||||
roles_to_load = [
|
||||
{
|
||||
@@ -461,7 +468,8 @@ async def test_load_roles_users(tmpdir, request):
|
||||
hub.init_db()
|
||||
db = hub.db
|
||||
hub.authenticator.admin_users = ['admin']
|
||||
hub.authenticator.allowed_users = ['cyclops', 'gandalf', 'bilbo', 'gargamel']
|
||||
if explicit_allowed_users:
|
||||
hub.authenticator.allowed_users = ['cyclops', 'gandalf', 'bilbo', 'gargamel']
|
||||
await hub.init_role_creation()
|
||||
await hub.init_users()
|
||||
await hub.init_role_assignment()
|
||||
@@ -665,7 +673,11 @@ async def test_load_roles_user_tokens(tmpdir, request):
|
||||
# no role requested - gets default 'token' role
|
||||
({}, None, None, 201),
|
||||
# role scopes within the user's default 'user' role
|
||||
({}, 'self-reader', ['read:users'], 201),
|
||||
({}, 'self-reader', ['read:users!user'], 201),
|
||||
# role scopes within the user's default 'user' role, but with disjoint filter
|
||||
({}, 'other-reader', ['read:users!user=other'], 403),
|
||||
# role scopes within the user's default 'user' role, without filter
|
||||
({}, 'other-reader', ['read:users'], 403),
|
||||
# role scopes outside of the user's role but within the group's role scopes of which the user is a member
|
||||
({}, 'groups-reader', ['read:groups'], 201),
|
||||
# non-existing role request
|
||||
@@ -1183,14 +1195,47 @@ async def test_no_admin_role_change():
|
||||
await hub.init_role_creation()
|
||||
|
||||
|
||||
async def test_user_config_respects_memberships():
|
||||
@pytest.mark.parametrize(
|
||||
"in_db, role_users, allowed_users, expected_members",
|
||||
[
|
||||
# users in the db, not specified in custom user role
|
||||
# no change to membership
|
||||
(["alpha", "beta"], None, None, ["alpha", "beta"]),
|
||||
# allowed_users is additive, not strict
|
||||
(["alpha", "beta"], None, {"gamma"}, ["alpha", "beta", "gamma"]),
|
||||
# explicit empty revokes all assignments
|
||||
(["alpha", "beta"], [], None, []),
|
||||
# explicit value is respected exactly
|
||||
(["alpha", "beta"], ["alpha", "gamma"], None, ["alpha", "gamma"]),
|
||||
],
|
||||
)
|
||||
async def test_user_role_from_config(
|
||||
in_db, role_users, allowed_users, expected_members
|
||||
):
|
||||
role_spec = {
|
||||
'name': 'user',
|
||||
'scopes': ['self', 'shutdown'],
|
||||
}
|
||||
if role_users is not None:
|
||||
role_spec['users'] = role_users
|
||||
hub = MockHub(load_roles=[role_spec])
|
||||
hub.init_db()
|
||||
db = hub.db
|
||||
hub.authenticator.admin_users = set()
|
||||
if allowed_users:
|
||||
hub.authenticator.allowed_users = allowed_users
|
||||
await hub.init_role_creation()
|
||||
|
||||
|
||||
async def test_user_config_creates_default_role():
|
||||
role_spec = [
|
||||
{
|
||||
'name': 'user',
|
||||
'scopes': ['self', 'shutdown'],
|
||||
'name': 'new-role',
|
||||
'scopes': ['read:users'],
|
||||
'users': ['not-yet-created-user'],
|
||||
}
|
||||
]
|
||||
user_names = ['eddy', 'carol']
|
||||
user_names = []
|
||||
hub = MockHub(load_roles=role_spec)
|
||||
hub.init_db()
|
||||
hub.authenticator.allowed_users = user_names
|
||||
@@ -1198,9 +1243,9 @@ async def test_user_config_respects_memberships():
|
||||
await hub.init_users()
|
||||
await hub.init_role_assignment()
|
||||
user_role = orm.Role.find(hub.db, 'user')
|
||||
for user_name in user_names:
|
||||
user = orm.User.find(hub.db, user_name)
|
||||
assert user in user_role.users
|
||||
new_role = orm.Role.find(hub.db, 'new-role')
|
||||
assert orm.User.find(hub.db, 'not-yet-created-user') in new_role.users
|
||||
assert orm.User.find(hub.db, 'not-yet-created-user') in user_role.users
|
||||
|
||||
|
||||
async def test_admin_role_respects_config():
|
||||
@@ -1222,16 +1267,45 @@ async def test_admin_role_respects_config():
|
||||
assert user in admin_role.users
|
||||
|
||||
|
||||
async def test_empty_admin_spec():
|
||||
role_spec = [{'name': 'admin', 'users': []}]
|
||||
hub = MockHub(load_roles=role_spec)
|
||||
@pytest.mark.parametrize(
|
||||
"in_db, role_users, admin_users, expected_members",
|
||||
[
|
||||
# users in the db, not specified in custom user role
|
||||
# no change to membership
|
||||
(["alpha", "beta"], None, None, ["alpha", "beta"]),
|
||||
# admin_users is additive, not strict
|
||||
(["alpha", "beta"], None, {"gamma"}, ["alpha", "beta", "gamma"]),
|
||||
# explicit empty revokes all assignments
|
||||
(["alpha", "beta"], [], None, []),
|
||||
# explicit value is respected exactly
|
||||
(["alpha", "beta"], ["alpha", "gamma"], None, ["alpha", "gamma"]),
|
||||
],
|
||||
)
|
||||
async def test_admin_role_membership(in_db, role_users, admin_users, expected_members):
|
||||
|
||||
load_roles = []
|
||||
if role_users is not None:
|
||||
load_roles.append({"name": "admin", "users": role_users})
|
||||
if not admin_users:
|
||||
admin_users = set()
|
||||
hub = MockHub(load_roles=load_roles, db_url="sqlite:///:memory:")
|
||||
hub.init_db()
|
||||
hub.authenticator.admin_users = []
|
||||
await hub.init_role_creation()
|
||||
db = hub.db
|
||||
hub.authenticator.admin_users = admin_users
|
||||
# add in_db users to the database
|
||||
# this is the 'before' state of who had the role before startup
|
||||
for username in in_db or []:
|
||||
user = orm.User(name=username)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
roles.grant_role(db, user, "admin")
|
||||
db.commit()
|
||||
await hub.init_users()
|
||||
await hub.init_role_assignment()
|
||||
admin_role = orm.Role.find(hub.db, 'admin')
|
||||
assert not admin_role.users
|
||||
admin_role = orm.Role.find(db, 'admin')
|
||||
role_members = sorted(user.name for user in admin_role.users)
|
||||
assert role_members == expected_members
|
||||
|
||||
|
||||
async def test_no_default_service_role():
|
||||
@@ -1344,7 +1418,8 @@ async def test_login_default_role(app, username):
|
||||
user.roles = []
|
||||
app.db.commit()
|
||||
|
||||
# login *again*; user exists, shouldn't trigger change in roles
|
||||
# login *again*; user exists,
|
||||
# login should always trigger "user" role assignment
|
||||
cookies = await app.login_user(username)
|
||||
user = app.users[username]
|
||||
assert user.roles == []
|
||||
assert [role.name for role in user.roles] == ["user"]
|
||||
|
@@ -677,9 +677,14 @@ async def test_resolve_token_permissions(
|
||||
intersection_scopes,
|
||||
):
|
||||
orm_user = create_user_with_scopes(*user_scopes).orm_user
|
||||
# ensure user has full permissions when token is created
|
||||
# to create tokens with permissions exceeding their owner
|
||||
roles.grant_role(app.db, orm_user, "admin")
|
||||
create_temp_role(token_scopes, 'active-posting')
|
||||
api_token = orm_user.new_api_token(roles=['active-posting'])
|
||||
orm_api_token = orm.APIToken.find(app.db, token=api_token)
|
||||
# drop admin so that filtering can be applied
|
||||
roles.strip_role(app.db, orm_user, "admin")
|
||||
|
||||
# get expanded !user filter scopes for check
|
||||
user_scopes = roles.expand_roles_to_scopes(orm_user)
|
||||
|
@@ -385,8 +385,8 @@ async def test_oauth_page_hit(
|
||||
hits_page,
|
||||
):
|
||||
test_roles = {
|
||||
'reader': create_temp_role(['read:users'], role_name='reader'),
|
||||
'writer': create_temp_role(['users:activity'], role_name='writer'),
|
||||
'reader': create_temp_role(['read:users!user'], role_name='reader'),
|
||||
'writer': create_temp_role(['users:activity!user'], role_name='writer'),
|
||||
}
|
||||
service = mockservice_url
|
||||
user = create_user_with_scopes("access:services", "self")
|
||||
|
@@ -81,6 +81,18 @@ async def test_spawner(db, request):
|
||||
assert isinstance(status, int)
|
||||
|
||||
|
||||
def test_spawner_from_db(app, user):
|
||||
spawner = user.spawners['name']
|
||||
user_options = {"test": "value"}
|
||||
spawner.orm_spawner.user_options = user_options
|
||||
app.db.commit()
|
||||
# delete and recreate the spawner from the db
|
||||
user.spawners.pop('name')
|
||||
new_spawner = user.spawners['name']
|
||||
assert new_spawner.orm_spawner.user_options == user_options
|
||||
assert new_spawner.user_options == user_options
|
||||
|
||||
|
||||
async def wait_for_spawner(spawner, timeout=10):
|
||||
"""Wait for an http server to show up
|
||||
|
||||
@@ -428,7 +440,99 @@ async def test_hub_connect_url(db):
|
||||
)
|
||||
|
||||
|
||||
async def test_spawner_oauth_roles(app):
|
||||
allowed_roles = ['lotsa', 'roles']
|
||||
spawner = new_spawner(app.db, oauth_roles=allowed_roles)
|
||||
assert spawner.oauth_roles == allowed_roles
|
||||
async def test_spawner_oauth_roles(app, user):
|
||||
allowed_roles = ["admin", "user"]
|
||||
spawner = user.spawners['']
|
||||
spawner.oauth_roles = allowed_roles
|
||||
# exercise start/stop which assign roles to oauth client
|
||||
await spawner.user.spawn()
|
||||
oauth_client = spawner.orm_spawner.oauth_client
|
||||
assert sorted(role.name for role in oauth_client.allowed_roles) == allowed_roles
|
||||
await spawner.user.stop()
|
||||
|
||||
|
||||
async def test_spawner_oauth_roles_bad(app, user):
|
||||
allowed_roles = ["user", "nosuchrole"]
|
||||
spawner = user.spawners['']
|
||||
spawner.oauth_roles = allowed_roles
|
||||
# exercise start/stop which assign roles
|
||||
# raises ValueError if we try to assign a role that doesn't exist
|
||||
with pytest.raises(ValueError):
|
||||
await spawner.user.spawn()
|
||||
|
||||
|
||||
async def test_spawner_options_from_form(db):
|
||||
def options_from_form(form_data):
|
||||
return form_data
|
||||
|
||||
spawner = new_spawner(db, options_from_form=options_from_form)
|
||||
form_data = {"key": ["value"]}
|
||||
result = spawner.run_options_from_form(form_data)
|
||||
for key, value in form_data.items():
|
||||
assert key in result
|
||||
assert result[key] == value
|
||||
|
||||
|
||||
async def test_spawner_options_from_form_with_spawner(db):
|
||||
def options_from_form(form_data, spawner):
|
||||
return form_data
|
||||
|
||||
spawner = new_spawner(db, options_from_form=options_from_form)
|
||||
form_data = {"key": ["value"]}
|
||||
result = spawner.run_options_from_form(form_data)
|
||||
for key, value in form_data.items():
|
||||
assert key in result
|
||||
assert result[key] == value
|
||||
|
||||
|
||||
def test_spawner_server(db):
|
||||
spawner = new_spawner(db)
|
||||
spawner.orm_spawner = None
|
||||
orm_spawner = orm.Spawner()
|
||||
orm_server = orm.Server(base_url="/1/")
|
||||
orm_spawner.server = orm_server
|
||||
db.add(orm_spawner)
|
||||
db.add(orm_server)
|
||||
db.commit()
|
||||
# initial: no orm_spawner
|
||||
assert spawner.server is None
|
||||
# assigning spawner.orm_spawner updates spawner.server
|
||||
spawner.orm_spawner = orm_spawner
|
||||
assert spawner.server is not None
|
||||
assert spawner.server.orm_server is orm_server
|
||||
# update orm_spawner.server without direct access on Spawner
|
||||
orm_spawner.server = new_server = orm.Server(base_url="/2/")
|
||||
db.commit()
|
||||
assert spawner.server is not None
|
||||
assert spawner.server.orm_server is not orm_server
|
||||
assert spawner.server.orm_server is new_server
|
||||
# clear orm_server via orm_spawner clears spawner.server
|
||||
orm_spawner.server = None
|
||||
db.commit()
|
||||
assert spawner.server is None
|
||||
# assigning spawner.server updates orm_spawner.server
|
||||
orm_server = orm.Server(base_url="/3/")
|
||||
db.add(orm_server)
|
||||
db.commit()
|
||||
spawner.server = server = Server(orm_server=orm_server)
|
||||
db.commit()
|
||||
assert spawner.server is server
|
||||
assert spawner.orm_spawner.server is orm_server
|
||||
# change orm spawner.server
|
||||
orm_server = orm.Server(base_url="/4/")
|
||||
db.add(orm_server)
|
||||
db.commit()
|
||||
spawner.server = server2 = Server(orm_server=orm_server)
|
||||
assert spawner.server is server2
|
||||
assert spawner.orm_spawner.server is orm_server
|
||||
# clear server via spawner.server
|
||||
spawner.server = None
|
||||
db.commit()
|
||||
assert spawner.orm_spawner.server is None
|
||||
|
||||
# test with no underlying orm.Spawner
|
||||
# (only relevant for mocking, never true for actual Spawners)
|
||||
spawner = Spawner()
|
||||
spawner.server = Server.from_url("http://1.2.3.4")
|
||||
assert spawner.server is not None
|
||||
assert spawner.server.ip == "1.2.3.4"
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from .. import orm
|
||||
from ..user import UserDict
|
||||
from .utils import add_user
|
||||
|
||||
@@ -20,3 +21,35 @@ async def test_userdict_get(db, attr):
|
||||
assert userdict.get(key).id == u.id
|
||||
# `in` should find it now
|
||||
assert key in userdict
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"group_names",
|
||||
[
|
||||
["isin1", "isin2"],
|
||||
["isin1"],
|
||||
["notin", "isin1"],
|
||||
["new-group", "isin1"],
|
||||
[],
|
||||
],
|
||||
)
|
||||
def test_sync_groups(app, user, group_names):
|
||||
expected = sorted(group_names)
|
||||
db = app.db
|
||||
db.add(orm.Group(name="notin"))
|
||||
in_groups = [orm.Group(name="isin1"), orm.Group(name="isin2")]
|
||||
for group in in_groups:
|
||||
db.add(group)
|
||||
db.commit()
|
||||
user.groups = in_groups
|
||||
db.commit()
|
||||
user.sync_groups(group_names)
|
||||
assert not app.db.dirty
|
||||
after_groups = sorted(g.name for g in user.groups)
|
||||
assert after_groups == expected
|
||||
# double-check backref
|
||||
for group in db.query(orm.Group):
|
||||
if group.name in expected:
|
||||
assert user.orm_user in group.users
|
||||
else:
|
||||
assert user.orm_user not in group.users
|
||||
|
@@ -2,12 +2,16 @@
|
||||
import asyncio
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from async_generator import aclosing
|
||||
from tornado import gen
|
||||
from tornado.concurrent import run_on_executor
|
||||
from tornado.httpserver import HTTPRequest
|
||||
from tornado.httputil import HTTPHeaders
|
||||
|
||||
from .. import utils
|
||||
from ..utils import iterate_until
|
||||
|
||||
|
||||
@@ -88,3 +92,33 @@ async def test_tornado_coroutines():
|
||||
# verify that tornado gen and executor methods return awaitables
|
||||
assert (await t.on_executor()) == "executor"
|
||||
assert (await t.tornado_coroutine()) == "gen.coroutine"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"forwarded, x_scheme, x_forwarded_proto, expected",
|
||||
[
|
||||
("", "", "", "_attr_"),
|
||||
("for=1.2.3.4", "", "", "_attr_"),
|
||||
("for=1.2.3.4,proto=https", "", "", "_attr_"),
|
||||
("", "https", "http", "https"),
|
||||
("", "https, http", "", "https"),
|
||||
("", "https, http", "http", "https"),
|
||||
("proto=http ; for=1.2.3.4, proto=https", "https, http", "", "http"),
|
||||
("proto=invalid;for=1.2.3.4,proto=http", "https, http", "", "https"),
|
||||
("for=1.2.3.4,proto=http", "https, http", "", "https"),
|
||||
("", "invalid, http", "", "_attr_"),
|
||||
],
|
||||
)
|
||||
def test_browser_protocol(x_scheme, x_forwarded_proto, forwarded, expected):
|
||||
request = Mock(spec=HTTPRequest)
|
||||
request.protocol = "_attr_"
|
||||
request.headers = HTTPHeaders()
|
||||
if x_scheme:
|
||||
request.headers["X-Scheme"] = x_scheme
|
||||
if x_forwarded_proto:
|
||||
request.headers["X-Forwarded-Proto"] = x_forwarded_proto
|
||||
if forwarded:
|
||||
request.headers["Forwarded"] = forwarded
|
||||
|
||||
proto = utils.get_browser_protocol(request)
|
||||
assert proto == expected
|
||||
|
@@ -253,6 +253,58 @@ class User:
|
||||
def spawner_class(self):
|
||||
return self.settings.get('spawner_class', LocalProcessSpawner)
|
||||
|
||||
def get_spawner(self, server_name="", replace_failed=False):
|
||||
"""Get a spawner by name
|
||||
|
||||
replace_failed governs whether a failed spawner should be replaced
|
||||
or returned (default: returned).
|
||||
|
||||
.. versionadded:: 2.2
|
||||
"""
|
||||
spawner = self.spawners[server_name]
|
||||
if replace_failed and spawner._failed:
|
||||
self.log.debug(f"Discarding failed spawner {spawner._log_name}")
|
||||
# remove failed spawner, create a new one
|
||||
self.spawners.pop(server_name)
|
||||
spawner = self.spawners[server_name]
|
||||
return spawner
|
||||
|
||||
def sync_groups(self, group_names):
|
||||
"""Synchronize groups with database"""
|
||||
|
||||
current_groups = {g.name for g in self.orm_user.groups}
|
||||
new_groups = set(group_names)
|
||||
if current_groups == new_groups:
|
||||
# no change, nothing to do
|
||||
return
|
||||
|
||||
# log group changes
|
||||
new_groups = set(group_names).difference(current_groups)
|
||||
removed_groups = current_groups.difference(group_names)
|
||||
if new_groups:
|
||||
self.log.info(f"Adding user {self.name} to group(s): {new_groups}")
|
||||
if removed_groups:
|
||||
self.log.info(f"Removing user {self.name} from group(s): {removed_groups}")
|
||||
|
||||
if group_names:
|
||||
groups = (
|
||||
self.db.query(orm.Group).filter(orm.Group.name.in_(group_names)).all()
|
||||
)
|
||||
existing_groups = {g.name for g in groups}
|
||||
for group_name in group_names:
|
||||
if group_name not in existing_groups:
|
||||
# create groups that don't exist yet
|
||||
self.log.info(
|
||||
f"Creating new group {group_name} for user {self.name}"
|
||||
)
|
||||
group = orm.Group(name=group_name)
|
||||
self.db.add(group)
|
||||
groups.append(group)
|
||||
self.groups = groups
|
||||
else:
|
||||
self.groups = []
|
||||
self.db.commit()
|
||||
|
||||
async def save_auth_state(self, auth_state):
|
||||
"""Encrypt and store auth_state"""
|
||||
if auth_state is None:
|
||||
@@ -376,6 +428,7 @@ class User:
|
||||
oauth_client_id=client_id,
|
||||
cookie_options=self.settings.get('cookie_options', {}),
|
||||
trusted_alt_names=trusted_alt_names,
|
||||
user_options=orm_spawner.user_options or {},
|
||||
)
|
||||
|
||||
if self.settings.get('internal_ssl'):
|
||||
@@ -591,7 +644,7 @@ class User:
|
||||
api_token = self.new_api_token(note=note, roles=['server'])
|
||||
db.commit()
|
||||
|
||||
spawner = self.spawners[server_name]
|
||||
spawner = self.get_spawner(server_name, replace_failed=True)
|
||||
spawner.server = server = Server(orm_server=orm_server)
|
||||
assert spawner.orm_spawner.server is orm_server
|
||||
|
||||
@@ -622,6 +675,19 @@ class User:
|
||||
if callable(allowed_roles):
|
||||
allowed_roles = allowed_roles(spawner)
|
||||
|
||||
# allowed_roles config is a list of strings
|
||||
# oauth provider.allowed_roles is a list of orm.Roles
|
||||
if allowed_roles:
|
||||
allowed_role_names = allowed_roles
|
||||
allowed_roles = list(
|
||||
self.db.query(orm.Role).filter(orm.Role.name.in_(allowed_roles))
|
||||
)
|
||||
if len(allowed_roles) != len(allowed_role_names):
|
||||
missing_roles = set(allowed_role_names).difference(
|
||||
{role.name for role in allowed_roles}
|
||||
)
|
||||
raise ValueError(f"No such role(s): {', '.join(missing_roles)}")
|
||||
|
||||
oauth_client = oauth_provider.add_client(
|
||||
client_id,
|
||||
api_token,
|
||||
@@ -746,7 +812,7 @@ class User:
|
||||
e.reason = 'timeout'
|
||||
self.settings['statsd'].incr('spawner.failure.timeout')
|
||||
else:
|
||||
self.log.error(
|
||||
self.log.exception(
|
||||
"Unhandled error starting {user}'s server: {error}".format(
|
||||
user=self.name, error=e
|
||||
)
|
||||
@@ -756,7 +822,7 @@ class User:
|
||||
try:
|
||||
await self.stop(spawner.name)
|
||||
except Exception:
|
||||
self.log.error(
|
||||
self.log.exception(
|
||||
"Failed to cleanup {user}'s server that failed to start".format(
|
||||
user=self.name
|
||||
),
|
||||
@@ -804,7 +870,7 @@ class User:
|
||||
self.settings['statsd'].incr('spawner.failure.http_timeout')
|
||||
else:
|
||||
e.reason = 'error'
|
||||
self.log.error(
|
||||
self.log.exception(
|
||||
"Unhandled error waiting for {user}'s server to show up at {url}: {error}".format(
|
||||
user=self.name, url=server.url, error=e
|
||||
)
|
||||
@@ -813,7 +879,7 @@ class User:
|
||||
try:
|
||||
await self.stop(spawner.name)
|
||||
except Exception:
|
||||
self.log.error(
|
||||
self.log.exception(
|
||||
"Failed to cleanup {user}'s server that failed to start".format(
|
||||
user=self.name
|
||||
),
|
||||
|
@@ -320,9 +320,11 @@ def admin_only(f):
|
||||
@auth_decorator
|
||||
def metrics_authentication(self):
|
||||
"""Decorator for restricting access to metrics"""
|
||||
user = self.current_user
|
||||
if user is None and self.authenticate_prometheus:
|
||||
raise web.HTTPError(403)
|
||||
if not self.authenticate_prometheus:
|
||||
return
|
||||
scope = 'read:metrics'
|
||||
if scope not in self.parsed_scopes:
|
||||
raise web.HTTPError(403, f"Access to metrics requires scope '{scope}'")
|
||||
|
||||
|
||||
# Token utilities
|
||||
@@ -355,7 +357,7 @@ def hash_token(token, salt=8, rounds=16384, algorithm='sha512'):
|
||||
h.update(btoken)
|
||||
digest = h.hexdigest()
|
||||
|
||||
return "{algorithm}:{rounds}:{salt}:{digest}".format(**locals())
|
||||
return f"{algorithm}:{rounds}:{salt}:{digest}"
|
||||
|
||||
|
||||
def compare_token(compare, token):
|
||||
@@ -683,3 +685,44 @@ def catch_db_error(f):
|
||||
return r
|
||||
|
||||
return catching
|
||||
|
||||
|
||||
def get_browser_protocol(request):
|
||||
"""Get the _protocol_ seen by the browser
|
||||
|
||||
Like tornado's _apply_xheaders,
|
||||
but in the case of multiple proxy hops,
|
||||
use the outermost value (what the browser likely sees)
|
||||
instead of the innermost value,
|
||||
which is the most trustworthy.
|
||||
|
||||
We care about what the browser sees,
|
||||
not where the request actually came from,
|
||||
so trusting possible spoofs is the right thing to do.
|
||||
"""
|
||||
headers = request.headers
|
||||
# first choice: Forwarded header
|
||||
forwarded_header = headers.get("Forwarded")
|
||||
if forwarded_header:
|
||||
first_forwarded = forwarded_header.split(",", 1)[0].strip()
|
||||
fields = {}
|
||||
forwarded_dict = {}
|
||||
for field in first_forwarded.split(";"):
|
||||
key, _, value = field.partition("=")
|
||||
fields[key.strip().lower()] = value.strip()
|
||||
if "proto" in fields and fields["proto"].lower() in {"http", "https"}:
|
||||
return fields["proto"].lower()
|
||||
else:
|
||||
app_log.warning(
|
||||
f"Forwarded header present without protocol: {forwarded_header}"
|
||||
)
|
||||
|
||||
# second choice: X-Scheme or X-Forwarded-Proto
|
||||
proto_header = headers.get("X-Scheme", headers.get("X-Forwarded-Proto", None))
|
||||
if proto_header:
|
||||
proto_header = proto_header.split(",")[0].strip().lower()
|
||||
if proto_header in {"http", "https"}:
|
||||
return proto_header
|
||||
|
||||
# no forwarded headers
|
||||
return request.protocol
|
||||
|
@@ -1,9 +1,13 @@
|
||||
[tool.black]
|
||||
skip-string-normalization = true
|
||||
# target-version should be all supported versions, see
|
||||
# https://github.com/psf/black/issues/751#issuecomment-473066811
|
||||
target_version = [
|
||||
"py36",
|
||||
"py37",
|
||||
"py38",
|
||||
"py39",
|
||||
"py310",
|
||||
]
|
||||
|
||||
[tool.tbump]
|
||||
@@ -11,7 +15,7 @@ target_version = [
|
||||
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||
|
||||
[tool.tbump.version]
|
||||
current = "2.0.0rc5"
|
||||
current = "2.3.0"
|
||||
|
||||
# Example of a semver regexp.
|
||||
# Make sure this matches current_version before
|
||||
|
@@ -3,6 +3,9 @@
|
||||
# so we have to disable this until pytest 3.11
|
||||
# minversion = 3.3
|
||||
|
||||
# automatically run coroutine tests with asyncio
|
||||
asyncio_mode = auto
|
||||
|
||||
# jupyter_server plugin is incompatible with notebook imports
|
||||
addopts = -p no:jupyter_server
|
||||
|
||||
|
File diff suppressed because one or more lines are too long
@@ -1,24 +1,10 @@
|
||||
{% extends "page.html" %}
|
||||
|
||||
{% macro th(label, key='', colspan=1) %}
|
||||
<th data-sort="{{key}}" colspan="{{colspan}}">{{label}}
|
||||
{% if key %}
|
||||
<a href="#"><i class="fa {% if sort.get(key) == 'asc' -%}
|
||||
fa-sort-asc
|
||||
{%- elif sort.get(key) == 'desc' -%}
|
||||
fa-sort-desc
|
||||
{%- else -%}
|
||||
fa-sort
|
||||
{%- endif %} sort-icon">
|
||||
</i></a>
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% block main %}
|
||||
<div id="react-admin-hook">
|
||||
<script id="jupyterhub-admin-config">
|
||||
window.api_page_limit = parseInt("{{ api_page_limit|safe }}")
|
||||
window.base_url = "{{ base_url|safe }}"
|
||||
</script>
|
||||
<script src="static/js/admin-react.js"></script>
|
||||
</div>
|
||||
|
@@ -15,12 +15,17 @@
|
||||
{{ custom_html | safe }}
|
||||
{% elif login_service %}
|
||||
<div class="service-login">
|
||||
<p id='insecure-login-warning' class='hidden'>
|
||||
Warning: JupyterHub seems to be served over an unsecured HTTP connection.
|
||||
We strongly recommend enabling HTTPS for JupyterHub.
|
||||
</p>
|
||||
|
||||
<a role="button" class='btn btn-jupyter btn-lg' href='{{authenticator_login_url}}'>
|
||||
Sign in with {{login_service}}
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<form action="{{login_url}}?next={{next}}" method="post" role="form">
|
||||
<form action="{{authenticator_login_url}}" method="post" role="form">
|
||||
<div class="auth-form-header">
|
||||
Sign in
|
||||
</div>
|
||||
|
@@ -18,8 +18,10 @@
|
||||
<p>
|
||||
{% if failed %}
|
||||
The latest attempt to start your server {{ server_name }} has failed.
|
||||
{% if failed_message %}
|
||||
{{ failed_message }}
|
||||
{% if failed_html_message %}
|
||||
</p><p>{{ failed_html_message | safe }}</p><p>
|
||||
{% elif failed_message %}
|
||||
</p><p>{{ failed_message }}</p><p>
|
||||
{% endif %}
|
||||
Would you like to retry starting it?
|
||||
{% else %}
|
||||
|
Reference in New Issue
Block a user