mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 02:24:08 +00:00
Compare commits
318 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
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 | ||
![]() |
b186bdbce3 | ||
![]() |
36fe6c6f66 | ||
![]() |
8bf559db52 | ||
![]() |
750085f627 | ||
![]() |
2dc2c99b4a | ||
![]() |
e703555888 | ||
![]() |
7e102f0511 | ||
![]() |
facde96425 | ||
![]() |
608c746a59 | ||
![]() |
a8c834410f | ||
![]() |
bda14b487a | ||
![]() |
fd5cf8c360 | ||
![]() |
03758e5b46 | ||
![]() |
e540d143bb | ||
![]() |
b2c5ad40c5 | ||
![]() |
edfdf672d8 | ||
![]() |
39f19aef49 | ||
![]() |
8813bb63d4 | ||
![]() |
7c18d6fe14 | ||
![]() |
d1fe17d3cb | ||
![]() |
b8965c2017 | ||
![]() |
733d7bc158 | ||
![]() |
88f31c29bb | ||
![]() |
3caf3cfda8 | ||
![]() |
d076c55cca | ||
![]() |
3e185022c8 | ||
![]() |
857ee2885f | ||
![]() |
cd8dd56213 | ||
![]() |
f06902aa8f | ||
![]() |
bb109c6f75 | ||
![]() |
e525ec7b5b | ||
![]() |
356b98e19f | ||
![]() |
8c803e7a53 | ||
![]() |
2e21a6f4e0 | ||
![]() |
cfd31b14e3 | ||
![]() |
f03a620424 | ||
![]() |
440ad77ad5 | ||
![]() |
68835e97a2 | ||
![]() |
ce80c9c9cf | ||
![]() |
3c299fbfb7 | ||
![]() |
597f8ea6eb | ||
![]() |
d1181085bf | ||
![]() |
913832da48 | ||
![]() |
42f57f4a72 | ||
![]() |
d01a518c41 | ||
![]() |
65ce06b116 | ||
![]() |
468aa5e93c | ||
![]() |
5c01370e6f | ||
![]() |
21d08883a8 | ||
![]() |
59de506f20 | ||
![]() |
b34120ed81 | ||
![]() |
617978179d | ||
![]() |
0985d6fdf2 | ||
![]() |
2049fb0491 | ||
![]() |
a58fc6534b | ||
![]() |
a14f97b7aa | ||
![]() |
0a4cd5b4f2 | ||
![]() |
dca6d372df | ||
![]() |
3898c72921 | ||
![]() |
b25517efe8 | ||
![]() |
392dffd11e | ||
![]() |
510f6ea7e6 | ||
![]() |
296a0ad2f2 | ||
![]() |
487c4524ad | ||
![]() |
b2f0208fcc | ||
![]() |
84b9c3848c | ||
![]() |
9adbafdfb3 | ||
![]() |
9cf2b5101e | ||
![]() |
725fa3a48a | ||
![]() |
534dda3dc7 | ||
![]() |
b0c7df04ac | ||
![]() |
61b0e8bef5 | ||
![]() |
64f3938528 | ||
![]() |
85bc92d88e | ||
![]() |
7bcda18564 | ||
![]() |
86da36857e | ||
![]() |
530833e930 | ||
![]() |
3b0850fa9b | ||
![]() |
1366911be6 | ||
![]() |
fe276eac64 | ||
![]() |
9209ccd0de | ||
![]() |
3b2a1a37f9 | ||
![]() |
6007ba78b0 | ||
![]() |
9cb19cc342 | ||
![]() |
0f471f4e12 | ||
![]() |
68db740998 | ||
![]() |
9c0c6f25b7 | ||
![]() |
5f0077cb5b | ||
![]() |
a6a2056cca | ||
![]() |
fb1e81212f | ||
![]() |
17f811d0b4 | ||
![]() |
34398d94de | ||
![]() |
6bf94fde48 | ||
![]() |
ee18fed04b | ||
![]() |
28f56ba510 | ||
![]() |
c8d3dbb7b1 | ||
![]() |
a76a093638 | ||
![]() |
27908a8e17 | ||
![]() |
8a30f015c9 | ||
![]() |
8cac83fc96 | ||
![]() |
9ade4bb9b2 | ||
![]() |
874c91a086 | ||
![]() |
a906677440 | ||
![]() |
3f93942a24 | ||
![]() |
aeb3130b25 | ||
![]() |
8a6b364ca5 | ||
![]() |
2ade7328d1 | ||
![]() |
2bb9f4f444 | ||
![]() |
b029d983f9 | ||
![]() |
4082006039 | ||
![]() |
69aa0eaa7a | ||
![]() |
3674ada640 | ||
![]() |
48accb0a64 | ||
![]() |
70ac143cfe | ||
![]() |
b1b2d531f8 | ||
![]() |
e200783c59 | ||
![]() |
a7e57196c6 | ||
![]() |
b5f05e6cd2 | ||
![]() |
5fe5b35f21 | ||
![]() |
3610454a12 | ||
![]() |
abc4bbebe4 |
77
.github/workflows/release.yml
vendored
77
.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
|
||||
@@ -129,7 +147,7 @@ jobs:
|
||||
# If GITHUB_TOKEN isn't available (e.g. in PRs) returns no tags [].
|
||||
- name: Get list of jupyterhub tags
|
||||
id: jupyterhubtags
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v1
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub:"
|
||||
@@ -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,11 +164,11 @@ 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@v1
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:"
|
||||
@@ -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,11 +185,11 @@ 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@v1
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:"
|
||||
@@ -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] }}
|
||||
@@ -190,3 +208,24 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}
|
||||
|
||||
# image: jupyterhub/singleuser
|
||||
#
|
||||
- name: Get list of jupyterhub/singleuser tags
|
||||
id: singleusertags
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
prefix: "${{ env.REGISTRY }}jupyterhub/singleuser:"
|
||||
defaultTag: "${{ env.REGISTRY }}jupyterhub/singleuser:noref"
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub/singleuser
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
|
||||
with:
|
||||
build-args: |
|
||||
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
|
||||
context: singleuser
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ join(fromJson(steps.singleusertags.outputs.tags)) }}
|
||||
|
31
.github/workflows/support-bot.yml
vendored
Normal file
31
.github/workflows/support-bot.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# https://github.com/dessant/support-requests
|
||||
name: "Support Requests"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled, unlabeled, reopened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/support-requests@v2
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
support-label: "support"
|
||||
issue-comment: |
|
||||
Hi there @{issue-author} :wave:!
|
||||
|
||||
I closed this issue because it was labelled as a support question.
|
||||
|
||||
Please help us organize discussion by posting this on the http://discourse.jupyter.org/ forum.
|
||||
|
||||
Our goal is to sustain a positive experience for both users and developers. We use GitHub issues for specific discussions related to changing a repository's content, and let the forum be where we can more generally help and inspire each other.
|
||||
|
||||
Thanks you for being an active member of our community! :heart:
|
||||
close-issue: true
|
||||
lock-issue: false
|
||||
issue-lock-reason: "off-topic"
|
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/
|
81
.github/workflows/test.yml
vendored
81
.github/workflows/test.yml
vendored
@@ -1,25 +1,67 @@
|
||||
# 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:
|
||||
# UTF-8 content may be interpreted as ascii and causes errors without this.
|
||||
LANG: C.UTF-8
|
||||
PYTEST_ADDOPTS: "--verbose --color=yes"
|
||||
|
||||
jobs:
|
||||
jstest:
|
||||
# Run javascript tests
|
||||
runs-on: ubuntu-20.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
# NOTE: actions/setup-node@v1 make use of a cache within the GitHub base
|
||||
# environment and setup in a fraction of a second.
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: "14"
|
||||
|
||||
- name: Install Node dependencies
|
||||
run: |
|
||||
npm install -g yarn
|
||||
|
||||
- name: Run yarn
|
||||
run: |
|
||||
cd jsx
|
||||
yarn
|
||||
|
||||
- name: yarn test
|
||||
run: |
|
||||
cd jsx
|
||||
yarn test
|
||||
|
||||
# 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
|
||||
@@ -38,9 +80,9 @@ jobs:
|
||||
# Tests everything when JupyterHub works against a dedicated mysql or
|
||||
# postgresql server.
|
||||
#
|
||||
# jupyter_server:
|
||||
# nbclassic:
|
||||
# Tests everything when the user instances are started with
|
||||
# jupyter_server instead of notebook.
|
||||
# notebook instead of jupyter_server.
|
||||
#
|
||||
# ssl:
|
||||
# Tests everything using internal SSL connections instead of
|
||||
@@ -48,7 +90,7 @@ jobs:
|
||||
#
|
||||
# main_dependencies:
|
||||
# Tests everything when the we use the latest available dependencies
|
||||
# from: ipytraitlets.
|
||||
# from: traitlets.
|
||||
#
|
||||
# NOTE: Since only the value of these parameters are presented in the
|
||||
# GitHub UI when the workflow run, we avoid using true/false as
|
||||
@@ -56,6 +98,7 @@ jobs:
|
||||
include:
|
||||
- python: "3.6"
|
||||
oldest_dependencies: oldest_dependencies
|
||||
nbclassic: nbclassic
|
||||
- python: "3.6"
|
||||
subdomain: subdomain
|
||||
- python: "3.7"
|
||||
@@ -65,7 +108,7 @@ jobs:
|
||||
- python: "3.8"
|
||||
db: postgres
|
||||
- python: "3.8"
|
||||
jupyter_server: jupyter_server
|
||||
nbclassic: nbclassic
|
||||
- python: "3.9"
|
||||
main_dependencies: main_dependencies
|
||||
|
||||
@@ -105,7 +148,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
|
||||
@@ -130,9 +172,9 @@ jobs:
|
||||
if [ "${{ matrix.main_dependencies }}" != "" ]; then
|
||||
pip install git+https://github.com/ipython/traitlets#egg=traitlets --force
|
||||
fi
|
||||
if [ "${{ matrix.jupyter_server }}" != "" ]; then
|
||||
pip uninstall notebook --yes
|
||||
pip install jupyter_server
|
||||
if [ "${{ matrix.nbclassic }}" != "" ]; then
|
||||
pip uninstall jupyter_server --yes
|
||||
pip install notebook
|
||||
fi
|
||||
if [ "${{ matrix.db }}" == "mysql" ]; then
|
||||
pip install mysql-connector-python
|
||||
@@ -168,33 +210,32 @@ 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
|
||||
|
||||
- name: Run pytest
|
||||
# FIXME: --color=yes explicitly set because:
|
||||
# https://github.com/actions/runner/issues/241
|
||||
run: |
|
||||
pytest -v --maxfail=2 --color=yes --cov=jupyterhub jupyterhub/tests
|
||||
- name: Run yarn jest test
|
||||
run: |
|
||||
cd jsx && yarn && yarn test
|
||||
pytest --maxfail=2 --cov=jupyterhub jupyterhub/tests
|
||||
- name: Submit codecov report
|
||||
run: |
|
||||
codecov
|
||||
|
||||
docker-build:
|
||||
runs-on: ubuntu-20.04
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
@@ -1,28 +1,28 @@
|
||||
repos:
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.26.0
|
||||
rev: v2.31.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args:
|
||||
- --py36-plus
|
||||
- repo: https://github.com/asottile/reorder_python_imports
|
||||
rev: v2.6.0
|
||||
rev: v2.7.1
|
||||
hooks:
|
||||
- id: reorder-python-imports
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 21.8b0
|
||||
rev: 22.1.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v2.4.0
|
||||
rev: v2.5.1
|
||||
hooks:
|
||||
- id: prettier
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: "3.9.2"
|
||||
rev: "4.0.1"
|
||||
hooks:
|
||||
- id: flake8
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.0.1
|
||||
rev: v4.1.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: check-case-conflict
|
||||
|
@@ -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: .
|
@@ -1,26 +0,0 @@
|
||||
# Release checklist
|
||||
|
||||
- [ ] Upgrade Docs prior to Release
|
||||
|
||||
- [ ] Change log
|
||||
- [ ] New features documented
|
||||
- [ ] Update the contributor list - thank you page
|
||||
|
||||
- [ ] Upgrade and test Reference Deployments
|
||||
|
||||
- [ ] Release software
|
||||
|
||||
- [ ] Make sure 0 issues in milestone
|
||||
- [ ] Follow release process steps
|
||||
- [ ] Send builds to PyPI (Warehouse) and Conda Forge
|
||||
|
||||
- [ ] Blog post and/or release note
|
||||
|
||||
- [ ] Notify users of release
|
||||
|
||||
- [ ] Email Jupyter and Jupyter In Education mailing lists
|
||||
- [ ] Tweet (optional)
|
||||
|
||||
- [ ] Increment the version number for the next release
|
||||
|
||||
- [ ] Update roadmap
|
@@ -56,9 +56,11 @@ Basic principles for operation are:
|
||||
servers.
|
||||
|
||||
JupyterHub also provides a
|
||||
[REST API](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/HEAD/docs/rest-api.yml#/default)
|
||||
[REST API][]
|
||||
for administration of the Hub and its users.
|
||||
|
||||
[rest api]: https://juptyerhub.readthedocs.io/en/latest/reference/rest-api.html
|
||||
|
||||
## Installation
|
||||
|
||||
### Check prerequisites
|
||||
@@ -115,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.
|
||||
@@ -239,7 +240,7 @@ You can also talk with us on our JupyterHub [Gitter](https://gitter.im/jupyterhu
|
||||
- [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
|
||||
- [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial)
|
||||
- [Documentation for JupyterHub](https://jupyterhub.readthedocs.io/en/latest/) | [PDF (latest)](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf) | [PDF (stable)](https://media.readthedocs.org/pdf/jupyterhub/stable/jupyterhub.pdf)
|
||||
- [Documentation for JupyterHub's REST API](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/HEAD/docs/rest-api.yml#/default)
|
||||
- [Documentation for JupyterHub's REST API][rest api]
|
||||
- [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)
|
||||
- [Project Jupyter website](https://jupyter.org)
|
||||
- [Project Jupyter community](https://jupyter.org/community)
|
||||
|
50
RELEASE.md
Normal file
50
RELEASE.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# How to make a release
|
||||
|
||||
`jupyterhub` is a package [available on
|
||||
PyPI](https://pypi.org/project/jupyterhub/) and
|
||||
[conda-forge](https://conda-forge.org/).
|
||||
These are instructions on how to make a release on PyPI.
|
||||
The PyPI release is done automatically by CI when a tag is pushed.
|
||||
|
||||
For you to follow along according to these instructions, you need:
|
||||
|
||||
- To have push rights to the [jupyterhub GitHub
|
||||
repository](https://github.com/jupyterhub/jupyterhub).
|
||||
|
||||
## Steps to make a release
|
||||
|
||||
1. Checkout main and make sure it is up to date.
|
||||
|
||||
```shell
|
||||
ORIGIN=${ORIGIN:-origin} # set to the canonical remote, e.g. 'upstream' if 'origin' is not the official repo
|
||||
git checkout main
|
||||
git fetch $ORIGIN main
|
||||
git reset --hard $ORIGIN/main
|
||||
```
|
||||
|
||||
1. Make sure `docs/source/changelog.md` is up-to-date.
|
||||
[github-activity][] can help with this.
|
||||
|
||||
1. Update the version with `tbump`.
|
||||
You can see what will happen without making any changes with `tbump --dry-run ${VERSION}`
|
||||
|
||||
```shell
|
||||
tbump ${VERSION}
|
||||
```
|
||||
|
||||
This will tag and publish a release,
|
||||
which will be finished on CI.
|
||||
|
||||
1. Reset the version back to dev, e.g. `2.1.0.dev` after releasing `2.0.0`
|
||||
|
||||
```shell
|
||||
tbump --no-tag ${NEXT_VERSION}.dev
|
||||
```
|
||||
|
||||
1. Following the release to PyPI, an automated PR should arrive to
|
||||
[conda-forge/jupyterhub-feedstock][],
|
||||
check for the tests to succeed on this PR and then merge it to successfully
|
||||
update the package for `conda` on the conda-forge channel.
|
||||
|
||||
[github-activity]: https://github.com/choldgraf/github-activity
|
||||
[conda-forge/jupyterhub-feedstock]: https://github.com/conda-forge/jupyterhub-feedstock
|
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Reporting a Vulnerability
|
||||
|
||||
If you believe you’ve found a security vulnerability in a Jupyter
|
||||
project, please report it to security@ipython.org. If you prefer to
|
||||
encrypt your security reports, you can use [this PGP public key](https://jupyter-notebook.readthedocs.io/en/stable/_downloads/1d303a645f2505a8fd283826fafc9908/ipython_security.asc).
|
@@ -7,13 +7,14 @@ codecov
|
||||
coverage
|
||||
cryptography
|
||||
html5lib # needed for beautifulsoup
|
||||
jupyterlab >=3
|
||||
mock
|
||||
notebook
|
||||
pre-commit
|
||||
pytest>=3.3
|
||||
pytest-asyncio
|
||||
pytest-cov
|
||||
requests-mock
|
||||
tbump
|
||||
# blacklist urllib3 releases affected by https://github.com/urllib3/urllib3/issues/1683
|
||||
# I *think* this should only affect testing, not production
|
||||
urllib3!=1.25.4,!=1.25.5
|
||||
|
@@ -53,14 +53,6 @@ help:
|
||||
clean:
|
||||
rm -rf $(BUILDDIR)/*
|
||||
|
||||
node_modules: package.json
|
||||
npm install && touch node_modules
|
||||
|
||||
rest-api: source/_static/rest-api/index.html
|
||||
|
||||
source/_static/rest-api/index.html: rest-api.yml node_modules
|
||||
npm run rest-api
|
||||
|
||||
metrics: source/reference/metrics.rst
|
||||
|
||||
source/reference/metrics.rst: generate-metrics.py
|
||||
@@ -71,7 +63,7 @@ scopes: source/rbac/scope-table.md
|
||||
source/rbac/scope-table.md: source/rbac/generate-scope-table.py
|
||||
python3 source/rbac/generate-scope-table.py
|
||||
|
||||
html: rest-api metrics scopes
|
||||
html: metrics scopes
|
||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||
|
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "jupyterhub-docs-build",
|
||||
"version": "0.8.0",
|
||||
"description": "build JupyterHub swagger docs",
|
||||
"scripts": {
|
||||
"rest-api": "bootprint openapi ./rest-api.yml source/_static/rest-api"
|
||||
},
|
||||
"author": "",
|
||||
"license": "BSD-3-Clause",
|
||||
"devDependencies": {
|
||||
"bootprint": "^1.0.0",
|
||||
"bootprint-openapi": "^1.0.0"
|
||||
}
|
||||
}
|
@@ -1,12 +1,12 @@
|
||||
-r ../requirements.txt
|
||||
|
||||
alabaster_jupyterhub
|
||||
# Temporary fix of #3021. Revert back to released autodoc-traits when
|
||||
# 0.1.0 released.
|
||||
https://github.com/jupyterhub/autodoc-traits/archive/d22282c1c18c6865436e06d8b329c06fe12a07f8.zip
|
||||
autodoc-traits
|
||||
myst-parser
|
||||
pre-commit
|
||||
pydata-sphinx-theme
|
||||
pytablewriter>=0.56
|
||||
ruamel.yaml
|
||||
sphinx>=1.7
|
||||
sphinx-copybutton
|
||||
sphinx-jsonschema
|
||||
|
1196
docs/rest-api.yml
1196
docs/rest-api.yml
File diff suppressed because it is too large
Load Diff
@@ -2,3 +2,9 @@
|
||||
.navbar-brand {
|
||||
height: 4rem !important;
|
||||
}
|
||||
|
||||
/* hide redundant funky-formatted swagger-ui version */
|
||||
|
||||
.swagger-ui .info .title small {
|
||||
display: none !important;
|
||||
}
|
||||
|
1422
docs/source/_static/rest-api.yml
Normal file
1422
docs/source/_static/rest-api.yml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,3 @@
|
||||
.. _admin/upgrading:
|
||||
|
||||
====================
|
||||
Upgrading JupyterHub
|
||||
====================
|
||||
|
@@ -17,11 +17,6 @@ information on:
|
||||
- making an API request programmatically using the requests library
|
||||
- learning more about JupyterHub's API
|
||||
|
||||
The same JupyterHub API spec, as found here, is available in an interactive form
|
||||
`here (on swagger's petstore) <https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#!/default>`__.
|
||||
The `OpenAPI Initiative`_ (fka Swagger™) is a project used to describe
|
||||
and document RESTful APIs.
|
||||
|
||||
JupyterHub API Reference:
|
||||
|
||||
.. toctree::
|
||||
|
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 --------------------------------------------------------
|
||||
|
||||
@@ -215,7 +243,7 @@ if on_rtd:
|
||||
# build both metrics and rest-api, since RTD doesn't run make
|
||||
from subprocess import check_call as sh
|
||||
|
||||
sh(['make', 'metrics', 'rest-api', 'scopes'], cwd=docs)
|
||||
sh(['make', 'metrics', 'scopes'], cwd=docs)
|
||||
|
||||
# -- Spell checking -------------------------------------------------------
|
||||
|
||||
|
@@ -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}
|
||||
|
BIN
docs/source/images/binder-404.png
Normal file
BIN
docs/source/images/binder-404.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 160 KiB |
BIN
docs/source/images/binderhub-form.png
Normal file
BIN
docs/source/images/binderhub-form.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 138 KiB |
BIN
docs/source/images/chp-404.png
Normal file
BIN
docs/source/images/chp-404.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 38 KiB |
BIN
docs/source/images/server-not-running.png
Normal file
BIN
docs/source/images/server-not-running.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 66 KiB |
@@ -43,7 +43,7 @@ JupyterHub performs the following functions:
|
||||
notebook servers
|
||||
|
||||
For convenient administration of the Hub, its users, and services,
|
||||
JupyterHub also provides a `REST API`_.
|
||||
JupyterHub also provides a :doc:`REST API <reference/rest-api>`.
|
||||
|
||||
The JupyterHub team and Project Jupyter value our community, and JupyterHub
|
||||
follows the Jupyter `Community Guides <https://jupyter.readthedocs.io/en/latest/community/content-community.html>`_.
|
||||
@@ -155,4 +155,3 @@ Questions? Suggestions?
|
||||
|
||||
.. _JupyterHub: https://github.com/jupyterhub/jupyterhub
|
||||
.. _Jupyter notebook: https://jupyter-notebook.readthedocs.io/en/latest/
|
||||
.. _REST API: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#!/default
|
||||
|
@@ -5,8 +5,8 @@
|
||||
Before installing JupyterHub, you will need:
|
||||
|
||||
- a Linux/Unix based system
|
||||
- [Python](https://www.python.org/downloads/) 3.5 or greater. An understanding
|
||||
of using [`pip`](https://pip.pypa.io/en/stable/) or
|
||||
- [Python](https://www.python.org/downloads/) 3.6 or greater. An understanding
|
||||
of using [`pip`](https://pip.pypa.io) or
|
||||
[`conda`](https://conda.io/docs/get-started.html) for
|
||||
installing Python packages is helpful.
|
||||
- [nodejs/npm](https://www.npmjs.com/). [Install nodejs/npm](https://docs.npmjs.com/getting-started/installing-node),
|
||||
@@ -20,11 +20,11 @@ Before installing JupyterHub, you will need:
|
||||
For example, install it on Linux (Debian/Ubuntu) using:
|
||||
|
||||
```
|
||||
sudo apt-get install npm nodejs-legacy
|
||||
sudo apt-get install nodejs npm
|
||||
```
|
||||
|
||||
The `nodejs-legacy` package installs the `node` executable and is currently
|
||||
required for npm to work on Debian/Ubuntu.
|
||||
[nodesource][] is a great resource to get more recent versions of the nodejs runtime,
|
||||
if your system package manager only has an old version of Node.js (e.g. 10 or older).
|
||||
|
||||
- A [pluggable authentication module (PAM)](https://en.wikipedia.org/wiki/Pluggable_authentication_module)
|
||||
to use the [default Authenticator](./getting-started/authenticators-users-basics.md).
|
||||
@@ -33,11 +33,17 @@ Before installing JupyterHub, you will need:
|
||||
- TLS certificate and key for HTTPS communication
|
||||
- Domain name
|
||||
|
||||
[nodesource]: https://github.com/nodesource/distributions#table-of-contents
|
||||
|
||||
Before running the single-user notebook servers (which may be on the same
|
||||
system as the Hub or not), you will need:
|
||||
|
||||
- [Jupyter Notebook](https://jupyter.readthedocs.io/en/latest/install.html)
|
||||
version 4 or greater
|
||||
- [JupyterLab][] version 3 or greater,
|
||||
or [Jupyter Notebook][]
|
||||
4 or greater.
|
||||
|
||||
[jupyterlab]: https://jupyterlab.readthedocs.io
|
||||
[jupyter notebook]: https://jupyter.readthedocs.io/en/latest/install.html
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -48,14 +54,14 @@ JupyterHub can be installed with `pip` (and the proxy with `npm`) or `conda`:
|
||||
```bash
|
||||
python3 -m pip install jupyterhub
|
||||
npm install -g configurable-http-proxy
|
||||
python3 -m pip install notebook # needed if running the notebook servers locally
|
||||
python3 -m pip install jupyterlab notebook # needed if running the notebook servers in the same environment
|
||||
```
|
||||
|
||||
**conda** (one command installs jupyterhub and proxy):
|
||||
|
||||
```bash
|
||||
conda install -c conda-forge jupyterhub # installs jupyterhub and proxy
|
||||
conda install notebook # needed if running the notebook servers locally
|
||||
conda install jupyterlab notebook # needed if running the notebook servers in the same environment
|
||||
```
|
||||
|
||||
Test your installation. If installed, these commands should return the packages'
|
||||
@@ -74,7 +80,7 @@ To start the Hub server, run the command:
|
||||
jupyterhub
|
||||
```
|
||||
|
||||
Visit `https://localhost:8000` in your browser, and sign in with your unix
|
||||
Visit `http://localhost:8000` in your browser, and sign in with your unix
|
||||
credentials.
|
||||
|
||||
To **allow multiple users to sign in** to the Hub server, you must start
|
||||
|
@@ -1,14 +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
|
||||
|
||||
from jupyterhub import __version__
|
||||
from jupyterhub.scopes import scope_definitions
|
||||
|
||||
HERE = os.path.abspath(os.path.dirname(__file__))
|
||||
PARENT = Path(HERE).parent.parent.absolute()
|
||||
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:
|
||||
@@ -80,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()
|
||||
@@ -97,23 +117,38 @@ class ScopeTableGenerator:
|
||||
)
|
||||
|
||||
def write_api(self):
|
||||
"""Generates the API description in markdown format and writes it into `rest-api.yml`"""
|
||||
filename = f"{PARENT}/rest-api.yml"
|
||||
yaml = YAML(typ='rt')
|
||||
"""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.preserve_quotes = True
|
||||
yaml.indent(mapping=2, offset=2, sequence=4)
|
||||
|
||||
scope_dict = {}
|
||||
with open(filename, 'r+') as f:
|
||||
with open(filename) as f:
|
||||
content = yaml.load(f.read())
|
||||
f.seek(0)
|
||||
for scope in self.scopes:
|
||||
description = self.scopes[scope]['description']
|
||||
doc_description = self.scopes[scope].get('doc_description', '')
|
||||
if doc_description:
|
||||
description = doc_description
|
||||
scope_dict[scope] = description
|
||||
content['securityDefinitions']['oauth2']['scopes'] = scope_dict
|
||||
|
||||
content["info"]["version"] = __version__
|
||||
for scope in self.scopes:
|
||||
description = self.scopes[scope]['description']
|
||||
doc_description = self.scopes[scope].get('doc_description', '')
|
||||
if doc_description:
|
||||
description = doc_description
|
||||
scope_dict[scope] = description
|
||||
content['components']['securitySchemes']['oauth2']['flows'][
|
||||
'authorizationCode'
|
||||
]['scopes'] = scope_dict
|
||||
|
||||
with open(filename, 'w') as f:
|
||||
yaml.dump(content, f)
|
||||
f.truncate()
|
||||
|
||||
run(
|
||||
['pre-commit', 'run', 'prettier', '--files', filename],
|
||||
cwd=HERE,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
|
@@ -123,13 +123,13 @@ has,
|
||||
define the `server` role.
|
||||
|
||||
To restore the JupyterHub 1.x behavior of servers being able to do anything their owners can do,
|
||||
use the scope `all`:
|
||||
use the scope `inherit` (for 'inheriting' the owner's permissions):
|
||||
|
||||
```python
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
'name': 'server',
|
||||
'scopes': ['all'],
|
||||
'scopes': ['inherit'],
|
||||
}
|
||||
]
|
||||
```
|
||||
|
128
docs/source/reference/api-only.md
Normal file
128
docs/source/reference/api-only.md
Normal file
@@ -0,0 +1,128 @@
|
||||
(api-only)=
|
||||
|
||||
# Deploying JupyterHub in "API only mode"
|
||||
|
||||
As a service for deploying and managing Jupyter servers for users, JupyterHub
|
||||
exposes this functionality _primarily_ via a [REST API](rest).
|
||||
For convenience, JupyterHub also ships with a _basic_ web UI built using that REST API.
|
||||
The basic web UI enables users to click a button to quickly start and stop their servers,
|
||||
and it lets admins perform some basic user and server management tasks.
|
||||
|
||||
The REST API has always provided additional functionality beyond what is available in the basic web UI.
|
||||
Similarly, we avoid implementing UI functionality that is also not available via the API.
|
||||
With JupyterHub 2.0, the basic web UI will **always** be composed using the REST API.
|
||||
In other words, no UI pages should rely on information not available via the REST API.
|
||||
Previously, some admin UI functionality could only be achieved via admin pages,
|
||||
such as paginated requests.
|
||||
|
||||
## Limited UI customization via templates
|
||||
|
||||
The JupyterHub UI is customizable via extensible HTML [templates](templates),
|
||||
but this has some limited scope to what can be customized.
|
||||
Adding some content and messages to existing pages is well supported,
|
||||
but changing the page flow and what pages are available are beyond the scope of what is customizable.
|
||||
|
||||
## Rich UI customization with REST API based apps
|
||||
|
||||
Increasingly, JupyterHub is used purely as an API for managing Jupyter servers
|
||||
for other Jupyter-based applications that might want to present a different user experience.
|
||||
If you want a fully customized user experience,
|
||||
you can now disable the Hub UI and use your own pages together with the JupyterHub REST API
|
||||
to build your own web application to serve your users,
|
||||
relying on the Hub only as an API for managing users and servers.
|
||||
|
||||
One example of such an application is [BinderHub][], which powers https://mybinder.org,
|
||||
and motivates many of these changes.
|
||||
|
||||
BinderHub is distinct from a traditional JupyterHub deployment
|
||||
because it uses temporary users created for each launch.
|
||||
Instead of presenting a login page,
|
||||
users are presented with a form to specify what environment they would like to launch:
|
||||
|
||||

|
||||
|
||||
When a launch is requested:
|
||||
|
||||
1. an image is built, if necessary
|
||||
2. a temporary user is created,
|
||||
3. a server is launched for that user, and
|
||||
4. when running, users are redirected to an already running server with an auth token in the URL
|
||||
5. after the session is over, the user is deleted
|
||||
|
||||
This means that a lot of JupyterHub's UI flow doesn't make sense:
|
||||
|
||||
- there is no way for users to login
|
||||
- the human user doesn't map onto a JupyterHub `User` in a meaningful way
|
||||
- when a server isn't running, there isn't a 'restart your server' action available because the user has been deleted
|
||||
- users do not have any access to any Hub functionality, so presenting pages for those features would be confusing
|
||||
|
||||
BinderHub is one of the motivating use cases for JupyterHub supporting being used _only_ via its API.
|
||||
We'll use BinderHub here as an example of various configuration options.
|
||||
|
||||
[binderhub]: https://binderhub.readthedocs.io
|
||||
|
||||
## Disabling Hub UI
|
||||
|
||||
`c.JupyterHub.hub_routespec` is a configuration option to specify which URL prefix should be routed to the Hub.
|
||||
The default is `/` which means that the Hub will receive all requests not already specified to be routed somewhere else.
|
||||
|
||||
There are three values that are most logical for `hub_routespec`:
|
||||
|
||||
- `/` - this is the default, and used in most deployments.
|
||||
It is also the only option prior to JupyterHub 1.4.
|
||||
- `/hub/` - this serves only Hub pages, both UI and API
|
||||
- `/hub/api` - this serves _only the Hub API_, so all Hub UI is disabled,
|
||||
aside from the OAuth confirmation page, if used.
|
||||
|
||||
If you choose a hub routespec other than `/`,
|
||||
the main JupyterHub feature you will lose is the automatic handling of requests for `/user/:username`
|
||||
when the requested server is not running.
|
||||
|
||||
JupyterHub's handling of this request shows this page,
|
||||
telling you that the server is not running,
|
||||
with a button to launch it again:
|
||||
|
||||

|
||||
|
||||
If you set `hub_routespec` to something other than `/`,
|
||||
it is likely that you also want to register another destination for `/` to handle requests to not-running servers.
|
||||
If you don't, you will see a default 404 page from the proxy:
|
||||
|
||||

|
||||
|
||||
For mybinder.org, the default "start my server" page doesn't make sense,
|
||||
because when a server is gone, there is no restart action.
|
||||
Instead, we provide hints about how to get back to a link to start a _new_ server:
|
||||
|
||||

|
||||
|
||||
To achieve this, mybinder.org registers a route for `/` that goes to a custom endpoint
|
||||
that runs nginx and only serves this static HTML error page.
|
||||
This is set with
|
||||
|
||||
```python
|
||||
c.Proxy.extra_routes = {
|
||||
"/": "http://custom-404-entpoint/",
|
||||
}
|
||||
```
|
||||
|
||||
You may want to use an alternate behavior, such as redirecting to a landing page,
|
||||
or taking some other action based on the requested page.
|
||||
|
||||
If you use `c.JupyterHub.hub_routespec = "/hub/"`,
|
||||
then all the Hub pages will be available,
|
||||
and only this default-page-404 issue will come up.
|
||||
|
||||
If you use `c.JupyterHub.hub_routespec = "/hub/api/"`,
|
||||
then only the Hub _API_ will be available,
|
||||
and all UI will be up to you.
|
||||
mybinder.org takes this last option,
|
||||
because none of the Hub UI pages really make sense.
|
||||
Binder users don't have any reason to know or care that JupyterHub happens
|
||||
to be an implementation detail of how their environment is managed.
|
||||
Seeing Hub error pages and messages in that situation is more likely to be confusing than helpful.
|
||||
|
||||
:::{versionadded} 1.4
|
||||
|
||||
`c.JupyterHub.hub_routespec` and `c.Proxy.extra_routes` are new in JupyterHub 1.4.
|
||||
:::
|
@@ -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>
|
||||
```
|
||||
@@ -219,7 +231,7 @@ In case of the need to run the jupyterhub under /jhub/ or other location please
|
||||
httpd.conf amendments:
|
||||
|
||||
```bash
|
||||
RewriteRule /jhub/(.*) ws://127.0.0.1:8000/jhub/$1 [NE.P,L]
|
||||
RewriteRule /jhub/(.*) ws://127.0.0.1:8000/jhub/$1 [NE,P,L]
|
||||
RewriteRule /jhub/(.*) http://127.0.0.1:8000/jhub/$1 [NE,P,L]
|
||||
|
||||
ProxyPass /jhub/ http://127.0.0.1:8000/jhub/
|
||||
|
@@ -76,13 +76,26 @@ c.InteractiveShellApp.extensions.append("cython")
|
||||
|
||||
### Example: Enable a Jupyter notebook configuration setting for all users
|
||||
|
||||
:::{note}
|
||||
These examples configure the Jupyter ServerApp,
|
||||
which is used by JupyterLab, the default in JupyterHub 2.0.
|
||||
|
||||
If you are using the classing Jupyter Notebook server,
|
||||
the same things should work,
|
||||
with the following substitutions:
|
||||
|
||||
- Where you see `jupyter_server_config`, use `jupyter_notebook_config`
|
||||
- Where you see `NotebookApp`, use `ServerApp`
|
||||
|
||||
:::
|
||||
|
||||
To enable Jupyter notebook's internal idle-shutdown behavior (requires
|
||||
notebook ≥ 5.4), set the following in the `/etc/jupyter/jupyter_notebook_config.py`
|
||||
notebook ≥ 5.4), set the following in the `/etc/jupyter/jupyter_server_config.py`
|
||||
file:
|
||||
|
||||
```python
|
||||
# shutdown the server after no activity for an hour
|
||||
c.NotebookApp.shutdown_no_activity_timeout = 60 * 60
|
||||
c.ServerApp.shutdown_no_activity_timeout = 60 * 60
|
||||
# shutdown kernels after no activity for 20 minutes
|
||||
c.MappingKernelManager.cull_idle_timeout = 20 * 60
|
||||
# check for idle kernels every two minutes
|
||||
@@ -112,8 +125,8 @@ Assuming I have a Python 2 and Python 3 environment that I want to make
|
||||
sure are available, I can install their specs system-wide (in /usr/local) with:
|
||||
|
||||
```bash
|
||||
/path/to/python3 -m IPython kernel install --prefix=/usr/local
|
||||
/path/to/python2 -m IPython kernel install --prefix=/usr/local
|
||||
/path/to/python3 -m ipykernel install --prefix=/usr/local
|
||||
/path/to/python2 -m ipykernel install --prefix=/usr/local
|
||||
```
|
||||
|
||||
## Multi-user hosts vs. Containers
|
||||
@@ -176,12 +189,40 @@ The number of named servers per user can be limited by setting
|
||||
c.JupyterHub.named_server_limit_per_user = 5
|
||||
```
|
||||
|
||||
## Switching to Jupyter Server
|
||||
(classic-notebook-ui)=
|
||||
|
||||
[Jupyter Server](https://jupyter-server.readthedocs.io/en/latest/) is a new Tornado Server backend for Jupyter web applications (e.g. JupyterLab 3.0 uses this package as its default backend).
|
||||
## Switching back to classic notebook
|
||||
|
||||
By default, the single-user notebook server uses the (old) `NotebookApp` from the [notebook](https://github.com/jupyter/notebook) package. You can switch to using Jupyter Server's `ServerApp` backend (this will likely become the default in future releases) by setting the `JUPYTERHUB_SINGLEUSER_APP` environment variable to:
|
||||
By default the single-user server launches JupyterLab,
|
||||
which is based on [Jupyter Server][].
|
||||
This is the default server when running JupyterHub ≥ 2.0.
|
||||
You can switch to using the legacy Jupyter Notebook server by setting the `JUPYTERHUB_SINGLEUSER_APP` environment variable
|
||||
(in the single-user environment) to:
|
||||
|
||||
```bash
|
||||
export JUPYTERHUB_SINGLEUSER_APP='notebook.notebookapp.NotebookApp'
|
||||
```
|
||||
|
||||
[jupyter server]: https://jupyter-server.readthedocs.io
|
||||
[jupyter notebook]: https://jupyter-notebook.readthedocs.io
|
||||
|
||||
:::{versionchanged} 2.0
|
||||
JupyterLab is now the default singleuser UI, if available,
|
||||
which is based on the [Jupyter Server][],
|
||||
no longer the legacy [Jupyter Notebook][] server.
|
||||
JupyterHub prior to 2.0 launched the legacy notebook server (`jupyter notebook`),
|
||||
and Jupyter server could be selected by specifying
|
||||
|
||||
```python
|
||||
# jupyterhub_config.py
|
||||
c.Spawner.cmd = ["jupyter-labhub"]
|
||||
```
|
||||
|
||||
or for an otherwise customized Jupyter Server app,
|
||||
set the environment variable:
|
||||
|
||||
```bash
|
||||
export JUPYTERHUB_SINGLEUSER_APP='jupyter_server.serverapp.ServerApp'
|
||||
```
|
||||
|
||||
:::
|
||||
|
@@ -16,10 +16,12 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
|
||||
proxy
|
||||
separate-proxy
|
||||
rest
|
||||
rest-api
|
||||
server-api
|
||||
monitoring
|
||||
database
|
||||
templates
|
||||
api-only
|
||||
../events/index
|
||||
config-user-env
|
||||
config-examples
|
||||
|
27
docs/source/reference/rest-api.md
Normal file
27
docs/source/reference/rest-api.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# JupyterHub REST API
|
||||
|
||||
Below is an interactive view of JupyterHub's OpenAPI specification.
|
||||
|
||||
<!-- client-rendered openapi UI copied from FastAPI -->
|
||||
|
||||
<link type="text/css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@4.1/swagger-ui-bundle.js"></script>
|
||||
<!-- `SwaggerUIBundle` is now available on the page -->
|
||||
|
||||
<!-- render the ui here -->
|
||||
<div id="openapi-ui"></div>
|
||||
|
||||
<script>
|
||||
const ui = SwaggerUIBundle({
|
||||
url: '../_static/rest-api.yml',
|
||||
dom_id: '#openapi-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "BaseLayout",
|
||||
deepLinking: true,
|
||||
showExtensions: true,
|
||||
showCommonExtensions: true,
|
||||
});
|
||||
</script>
|
@@ -1,14 +0,0 @@
|
||||
:orphan:
|
||||
|
||||
===================
|
||||
JupyterHub REST API
|
||||
===================
|
||||
|
||||
.. this doc exists as a resolvable link target
|
||||
.. which _static files are not
|
||||
|
||||
.. meta::
|
||||
:http-equiv=refresh: 0;url=../_static/rest-api/index.html
|
||||
|
||||
The rest API docs are `here <../_static/rest-api/index.html>`_
|
||||
if you are not redirected automatically.
|
@@ -1,3 +1,5 @@
|
||||
(rest-api)=
|
||||
|
||||
# Using JupyterHub's REST API
|
||||
|
||||
This section will give you information on:
|
||||
@@ -111,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
|
||||
@@ -302,12 +303,8 @@ or kubernetes pods.
|
||||
|
||||
## Learn more about the API
|
||||
|
||||
You can see the full [JupyterHub REST API][] for details. This REST API Spec can
|
||||
be viewed in a more [interactive style on swagger's petstore][].
|
||||
Both resources contain the same information and differ only in its display.
|
||||
Note: The Swagger specification is being renamed the [OpenAPI Initiative][].
|
||||
You can see the full [JupyterHub REST API][] for details.
|
||||
|
||||
[interactive style on swagger's petstore]: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#!/default
|
||||
[openapi initiative]: https://www.openapis.org/
|
||||
[jupyterhub rest api]: ./rest-api
|
||||
[jupyter notebook rest api]: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/notebook/HEAD/notebook/services/api/api.yaml
|
||||
|
@@ -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
|
||||
|
||||
|
46
docs/test_docs.py
Normal file
46
docs/test_docs.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from subprocess import run
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
yaml = YAML(typ="safe")
|
||||
|
||||
here = Path(__file__).absolute().parent
|
||||
root = here.parent
|
||||
|
||||
|
||||
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 = {}
|
||||
with version_py.open() as f:
|
||||
exec(f.read(), {}, ns)
|
||||
jupyterhub_version = ns["__version__"]
|
||||
|
||||
with rest_api_yaml.open() as f:
|
||||
rest_api = yaml.load(f)
|
||||
rest_api_version = rest_api["info"]["version"]
|
||||
|
||||
assert jupyterhub_version == rest_api_version
|
||||
|
||||
|
||||
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(
|
||||
[
|
||||
"git",
|
||||
"--no-pager",
|
||||
"diff",
|
||||
"--color=always",
|
||||
"--exit-code",
|
||||
str(here.joinpath("source", "_static", "rest-api.yml")),
|
||||
],
|
||||
cwd=here,
|
||||
check=True,
|
||||
)
|
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
|
@@ -29,7 +29,7 @@ def get_token():
|
||||
token_file = here.joinpath("service-token")
|
||||
log.info(f"Loading token from {token_file}")
|
||||
with token_file.open("r") as f:
|
||||
token = f.read()
|
||||
token = f.read().strip()
|
||||
return token
|
||||
|
||||
|
||||
|
@@ -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'],
|
||||
},
|
||||
]
|
||||
|
||||
|
@@ -31,6 +31,9 @@
|
||||
"@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",
|
||||
@@ -54,7 +57,7 @@
|
||||
"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",
|
||||
|
@@ -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();
|
||||
});
|
||||
|
@@ -10,6 +10,14 @@ import "./server-dashboard.css";
|
||||
import { timeSince } from "../../util/timeSince";
|
||||
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
||||
|
||||
const AccessServerButton = ({ userName, serverName }) => (
|
||||
<a href={`/user/${userName}/${serverName || ""}`}>
|
||||
<button className="btn btn-primary btn-xs" style={{ marginRight: 20 }}>
|
||||
Access Server
|
||||
</button>
|
||||
</a>
|
||||
);
|
||||
|
||||
const ServerDashboard = (props) => {
|
||||
// sort methods
|
||||
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
|
||||
@@ -27,7 +35,9 @@ 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({});
|
||||
|
||||
var user_data = useSelector((state) => state.user_data),
|
||||
user_page = useSelector((state) => state.user_page),
|
||||
@@ -60,7 +70,7 @@ const ServerDashboard = (props) => {
|
||||
};
|
||||
|
||||
if (!user_data) {
|
||||
return <div></div>;
|
||||
return <div data-testid="no-show"></div>;
|
||||
}
|
||||
|
||||
if (page != user_page) {
|
||||
@@ -71,8 +81,121 @@ const ServerDashboard = (props) => {
|
||||
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);
|
||||
})
|
||||
.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);
|
||||
})
|
||||
.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, numServers, serverName }) => {
|
||||
if (serverName) return null;
|
||||
return (
|
||||
<td rowspan={numServers}>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
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="manage-groups" style={{ float: "right", margin: "20px" }}>
|
||||
<Link to="/groups">{"> Manage Groups"}</Link>
|
||||
</div>
|
||||
@@ -85,6 +208,7 @@ const ServerDashboard = (props) => {
|
||||
<SortHandler
|
||||
sorts={{ asc: usernameAsc, desc: usernameDesc }}
|
||||
callback={(method) => setSortMethod(() => method)}
|
||||
testid="user-sort"
|
||||
/>
|
||||
</th>
|
||||
<th id="admin-header">
|
||||
@@ -92,6 +216,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 +232,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 +240,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 +260,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) => {
|
||||
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;
|
||||
})
|
||||
.then((res) => {
|
||||
updateUsers(...slice)
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data, page);
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
.catch(() =>
|
||||
setErrorAlert(`Failed to update users list.`)
|
||||
);
|
||||
return res;
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
.catch(() => setErrorAlert(`Failed to start servers.`));
|
||||
}}
|
||||
>
|
||||
Start All
|
||||
@@ -145,17 +296,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) => {
|
||||
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;
|
||||
})
|
||||
.then((res) => {
|
||||
updateUsers(...slice)
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data, page);
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
.catch(() =>
|
||||
setErrorAlert(`Failed to update users list.`)
|
||||
);
|
||||
return res;
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
.catch(() => setErrorAlert(`Failed to stop servers.`));
|
||||
}}
|
||||
>
|
||||
Stop All
|
||||
@@ -172,70 +339,88 @@ 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>
|
||||
))}
|
||||
{user_data.flatMap((e, i) => {
|
||||
let userServers = Object.values({
|
||||
"": e.server,
|
||||
...(e.servers || {}),
|
||||
});
|
||||
return userServers.map((server) => {
|
||||
server = { name: "", ...server };
|
||||
return (
|
||||
<tr key={i + "row"} className="user-row">
|
||||
{!server.name && (
|
||||
<td
|
||||
data-testid="user-row-name"
|
||||
rowspan={userServers.length}
|
||||
>
|
||||
{e.name}
|
||||
</td>
|
||||
)}
|
||||
{!server.name && (
|
||||
<td
|
||||
data-testid="user-row-admin"
|
||||
rowspan={userServers.length}
|
||||
>
|
||||
{e.admin ? "admin" : ""}
|
||||
</td>
|
||||
)}
|
||||
|
||||
<td data-testid="user-row-server">
|
||||
{server.name ? (
|
||||
<p class="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={e.name}
|
||||
/>
|
||||
<AccessServerButton
|
||||
serverName={server.name}
|
||||
userName={e.name}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Start Single-user server
|
||||
<>
|
||||
<StartServerButton
|
||||
serverName={server.name}
|
||||
userName={e.name}
|
||||
/>
|
||||
<a
|
||||
href={`/spawn/${e.name}${
|
||||
server.name && "/" + server.name
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className="btn btn-secondary btn-xs"
|
||||
style={{ marginRight: 20 }}
|
||||
>
|
||||
Spawn Page
|
||||
</button>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<EditUserCell
|
||||
user={e}
|
||||
numServers={userServers.length}
|
||||
serverName={server.name}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<PaginationFooter
|
||||
@@ -269,13 +454,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 +489,7 @@ const SortHandler = (props) => {
|
||||
SortHandler.propTypes = {
|
||||
sorts: PropTypes.object,
|
||||
callback: PropTypes.func,
|
||||
testid: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ServerDashboard;
|
||||
|
@@ -1,161 +1,437 @@
|
||||
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 { 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";
|
||||
|
||||
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(() => {
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback(mockAppState());
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSelector.mockClear();
|
||||
});
|
||||
|
||||
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.getByText("foo");
|
||||
let bar = screen.getByText("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).toBe("bar");
|
||||
|
||||
fireEvent.click(handler);
|
||||
|
||||
first = screen.getAllByTestId("user-row-name")[0];
|
||||
expect(first.textContent).toBe("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).toBe("foo");
|
||||
|
||||
fireEvent.click(handler);
|
||||
|
||||
first = screen.getAllByTestId("user-row-name")[0];
|
||||
expect(first.textContent).toBe("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).toBe("foo");
|
||||
|
||||
fireEvent.click(handler);
|
||||
|
||||
first = screen.getAllByTestId("user-row-name")[0];
|
||||
expect(first.textContent).toBe("bar");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
@@ -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: {
|
||||
|
@@ -11,8 +11,10 @@ const withAPI = withProps(() => ({
|
||||
(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 +38,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: () =>
|
||||
|
567
jsx/yarn.lock
567
jsx/yarn.lock
@@ -935,6 +935,14 @@
|
||||
"@babel/plugin-transform-react-jsx-development" "^7.12.7"
|
||||
"@babel/plugin-transform-react-pure-annotations" "^7.12.1"
|
||||
|
||||
"@babel/runtime-corejs3@^7.10.2":
|
||||
version "7.16.3"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.16.3.tgz#1e25de4fa994c57c18e5fdda6cc810dac70f5590"
|
||||
integrity sha512-IAdDC7T0+wEB4y2gbIL0uOXEYpiZEeuFUTVbdGq+UwCcF35T/tS8KrmMomEwEc5wBbyfH3PJVpTSUqrhPDXFcQ==
|
||||
dependencies:
|
||||
core-js-pure "^3.19.0"
|
||||
regenerator-runtime "^0.13.4"
|
||||
|
||||
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.4.2", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
|
||||
version "7.12.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e"
|
||||
@@ -942,6 +950,13 @@
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.4"
|
||||
|
||||
"@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.9.2":
|
||||
version "7.16.3"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5"
|
||||
integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.4"
|
||||
|
||||
"@babel/template@^7.10.4":
|
||||
version "7.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278"
|
||||
@@ -1224,6 +1239,17 @@
|
||||
"@types/yargs" "^15.0.0"
|
||||
chalk "^4.0.0"
|
||||
|
||||
"@jest/types@^27.4.2":
|
||||
version "27.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@jest/types/-/types-27.4.2.tgz#96536ebd34da6392c2b7c7737d693885b5dd44a5"
|
||||
integrity sha512-j35yw0PMTPpZsUoOBiuHzr1zTYoad1cVIE0ajEjcrJONxxrko/IRGKkXx3os0Nsi4Hu3+5VmDbVfq5WhG/pWAg==
|
||||
dependencies:
|
||||
"@types/istanbul-lib-coverage" "^2.0.0"
|
||||
"@types/istanbul-reports" "^3.0.0"
|
||||
"@types/node" "*"
|
||||
"@types/yargs" "^16.0.0"
|
||||
chalk "^4.0.0"
|
||||
|
||||
"@popperjs/core@^2.5.3":
|
||||
version "2.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.5.4.tgz#de25b5da9f727985a3757fd59b5d028aba75841a"
|
||||
@@ -1256,6 +1282,55 @@
|
||||
dependencies:
|
||||
"@sinonjs/commons" "^1.7.0"
|
||||
|
||||
"@testing-library/dom@^8.0.0":
|
||||
version "8.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.11.1.tgz#03fa2684aa09ade589b460db46b4c7be9fc69753"
|
||||
integrity sha512-3KQDyx9r0RKYailW2MiYrSSKEfH0GTkI51UGEvJenvcoDoeRYs0PZpi2SXqtnMClQvCqdtTTpOfFETDTVADpAg==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.10.4"
|
||||
"@babel/runtime" "^7.12.5"
|
||||
"@types/aria-query" "^4.2.0"
|
||||
aria-query "^5.0.0"
|
||||
chalk "^4.1.0"
|
||||
dom-accessibility-api "^0.5.9"
|
||||
lz-string "^1.4.4"
|
||||
pretty-format "^27.0.2"
|
||||
|
||||
"@testing-library/jest-dom@^5.15.1":
|
||||
version "5.15.1"
|
||||
resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.15.1.tgz#4c49ba4d244f235aec53f0a83498daeb4ee06c33"
|
||||
integrity sha512-kmj8opVDRE1E4GXyLlESsQthCXK7An28dFWxhiMwD7ZUI7ZxA6sjdJRxLerD9Jd8cHX4BDc1jzXaaZKqzlUkvg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.9.2"
|
||||
"@types/testing-library__jest-dom" "^5.9.1"
|
||||
aria-query "^4.2.2"
|
||||
chalk "^3.0.0"
|
||||
css "^3.0.0"
|
||||
css.escape "^1.5.1"
|
||||
dom-accessibility-api "^0.5.6"
|
||||
lodash "^4.17.15"
|
||||
redent "^3.0.0"
|
||||
|
||||
"@testing-library/react@^12.1.2":
|
||||
version "12.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.2.tgz#f1bc9a45943461fa2a598bb4597df1ae044cfc76"
|
||||
integrity sha512-ihQiEOklNyHIpo2Y8FREkyD1QAea054U0MVbwH1m8N9TxeFz+KoJ9LkqoKqJlzx2JDm56DVwaJ1r36JYxZM05g==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
"@testing-library/dom" "^8.0.0"
|
||||
|
||||
"@testing-library/user-event@^13.5.0":
|
||||
version "13.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-13.5.0.tgz#69d77007f1e124d55314a2b73fd204b333b13295"
|
||||
integrity sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
|
||||
"@types/aria-query@^4.2.0":
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.2.tgz#ed4e0ad92306a704f9fb132a0cfcf77486dbe2bc"
|
||||
integrity sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==
|
||||
|
||||
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
|
||||
version "7.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.12.tgz#4d8e9e51eb265552a7e4f1ff2219ab6133bdfb2d"
|
||||
@@ -1354,6 +1429,14 @@
|
||||
dependencies:
|
||||
"@types/istanbul-lib-report" "*"
|
||||
|
||||
"@types/jest@*":
|
||||
version "27.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.0.3.tgz#0cf9dfe9009e467f70a342f0f94ead19842a783a"
|
||||
integrity sha512-cmmwv9t7gBYt7hNKH5Spu7Kuu/DotGa+Ff+JGRKZ4db5eh8PnKS4LuebJ3YLUoyOyIHraTGyULn23YtEAm0VSg==
|
||||
dependencies:
|
||||
jest-diff "^27.0.0"
|
||||
pretty-format "^27.0.0"
|
||||
|
||||
"@types/json-schema@*", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6":
|
||||
version "7.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0"
|
||||
@@ -1411,6 +1494,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff"
|
||||
integrity sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==
|
||||
|
||||
"@types/testing-library__jest-dom@^5.9.1":
|
||||
version "5.14.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.2.tgz#564fb2b2dc827147e937a75b639a05d17ce18b44"
|
||||
integrity sha512-vehbtyHUShPxIa9SioxDwCvgxukDMH//icJG90sXQBUm5lJOHLT5kNeU9tnivhnA/TkOFMzGIXN2cTc4hY8/kg==
|
||||
dependencies:
|
||||
"@types/jest" "*"
|
||||
|
||||
"@types/warning@^3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.0.tgz#0d2501268ad8f9962b740d387c4654f5f8e23e52"
|
||||
@@ -1428,6 +1518,13 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@types/yargs@^16.0.0":
|
||||
version "16.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-16.0.4.tgz#26aad98dd2c2a38e421086ea9ad42b9e51642977"
|
||||
integrity sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@webassemblyjs/ast@1.9.0":
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964"
|
||||
@@ -1573,20 +1670,30 @@
|
||||
"@webassemblyjs/wast-parser" "1.9.0"
|
||||
"@xtuc/long" "4.2.2"
|
||||
|
||||
"@wojtekmaj/enzyme-adapter-react-17@^0.4.1":
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@wojtekmaj/enzyme-adapter-react-17/-/enzyme-adapter-react-17-0.4.1.tgz#a9d4a2873025c6de19e1142ca076661bac69f587"
|
||||
integrity sha512-WZr8i4C6WVDV7Mb8sbm7GdlEPmk1f+xOMjUKThqrkWgwsfvu90zJyyX54wyAvsS91sjtKZ0JipGj2cJnEDaxPA==
|
||||
"@wojtekmaj/enzyme-adapter-react-17@^0.6.5":
|
||||
version "0.6.5"
|
||||
resolved "https://registry.yarnpkg.com/@wojtekmaj/enzyme-adapter-react-17/-/enzyme-adapter-react-17-0.6.5.tgz#1925e17aaea7089e7ec66c7c35e5771e49b6bf7e"
|
||||
integrity sha512-ChIObUiXXYUiqzXPqOai+p6KF5dlbItpDDYsftUOQiAiygbMDlLeJIjynC6ZrJIa2U2MpRp4YJmtR2GQyIHjgA==
|
||||
dependencies:
|
||||
enzyme-adapter-utils "^1.14.0"
|
||||
enzyme-shallow-equal "^1.0.4"
|
||||
has "^1.0.3"
|
||||
"@wojtekmaj/enzyme-adapter-utils" "^0.1.1"
|
||||
enzyme-shallow-equal "^1.0.0"
|
||||
has "^1.0.0"
|
||||
object.assign "^4.1.0"
|
||||
object.values "^1.1.1"
|
||||
prop-types "^15.7.2"
|
||||
react-is "^17.0.0"
|
||||
object.values "^1.1.0"
|
||||
prop-types "^15.7.0"
|
||||
react-is "^17.0.2"
|
||||
react-test-renderer "^17.0.0"
|
||||
semver "^5.7.0"
|
||||
|
||||
"@wojtekmaj/enzyme-adapter-utils@^0.1.1":
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@wojtekmaj/enzyme-adapter-utils/-/enzyme-adapter-utils-0.1.1.tgz#17773cf264570fbcfc0d33bb74e4002c17f2f1ec"
|
||||
integrity sha512-bNPWtN/d8huKOkC6j1E3EkSamnRrHHT7YuR6f9JppAQqtoAm3v4/vERe4J14jQKmHLCyEBHXrlgb7H6l817hVg==
|
||||
dependencies:
|
||||
function.prototype.name "^1.1.0"
|
||||
has "^1.0.0"
|
||||
object.assign "^4.1.0"
|
||||
object.fromentries "^2.0.0"
|
||||
prop-types "^15.7.0"
|
||||
|
||||
"@xtuc/ieee754@^1.2.0":
|
||||
version "1.2.0"
|
||||
@@ -1639,21 +1746,6 @@ acorn@^8.0.4:
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.0.4.tgz#7a3ae4191466a6984eee0fe3407a4f3aa9db8354"
|
||||
integrity sha512-XNP0PqF1XD19ZlLKvB7cMmnZswW4C/03pRHgirB30uSJTaS3A3V1/P4sS3HPvFmjoriPCJQs+JDSbm4bL1TxGQ==
|
||||
|
||||
airbnb-prop-types@^2.16.0:
|
||||
version "2.16.0"
|
||||
resolved "https://registry.yarnpkg.com/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz#b96274cefa1abb14f623f804173ee97c13971dc2"
|
||||
integrity sha512-7WHOFolP/6cS96PhKNrslCLMYAI8yB1Pp6u6XmxozQOiZbsI5ycglZr5cHhBFfuRcQQjzCMith5ZPZdYiJCxUg==
|
||||
dependencies:
|
||||
array.prototype.find "^2.1.1"
|
||||
function.prototype.name "^1.1.2"
|
||||
is-regex "^1.1.0"
|
||||
object-is "^1.1.2"
|
||||
object.assign "^4.1.0"
|
||||
object.entries "^1.1.2"
|
||||
prop-types "^15.7.2"
|
||||
prop-types-exact "^1.2.0"
|
||||
react-is "^16.13.1"
|
||||
|
||||
ajv-errors@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d"
|
||||
@@ -1721,6 +1813,11 @@ ansi-regex@^5.0.0:
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75"
|
||||
integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==
|
||||
|
||||
ansi-regex@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
||||
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
|
||||
|
||||
ansi-styles@^3.2.0, ansi-styles@^3.2.1:
|
||||
version "3.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
|
||||
@@ -1735,6 +1832,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0:
|
||||
dependencies:
|
||||
color-convert "^2.0.1"
|
||||
|
||||
ansi-styles@^5.0.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b"
|
||||
integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
|
||||
|
||||
anymatch@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb"
|
||||
@@ -1758,6 +1860,19 @@ argparse@^1.0.7:
|
||||
dependencies:
|
||||
sprintf-js "~1.0.2"
|
||||
|
||||
aria-query@^4.2.2:
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b"
|
||||
integrity sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.10.2"
|
||||
"@babel/runtime-corejs3" "^7.10.2"
|
||||
|
||||
aria-query@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.0.0.tgz#210c21aaf469613ee8c9a62c7f86525e058db52c"
|
||||
integrity sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==
|
||||
|
||||
arr-diff@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
|
||||
@@ -1816,14 +1931,6 @@ array-unique@^0.3.2:
|
||||
resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
|
||||
integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=
|
||||
|
||||
array.prototype.find@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/array.prototype.find/-/array.prototype.find-2.1.1.tgz#3baca26108ca7affb08db06bf0be6cb3115a969c"
|
||||
integrity sha512-mi+MYNJYLTx2eNYy+Yh6raoQacCsNeeMUaspFPh9Y141lFSsWxxB8V9mM2ye+eqiRs917J6/pJ4M9ZPzenWckA==
|
||||
dependencies:
|
||||
define-properties "^1.1.3"
|
||||
es-abstract "^1.17.4"
|
||||
|
||||
array.prototype.flat@^1.2.3:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz#6ef638b43312bd401b4c6199fdec7e2dc9e9a123"
|
||||
@@ -2217,6 +2324,14 @@ chalk@^2.0.0, chalk@^2.4.2:
|
||||
escape-string-regexp "^1.0.5"
|
||||
supports-color "^5.3.0"
|
||||
|
||||
chalk@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4"
|
||||
integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==
|
||||
dependencies:
|
||||
ansi-styles "^4.1.0"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
chalk@^4.0.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a"
|
||||
@@ -2225,6 +2340,14 @@ chalk@^4.0.0:
|
||||
ansi-styles "^4.1.0"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
chalk@^4.1.0:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
|
||||
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
|
||||
dependencies:
|
||||
ansi-styles "^4.1.0"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
change-emitter@^0.1.2:
|
||||
version "0.1.6"
|
||||
resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515"
|
||||
@@ -2469,6 +2592,11 @@ core-js-compat@^3.8.0:
|
||||
browserslist "^4.16.0"
|
||||
semver "7.0.0"
|
||||
|
||||
core-js-pure@^3.19.0:
|
||||
version "3.19.2"
|
||||
resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.19.2.tgz#26b5bfb503178cff6e3e115bc2ba6c6419383680"
|
||||
integrity sha512-5LkcgQEy8pFeVnd/zomkUBSwnmIxuF1C8E9KrMAbOc8f34IBT9RGvTYeNDdp1PnvMJrrVhvk1hg/yVV5h/znlg==
|
||||
|
||||
core-js@^1.0.0:
|
||||
version "1.2.7"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
|
||||
@@ -2533,6 +2661,20 @@ css-what@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/css-what/-/css-what-4.0.0.tgz#35e73761cab2eeb3d3661126b23d7aa0e8432233"
|
||||
integrity sha512-teijzG7kwYfNVsUh2H/YN62xW3KK9YhXEgSlbxMlcyjPNvdKJqFx5lrwlJgoFP1ZHlB89iGDlo/JyshKeRhv5A==
|
||||
|
||||
css.escape@^1.5.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb"
|
||||
integrity sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=
|
||||
|
||||
css@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/css/-/css-3.0.0.tgz#4447a4d58fdd03367c516ca9f64ae365cee4aa5d"
|
||||
integrity sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==
|
||||
dependencies:
|
||||
inherits "^2.0.4"
|
||||
source-map "^0.6.1"
|
||||
source-map-resolve "^0.6.0"
|
||||
|
||||
cssesc@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
||||
@@ -2719,6 +2861,11 @@ diff-sequences@^26.6.2:
|
||||
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1"
|
||||
integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==
|
||||
|
||||
diff-sequences@^27.4.0:
|
||||
version "27.4.0"
|
||||
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.4.0.tgz#d783920ad8d06ec718a060d00196dfef25b132a5"
|
||||
integrity sha512-YqiQzkrsmHMH5uuh8OdQFU9/ZpADnwzml8z0O5HvRNda+5UZsaX/xN+AAxfR2hWq1Y7HZnAzO9J5lJXOuDz2Ww==
|
||||
|
||||
discontinuous-range@1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a"
|
||||
@@ -2758,6 +2905,11 @@ doctrine@^3.0.0:
|
||||
dependencies:
|
||||
esutils "^2.0.2"
|
||||
|
||||
dom-accessibility-api@^0.5.6, dom-accessibility-api@^0.5.9:
|
||||
version "0.5.10"
|
||||
resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.10.tgz#caa6d08f60388d0bb4539dd75fe458a9a1d0014c"
|
||||
integrity sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g==
|
||||
|
||||
dom-helpers@^5.0.1, dom-helpers@^5.1.2, dom-helpers@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.0.tgz#57fd054c5f8f34c52a3eeffdb7e7e93cd357d95b"
|
||||
@@ -2889,20 +3041,7 @@ entities@^2.0.0, entities@~2.1.0:
|
||||
resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5"
|
||||
integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==
|
||||
|
||||
enzyme-adapter-utils@^1.14.0:
|
||||
version "1.14.0"
|
||||
resolved "https://registry.yarnpkg.com/enzyme-adapter-utils/-/enzyme-adapter-utils-1.14.0.tgz#afbb0485e8033aa50c744efb5f5711e64fbf1ad0"
|
||||
integrity sha512-F/z/7SeLt+reKFcb7597IThpDp0bmzcH1E9Oabqv+o01cID2/YInlqHbFl7HzWBl4h3OdZYedtwNDOmSKkk0bg==
|
||||
dependencies:
|
||||
airbnb-prop-types "^2.16.0"
|
||||
function.prototype.name "^1.1.3"
|
||||
has "^1.0.3"
|
||||
object.assign "^4.1.2"
|
||||
object.fromentries "^2.0.3"
|
||||
prop-types "^15.7.2"
|
||||
semver "^5.7.1"
|
||||
|
||||
enzyme-shallow-equal@^1.0.1, enzyme-shallow-equal@^1.0.4:
|
||||
enzyme-shallow-equal@^1.0.0, enzyme-shallow-equal@^1.0.1:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.4.tgz#b9256cb25a5f430f9bfe073a84808c1d74fced2e"
|
||||
integrity sha512-MttIwB8kKxypwHvRynuC3ahyNc+cFbR8mjVIltnmzQ0uKGqmsfO4bfBuLxb0beLNPhjblUEYvEbsg+VSygvF1Q==
|
||||
@@ -2952,7 +3091,7 @@ error-ex@^1.3.1:
|
||||
dependencies:
|
||||
is-arrayish "^0.2.1"
|
||||
|
||||
es-abstract@^1.17.0-next.1, es-abstract@^1.17.4:
|
||||
es-abstract@^1.17.0-next.1:
|
||||
version "1.17.7"
|
||||
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c"
|
||||
integrity sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g==
|
||||
@@ -2987,6 +3126,32 @@ es-abstract@^1.18.0-next.1:
|
||||
string.prototype.trimend "^1.0.1"
|
||||
string.prototype.trimstart "^1.0.1"
|
||||
|
||||
es-abstract@^1.19.0, es-abstract@^1.19.1:
|
||||
version "1.19.1"
|
||||
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.19.1.tgz#d4885796876916959de78edaa0df456627115ec3"
|
||||
integrity sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==
|
||||
dependencies:
|
||||
call-bind "^1.0.2"
|
||||
es-to-primitive "^1.2.1"
|
||||
function-bind "^1.1.1"
|
||||
get-intrinsic "^1.1.1"
|
||||
get-symbol-description "^1.0.0"
|
||||
has "^1.0.3"
|
||||
has-symbols "^1.0.2"
|
||||
internal-slot "^1.0.3"
|
||||
is-callable "^1.2.4"
|
||||
is-negative-zero "^2.0.1"
|
||||
is-regex "^1.1.4"
|
||||
is-shared-array-buffer "^1.0.1"
|
||||
is-string "^1.0.7"
|
||||
is-weakref "^1.0.1"
|
||||
object-inspect "^1.11.0"
|
||||
object-keys "^1.1.1"
|
||||
object.assign "^4.1.2"
|
||||
string.prototype.trimend "^1.0.4"
|
||||
string.prototype.trimstart "^1.0.4"
|
||||
unbox-primitive "^1.0.1"
|
||||
|
||||
es-to-primitive@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a"
|
||||
@@ -3499,9 +3664,9 @@ flatted@^3.1.0:
|
||||
integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==
|
||||
|
||||
follow-redirects@^1.0.0:
|
||||
version "1.13.0"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db"
|
||||
integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==
|
||||
version "1.14.8"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc"
|
||||
integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==
|
||||
|
||||
for-in@^1.0.2:
|
||||
version "1.0.2"
|
||||
@@ -3562,7 +3727,17 @@ function-bind@^1.1.1:
|
||||
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
|
||||
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
|
||||
|
||||
function.prototype.name@^1.1.2, function.prototype.name@^1.1.3:
|
||||
function.prototype.name@^1.1.0:
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621"
|
||||
integrity sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==
|
||||
dependencies:
|
||||
call-bind "^1.0.2"
|
||||
define-properties "^1.1.3"
|
||||
es-abstract "^1.19.0"
|
||||
functions-have-names "^1.2.2"
|
||||
|
||||
function.prototype.name@^1.1.2:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.3.tgz#0bb034bb308e7682826f215eb6b2ae64918847fe"
|
||||
integrity sha512-H51qkbNSp8mtkJt+nyW1gyStBiKZxfRqySNUR99ylq6BPXHKI4SEvIlTKp4odLfjRKJV04DFWMU3G/YRlQOsag==
|
||||
@@ -3577,7 +3752,7 @@ functional-red-black-tree@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
|
||||
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
|
||||
|
||||
functions-have-names@^1.2.1:
|
||||
functions-have-names@^1.2.1, functions-have-names@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.2.tgz#98d93991c39da9361f8e50b337c4f6e41f120e21"
|
||||
integrity sha512-bLgc3asbWdwPbx2mNk2S49kmJCuQeu0nfmaOgbs8WIyzzkw3r4htszdIi9Q9EMezDPTYuJx2wvjZ/EwgAthpnA==
|
||||
@@ -3610,6 +3785,15 @@ get-intrinsic@^1.0.1, get-intrinsic@^1.0.2:
|
||||
has "^1.0.3"
|
||||
has-symbols "^1.0.1"
|
||||
|
||||
get-intrinsic@^1.1.0, get-intrinsic@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6"
|
||||
integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==
|
||||
dependencies:
|
||||
function-bind "^1.1.1"
|
||||
has "^1.0.3"
|
||||
has-symbols "^1.0.1"
|
||||
|
||||
get-package-type@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a"
|
||||
@@ -3629,6 +3813,14 @@ get-stream@^5.0.0:
|
||||
dependencies:
|
||||
pump "^3.0.0"
|
||||
|
||||
get-symbol-description@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6"
|
||||
integrity sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==
|
||||
dependencies:
|
||||
call-bind "^1.0.2"
|
||||
get-intrinsic "^1.1.1"
|
||||
|
||||
get-value@^2.0.3, get-value@^2.0.6:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
|
||||
@@ -3770,6 +3962,11 @@ harmony-reflect@^1.4.6:
|
||||
resolved "https://registry.yarnpkg.com/harmony-reflect/-/harmony-reflect-1.6.1.tgz#c108d4f2bb451efef7a37861fdbdae72c9bdefa9"
|
||||
integrity sha512-WJTeyp0JzGtHcuMsi7rw2VwtkvLa+JyfEKJCFyfcS0+CDkjQ5lHPu7zEhFZP+PDSRrEgXa5Ah0l1MbgbE41XjA==
|
||||
|
||||
has-bigints@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113"
|
||||
integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==
|
||||
|
||||
has-flag@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
|
||||
@@ -3785,6 +3982,18 @@ has-symbols@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
|
||||
integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
|
||||
|
||||
has-symbols@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423"
|
||||
integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
|
||||
|
||||
has-tostringtag@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25"
|
||||
integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==
|
||||
dependencies:
|
||||
has-symbols "^1.0.2"
|
||||
|
||||
has-value@^0.3.1:
|
||||
version "0.3.1"
|
||||
resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f"
|
||||
@@ -3816,7 +4025,7 @@ has-values@^1.0.0:
|
||||
is-number "^3.0.0"
|
||||
kind-of "^4.0.0"
|
||||
|
||||
has@^1.0.3:
|
||||
has@^1.0.0, has@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
|
||||
integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
|
||||
@@ -4045,6 +4254,11 @@ imurmurhash@^0.1.4:
|
||||
resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
|
||||
integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
|
||||
|
||||
indent-string@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251"
|
||||
integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
|
||||
|
||||
indexes-of@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607"
|
||||
@@ -4058,7 +4272,7 @@ inflight@^1.0.4:
|
||||
once "^1.3.0"
|
||||
wrappy "1"
|
||||
|
||||
inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3:
|
||||
inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
@@ -4090,6 +4304,15 @@ internal-slot@^1.0.2:
|
||||
has "^1.0.3"
|
||||
side-channel "^1.0.2"
|
||||
|
||||
internal-slot@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c"
|
||||
integrity sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==
|
||||
dependencies:
|
||||
get-intrinsic "^1.1.0"
|
||||
has "^1.0.3"
|
||||
side-channel "^1.0.4"
|
||||
|
||||
interpret@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
|
||||
@@ -4146,6 +4369,13 @@ is-arrayish@^0.2.1:
|
||||
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
|
||||
integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
|
||||
|
||||
is-bigint@^1.0.1:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3"
|
||||
integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==
|
||||
dependencies:
|
||||
has-bigints "^1.0.1"
|
||||
|
||||
is-binary-path@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898"
|
||||
@@ -4160,6 +4390,14 @@ is-boolean-object@^1.0.1:
|
||||
dependencies:
|
||||
call-bind "^1.0.0"
|
||||
|
||||
is-boolean-object@^1.1.0:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719"
|
||||
integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==
|
||||
dependencies:
|
||||
call-bind "^1.0.2"
|
||||
has-tostringtag "^1.0.0"
|
||||
|
||||
is-buffer@^1.1.5:
|
||||
version "1.1.6"
|
||||
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
|
||||
@@ -4170,6 +4408,11 @@ is-callable@^1.1.4, is-callable@^1.1.5, is-callable@^1.2.2:
|
||||
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9"
|
||||
integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA==
|
||||
|
||||
is-callable@^1.2.4:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945"
|
||||
integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==
|
||||
|
||||
is-ci@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c"
|
||||
@@ -4277,6 +4520,11 @@ is-negative-zero@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.0.tgz#9553b121b0fac28869da9ed459e20c7543788461"
|
||||
integrity sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE=
|
||||
|
||||
is-negative-zero@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24"
|
||||
integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==
|
||||
|
||||
is-number-object@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197"
|
||||
@@ -4325,13 +4573,26 @@ is-potential-custom-element-name@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397"
|
||||
integrity sha1-DFLlS8yjkbssSUsh6GJtczbG45c=
|
||||
|
||||
is-regex@^1.0.4, is-regex@^1.0.5, is-regex@^1.1.0, is-regex@^1.1.1:
|
||||
is-regex@^1.0.4, is-regex@^1.0.5, is-regex@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9"
|
||||
integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==
|
||||
dependencies:
|
||||
has-symbols "^1.0.1"
|
||||
|
||||
is-regex@^1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958"
|
||||
integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==
|
||||
dependencies:
|
||||
call-bind "^1.0.2"
|
||||
has-tostringtag "^1.0.0"
|
||||
|
||||
is-shared-array-buffer@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz#97b0c85fbdacb59c9c446fe653b82cf2b5b7cfe6"
|
||||
integrity sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==
|
||||
|
||||
is-stream@^1.0.1, is-stream@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
|
||||
@@ -4347,6 +4608,13 @@ is-string@^1.0.5:
|
||||
resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6"
|
||||
integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==
|
||||
|
||||
is-string@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd"
|
||||
integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==
|
||||
dependencies:
|
||||
has-tostringtag "^1.0.0"
|
||||
|
||||
is-subset@^0.1.1:
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/is-subset/-/is-subset-0.1.1.tgz#8a59117d932de1de00f245fcdd39ce43f1e939a6"
|
||||
@@ -4359,11 +4627,25 @@ is-symbol@^1.0.2:
|
||||
dependencies:
|
||||
has-symbols "^1.0.1"
|
||||
|
||||
is-symbol@^1.0.3:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c"
|
||||
integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==
|
||||
dependencies:
|
||||
has-symbols "^1.0.2"
|
||||
|
||||
is-typedarray@^1.0.0, is-typedarray@~1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
|
||||
integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
|
||||
|
||||
is-weakref@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.1.tgz#842dba4ec17fa9ac9850df2d6efbc1737274f2a2"
|
||||
integrity sha512-b2jKc2pQZjaeFYWEf7ScFj+Be1I+PXmlu572Q8coTXZ+LD/QQZ7ShPMst8h16riVgyXTQwUsFEl74mDvc/3MHQ==
|
||||
dependencies:
|
||||
call-bind "^1.0.0"
|
||||
|
||||
is-windows@^1.0.1, is-windows@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
|
||||
@@ -4524,6 +4806,16 @@ jest-diff@^26.6.2:
|
||||
jest-get-type "^26.3.0"
|
||||
pretty-format "^26.6.2"
|
||||
|
||||
jest-diff@^27.0.0:
|
||||
version "27.4.2"
|
||||
resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.4.2.tgz#786b2a5211d854f848e2dcc1e324448e9481f36f"
|
||||
integrity sha512-ujc9ToyUZDh9KcqvQDkk/gkbf6zSaeEg9AiBxtttXW59H/AcqEYp1ciXAtJp+jXWva5nAf/ePtSsgWwE5mqp4Q==
|
||||
dependencies:
|
||||
chalk "^4.0.0"
|
||||
diff-sequences "^27.4.0"
|
||||
jest-get-type "^27.4.0"
|
||||
pretty-format "^27.4.2"
|
||||
|
||||
jest-docblock@^26.0.0:
|
||||
version "26.0.0"
|
||||
resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-26.0.0.tgz#3e2fa20899fc928cb13bd0ff68bd3711a36889b5"
|
||||
@@ -4572,6 +4864,11 @@ jest-get-type@^26.3.0:
|
||||
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0"
|
||||
integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==
|
||||
|
||||
jest-get-type@^27.4.0:
|
||||
version "27.4.0"
|
||||
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.4.0.tgz#7503d2663fffa431638337b3998d39c5e928e9b5"
|
||||
integrity sha512-tk9o+ld5TWq41DkK14L4wox4s2D9MtTpKaAVzXfr5CUKm5ZK2ExcaFE0qls2W71zE/6R2TxxrK9w2r6svAFDBQ==
|
||||
|
||||
jest-haste-map@^26.6.2:
|
||||
version "26.6.2"
|
||||
resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.6.2.tgz#dd7e60fe7dc0e9f911a23d79c5ff7fb5c2cafeaa"
|
||||
@@ -5114,6 +5411,11 @@ lru-cache@^6.0.0:
|
||||
dependencies:
|
||||
yallist "^4.0.0"
|
||||
|
||||
lz-string@^1.4.4:
|
||||
version "1.4.4"
|
||||
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
|
||||
integrity sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=
|
||||
|
||||
make-dir@^2.0.0, make-dir@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
|
||||
@@ -5250,6 +5552,11 @@ mimic-fn@^2.1.0:
|
||||
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
|
||||
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
|
||||
|
||||
min-indent@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
|
||||
integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
|
||||
|
||||
mini-create-react-context@^0.4.0:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz#072171561bfdc922da08a60c2197a497cc2d1d5e"
|
||||
@@ -5329,9 +5636,9 @@ nan@^2.12.1:
|
||||
integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==
|
||||
|
||||
nanoid@^3.1.23:
|
||||
version "3.1.23"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81"
|
||||
integrity sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c"
|
||||
integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==
|
||||
|
||||
nanomatch@^1.2.9:
|
||||
version "1.2.13"
|
||||
@@ -5457,9 +5764,9 @@ npm-run-path@^4.0.0:
|
||||
path-key "^3.0.0"
|
||||
|
||||
nth-check@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.0.0.tgz#1bb4f6dac70072fc313e8c9cd1417b5074c0a125"
|
||||
integrity sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q==
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.0.1.tgz#2efe162f5c3da06a28959fbd3db75dbeea9f0fc2"
|
||||
integrity sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==
|
||||
dependencies:
|
||||
boolbase "^1.0.0"
|
||||
|
||||
@@ -5487,6 +5794,11 @@ object-copy@^0.1.0:
|
||||
define-property "^0.2.5"
|
||||
kind-of "^3.0.3"
|
||||
|
||||
object-inspect@^1.11.0:
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.11.0.tgz#9dceb146cedd4148a0d9e51ab88d34cf509922b1"
|
||||
integrity sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==
|
||||
|
||||
object-inspect@^1.7.0, object-inspect@^1.9.0:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.9.0.tgz#c90521d74e1127b67266ded3394ad6116986533a"
|
||||
@@ -5545,7 +5857,16 @@ object.entries@^1.1.1, object.entries@^1.1.2:
|
||||
es-abstract "^1.18.0-next.1"
|
||||
has "^1.0.3"
|
||||
|
||||
object.fromentries@^2.0.2, object.fromentries@^2.0.3:
|
||||
object.fromentries@^2.0.0:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.5.tgz#7b37b205109c21e741e605727fe8b0ad5fa08251"
|
||||
integrity sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw==
|
||||
dependencies:
|
||||
call-bind "^1.0.2"
|
||||
define-properties "^1.1.3"
|
||||
es-abstract "^1.19.1"
|
||||
|
||||
object.fromentries@^2.0.2:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.3.tgz#13cefcffa702dc67750314a3305e8cb3fad1d072"
|
||||
integrity sha512-IDUSMXs6LOSJBWE++L0lzIbSqHl9KDCfff2x/JSEIDtEUavUnyMYC2ZGay/04Zq4UT8lvd4xNhU4/YHKibAOlw==
|
||||
@@ -5562,6 +5883,15 @@ object.pick@^1.3.0:
|
||||
dependencies:
|
||||
isobject "^3.0.1"
|
||||
|
||||
object.values@^1.1.0:
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.5.tgz#959f63e3ce9ef108720333082131e4a459b716ac"
|
||||
integrity sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==
|
||||
dependencies:
|
||||
call-bind "^1.0.2"
|
||||
define-properties "^1.1.3"
|
||||
es-abstract "^1.19.1"
|
||||
|
||||
object.values@^1.1.1:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.2.tgz#7a2015e06fcb0f546bd652486ce8583a4731c731"
|
||||
@@ -5948,6 +6278,16 @@ pretty-format@^26.6.2:
|
||||
ansi-styles "^4.0.0"
|
||||
react-is "^17.0.1"
|
||||
|
||||
pretty-format@^27.0.0, pretty-format@^27.0.2, pretty-format@^27.4.2:
|
||||
version "27.4.2"
|
||||
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.4.2.tgz#e4ce92ad66c3888423d332b40477c87d1dac1fb8"
|
||||
integrity sha512-p0wNtJ9oLuvgOQDEIZ9zQjZffK7KtyR6Si0jnXULIDwrlNF8Cuir3AZP0hHv0jmKuNN/edOnbMjnzd4uTcmWiw==
|
||||
dependencies:
|
||||
"@jest/types" "^27.4.2"
|
||||
ansi-regex "^5.0.1"
|
||||
ansi-styles "^5.0.0"
|
||||
react-is "^17.0.1"
|
||||
|
||||
process-nextick-args@~2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
|
||||
@@ -5973,15 +6313,6 @@ prompts@^2.0.1:
|
||||
kleur "^3.0.3"
|
||||
sisteransi "^1.0.5"
|
||||
|
||||
prop-types-exact@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/prop-types-exact/-/prop-types-exact-1.2.0.tgz#825d6be46094663848237e3925a98c6e944e9869"
|
||||
integrity sha512-K+Tk3Kd9V0odiXFP9fwDHUYRyvK3Nun3GVyPapSIs5OBkITAm15W0CPFD/YKTkMUAbc0b9CUwRQp2ybiBIq+eA==
|
||||
dependencies:
|
||||
has "^1.0.3"
|
||||
object.assign "^4.1.0"
|
||||
reflect.ownkeys "^0.2.0"
|
||||
|
||||
prop-types-extra@^1.1.0:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/prop-types-extra/-/prop-types-extra-1.1.1.tgz#58c3b74cbfbb95d304625975aa2f0848329a010b"
|
||||
@@ -5990,7 +6321,7 @@ prop-types-extra@^1.1.0:
|
||||
react-is "^16.3.2"
|
||||
warning "^4.0.0"
|
||||
|
||||
prop-types@^15.6.2, prop-types@^15.7.2:
|
||||
prop-types@^15.6.2, prop-types@^15.7.0, prop-types@^15.7.2:
|
||||
version "15.7.2"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
|
||||
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
|
||||
@@ -6135,7 +6466,7 @@ react-icons@^4.1.0:
|
||||
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.1.0.tgz#9ca9bcbf2e3aee8e86e378bb9d465842947bbfc3"
|
||||
integrity sha512-FCXBg1JbbR0vWALXIxmFAfozHdVIJmmwCD81Jk0EKOt7Ax4AdBNcaRkWhR0NaKy9ugJgoY3fFvo0PHpte55pXg==
|
||||
|
||||
"react-is@^16.12.0 || ^17.0.0", react-is@^17.0.0, react-is@^17.0.1:
|
||||
"react-is@^16.12.0 || ^17.0.0", react-is@^17.0.1:
|
||||
version "17.0.1"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339"
|
||||
integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==
|
||||
@@ -6145,6 +6476,11 @@ react-is@^16.13.1, react-is@^16.3.2, react-is@^16.6.0, react-is@^16.7.0, react-i
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
|
||||
react-is@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
|
||||
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
|
||||
|
||||
react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
|
||||
@@ -6309,6 +6645,14 @@ recompose@^0.30.0:
|
||||
react-lifecycles-compat "^3.0.2"
|
||||
symbol-observable "^1.0.4"
|
||||
|
||||
redent@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f"
|
||||
integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==
|
||||
dependencies:
|
||||
indent-string "^4.0.0"
|
||||
strip-indent "^3.0.0"
|
||||
|
||||
redux@^4.0.5:
|
||||
version "4.0.5"
|
||||
resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f"
|
||||
@@ -6317,11 +6661,6 @@ redux@^4.0.5:
|
||||
loose-envify "^1.4.0"
|
||||
symbol-observable "^1.2.0"
|
||||
|
||||
reflect.ownkeys@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460"
|
||||
integrity sha1-dJrO7H8/34tj+SegSAnpDFwLNGA=
|
||||
|
||||
regenerate-unicode-properties@^8.2.0:
|
||||
version "8.2.0"
|
||||
resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec"
|
||||
@@ -6659,7 +6998,7 @@ selfsigned@^1.10.7:
|
||||
dependencies:
|
||||
node-forge "^0.10.0"
|
||||
|
||||
"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1:
|
||||
"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.6.0:
|
||||
version "5.7.1"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
|
||||
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
|
||||
@@ -6794,7 +7133,7 @@ shellwords@^0.1.1:
|
||||
resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b"
|
||||
integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==
|
||||
|
||||
side-channel@^1.0.2, side-channel@^1.0.3:
|
||||
side-channel@^1.0.2, side-channel@^1.0.3, side-channel@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
|
||||
integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==
|
||||
@@ -6899,6 +7238,14 @@ source-map-resolve@^0.5.0:
|
||||
source-map-url "^0.4.0"
|
||||
urix "^0.1.0"
|
||||
|
||||
source-map-resolve@^0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.6.0.tgz#3d9df87e236b53f16d01e58150fc7711138e5ed2"
|
||||
integrity sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==
|
||||
dependencies:
|
||||
atob "^2.1.2"
|
||||
decode-uri-component "^0.2.0"
|
||||
|
||||
source-map-support@^0.5.6, source-map-support@~0.5.19:
|
||||
version "0.5.19"
|
||||
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
|
||||
@@ -7084,6 +7431,14 @@ string.prototype.trimend@^1.0.1:
|
||||
define-properties "^1.1.3"
|
||||
es-abstract "^1.18.0-next.1"
|
||||
|
||||
string.prototype.trimend@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz#e75ae90c2942c63504686c18b287b4a0b1a45f80"
|
||||
integrity sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==
|
||||
dependencies:
|
||||
call-bind "^1.0.2"
|
||||
define-properties "^1.1.3"
|
||||
|
||||
string.prototype.trimstart@^1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.2.tgz#22d45da81015309cd0cdd79787e8919fc5c613e7"
|
||||
@@ -7092,6 +7447,14 @@ string.prototype.trimstart@^1.0.1:
|
||||
define-properties "^1.1.3"
|
||||
es-abstract "^1.18.0-next.1"
|
||||
|
||||
string.prototype.trimstart@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz#b36399af4ab2999b4c9c648bd7a3fb2bb26feeed"
|
||||
integrity sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==
|
||||
dependencies:
|
||||
call-bind "^1.0.2"
|
||||
define-properties "^1.1.3"
|
||||
|
||||
string_decoder@^1.1.1:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
|
||||
@@ -7142,6 +7505,13 @@ strip-final-newline@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
|
||||
integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
|
||||
|
||||
strip-indent@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001"
|
||||
integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==
|
||||
dependencies:
|
||||
min-indent "^1.0.0"
|
||||
|
||||
strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
|
||||
@@ -7278,9 +7648,9 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.3:
|
||||
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
|
||||
|
||||
tmpl@1.0.x:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"
|
||||
integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"
|
||||
integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==
|
||||
|
||||
to-fast-properties@^2.0.0:
|
||||
version "2.0.0"
|
||||
@@ -7419,6 +7789,16 @@ ua-parser-js@^0.7.18:
|
||||
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31"
|
||||
integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g==
|
||||
|
||||
unbox-primitive@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"
|
||||
integrity sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==
|
||||
dependencies:
|
||||
function-bind "^1.1.1"
|
||||
has-bigints "^1.0.1"
|
||||
has-symbols "^1.0.2"
|
||||
which-boxed-primitive "^1.0.2"
|
||||
|
||||
uncontrollable@^7.0.0:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/uncontrollable/-/uncontrollable-7.1.1.tgz#f67fed3ef93637126571809746323a9db815d556"
|
||||
@@ -7498,9 +7878,9 @@ urix@^0.1.0:
|
||||
integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
|
||||
|
||||
url-parse@^1.4.3:
|
||||
version "1.5.3"
|
||||
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.3.tgz#71c1303d38fb6639ade183c2992c8cc0686df862"
|
||||
integrity sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==
|
||||
version "1.5.10"
|
||||
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1"
|
||||
integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==
|
||||
dependencies:
|
||||
querystringify "^2.1.1"
|
||||
requires-port "^1.0.0"
|
||||
@@ -7792,6 +8172,17 @@ whatwg-url@^8.0.0:
|
||||
tr46 "^2.0.2"
|
||||
webidl-conversions "^6.1.0"
|
||||
|
||||
which-boxed-primitive@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"
|
||||
integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==
|
||||
dependencies:
|
||||
is-bigint "^1.0.1"
|
||||
is-boolean-object "^1.1.0"
|
||||
is-number-object "^1.0.4"
|
||||
is-string "^1.0.5"
|
||||
is-symbol "^1.0.3"
|
||||
|
||||
which-module@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
|
||||
|
@@ -1,14 +1,8 @@
|
||||
"""JupyterHub version info"""
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
version_info = (
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
"b1", # release (b1, rc1, or "" for final or dev)
|
||||
# "dev", # dev or nothing for beta/rc/stable releases
|
||||
)
|
||||
# version_info updated by running `tbump`
|
||||
version_info = (2, 2, 0, "", "")
|
||||
|
||||
# pep 440 version: no dot before beta/rc, but before .dev
|
||||
# 0.1.0rc1
|
||||
@@ -16,7 +10,9 @@ version_info = (
|
||||
# 0.1.0b1.dev
|
||||
# 0.1.0.dev
|
||||
|
||||
__version__ = ".".join(map(str, version_info[:3])) + ".".join(version_info[3:])
|
||||
__version__ = ".".join(map(str, version_info[:3])) + ".".join(version_info[3:]).rstrip(
|
||||
"."
|
||||
)
|
||||
|
||||
# Singleton flag to only log the major/minor mismatch warning once per mismatch combo.
|
||||
_version_mismatch_warning_logged = {}
|
||||
|
@@ -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)
|
||||
@@ -308,12 +312,14 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
||||
"filter": "",
|
||||
}
|
||||
]
|
||||
elif 'all' in raw_scopes:
|
||||
raw_scopes = ['all']
|
||||
elif 'inherit' in raw_scopes:
|
||||
raw_scopes = ['inherit']
|
||||
scope_descriptions = [
|
||||
{
|
||||
"scope": "all",
|
||||
"description": scopes.scope_definitions['all']['description'],
|
||||
"scope": "inherit",
|
||||
"description": scopes.scope_definitions['inherit'][
|
||||
'description'
|
||||
],
|
||||
"filter": "",
|
||||
}
|
||||
]
|
||||
|
@@ -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
|
||||
|
||||
@@ -31,6 +32,9 @@ class APIHandler(BaseHandler):
|
||||
- methods for REST API models
|
||||
"""
|
||||
|
||||
# accept token-based authentication for API requests
|
||||
_accept_token_auth = True
|
||||
|
||||
@property
|
||||
def content_security_policy(self):
|
||||
return '; '.join([super().content_security_policy, "default-src 'none'"])
|
||||
@@ -55,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.
|
||||
@@ -67,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
|
||||
@@ -210,6 +229,7 @@ class APIHandler(BaseHandler):
|
||||
'last_activity': isoformat(token.last_activity),
|
||||
'expires_at': isoformat(token.expires_at),
|
||||
'note': token.note,
|
||||
'session_id': token.session_id,
|
||||
'oauth_client': token.oauth_client.description
|
||||
or token.oauth_client.identifier,
|
||||
}
|
||||
|
@@ -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')
|
||||
@@ -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 = {}
|
||||
@@ -129,9 +138,10 @@ class GroupAPIHandler(_GroupAPIHandler):
|
||||
self.write(json.dumps(self.group_model(group)))
|
||||
self.set_status(201)
|
||||
|
||||
@needs_scope('admin:groups')
|
||||
@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)
|
||||
|
@@ -58,6 +58,14 @@ class SelfAPIHandler(APIHandler):
|
||||
|
||||
model = get_model(user)
|
||||
|
||||
# add session_id associated with token
|
||||
# added in 2.0
|
||||
token = self.get_token()
|
||||
if token:
|
||||
model["session_id"] = token.session_id
|
||||
else:
|
||||
model["session_id"] = None
|
||||
|
||||
# add scopes to identify model,
|
||||
# but not the scopes we added to ensure we could read our own model
|
||||
model["scopes"] = sorted(self.expanded_scopes.difference(_added_scopes))
|
||||
@@ -266,7 +274,7 @@ class UserAPIHandler(APIHandler):
|
||||
self.write(json.dumps(self.user_model(user)))
|
||||
self.set_status(201)
|
||||
|
||||
@needs_scope('admin:users')
|
||||
@needs_scope('delete:users')
|
||||
async def delete(self, user_name):
|
||||
user = self.find_user(user_name)
|
||||
if user is None:
|
||||
@@ -397,9 +405,11 @@ class UserTokenListAPIHandler(APIHandler):
|
||||
token_roles = body.get('roles')
|
||||
try:
|
||||
api_token = user.new_api_token(
|
||||
note=note, expires_in=body.get('expires_in', None), roles=token_roles
|
||||
note=note,
|
||||
expires_in=body.get('expires_in', None),
|
||||
roles=token_roles,
|
||||
)
|
||||
except NameError:
|
||||
except KeyError:
|
||||
raise web.HTTPError(404, "Requested roles %r not found" % token_roles)
|
||||
except ValueError:
|
||||
raise web.HTTPError(
|
||||
@@ -421,6 +431,7 @@ class UserTokenListAPIHandler(APIHandler):
|
||||
token_model = self.token_model(orm.APIToken.find(self.db, api_token))
|
||||
token_model['token'] = api_token
|
||||
self.write(json.dumps(token_model))
|
||||
self.set_status(201)
|
||||
|
||||
|
||||
class UserTokenAPIHandler(APIHandler):
|
||||
@@ -483,6 +494,11 @@ class UserServerAPIHandler(APIHandler):
|
||||
@needs_scope('servers')
|
||||
async def post(self, user_name, server_name=''):
|
||||
user = self.find_user(user_name)
|
||||
if user is None:
|
||||
# this can be reached if a token has `servers`
|
||||
# permission on *all* users
|
||||
raise web.HTTPError(404)
|
||||
|
||||
if server_name:
|
||||
if not self.allow_named_servers:
|
||||
raise web.HTTPError(400, "Named servers are not enabled.")
|
||||
@@ -499,7 +515,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')
|
||||
@@ -525,7 +541,7 @@ class UserServerAPIHandler(APIHandler):
|
||||
self.set_header('Content-Type', 'text/plain')
|
||||
self.set_status(status)
|
||||
|
||||
@needs_scope('servers')
|
||||
@needs_scope('delete:servers')
|
||||
async def delete(self, user_name, server_name=''):
|
||||
user = self.find_user(user_name)
|
||||
options = self.get_json_body()
|
||||
@@ -698,7 +714,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:
|
||||
@@ -731,7 +752,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
|
||||
|
@@ -90,6 +90,7 @@ from .log import CoroutineLogFormatter, log_request
|
||||
from .proxy import Proxy, ConfigurableHTTPProxy
|
||||
from .traitlets import URLPrefix, Command, EntryPointType, Callable
|
||||
from .utils import (
|
||||
AnyTimeoutError,
|
||||
catch_db_error,
|
||||
maybe_future,
|
||||
url_path_join,
|
||||
@@ -790,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.
|
||||
@@ -1518,6 +1529,25 @@ class JupyterHub(Application):
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
use_legacy_stopped_server_status_code = Bool(
|
||||
False,
|
||||
help="""
|
||||
Return 503 rather than 424 when request comes in for a non-running server.
|
||||
|
||||
Prior to JupyterHub 2.0, we returned a 503 when any request came in for
|
||||
a user server that was currently not running. By default, JupyterHub 2.0
|
||||
will return a 424 - this makes operational metric dashboards more useful.
|
||||
|
||||
JupyterLab < 3.2 expected the 503 to know if the user server is no longer
|
||||
running, and prompted the user to start their server. Set this config to
|
||||
true to retain the old behavior, so JupyterLab < 3.2 can continue to show
|
||||
the appropriate UI when the user server is stopped.
|
||||
|
||||
This option will be removed in a future release.
|
||||
""",
|
||||
config=True,
|
||||
)
|
||||
|
||||
def init_handlers(self):
|
||||
h = []
|
||||
# load handlers from the authenticator
|
||||
@@ -1873,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:
|
||||
@@ -1963,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:
|
||||
@@ -1984,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(
|
||||
@@ -2002,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)
|
||||
@@ -2017,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
|
||||
)
|
||||
@@ -2025,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:
|
||||
@@ -2048,66 +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."
|
||||
"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)
|
||||
if orm_obj:
|
||||
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=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)
|
||||
)
|
||||
@@ -2124,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)
|
||||
@@ -2331,7 +2408,7 @@ class JupyterHub(Application):
|
||||
continue
|
||||
try:
|
||||
await Server.from_orm(service.orm.server).wait_up(timeout=1, http=True)
|
||||
except TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
self.log.warning(
|
||||
"Cannot connect to %s service %s at %s",
|
||||
service.kind,
|
||||
@@ -2409,7 +2486,7 @@ class JupyterHub(Application):
|
||||
)
|
||||
try:
|
||||
await user._wait_up(spawner)
|
||||
except TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
self.log.error(
|
||||
"%s does not appear to be running at %s, shutting it down.",
|
||||
spawner._log_name,
|
||||
@@ -2773,7 +2850,7 @@ class JupyterHub(Application):
|
||||
await gen.with_timeout(
|
||||
timedelta(seconds=max(init_spawners_timeout, 1)), init_spawners_future
|
||||
)
|
||||
except gen.TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
self.log.warning(
|
||||
"init_spawners did not complete within %i seconds. "
|
||||
"Allowing to complete in the background.",
|
||||
@@ -3036,7 +3113,7 @@ class JupyterHub(Application):
|
||||
await Server.from_orm(service.orm.server).wait_up(
|
||||
http=True, timeout=1, ssl_context=ssl_context
|
||||
)
|
||||
except TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
if service.managed:
|
||||
status = await service.spawner.poll()
|
||||
if status is not None:
|
||||
@@ -3073,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)
|
||||
|
@@ -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)
|
||||
|
||||
@@ -1173,3 +1198,22 @@ class DummyAuthenticator(Authenticator):
|
||||
return data['username']
|
||||
return None
|
||||
return data['username']
|
||||
|
||||
|
||||
class NullAuthenticator(Authenticator):
|
||||
"""Null Authenticator for JupyterHub
|
||||
|
||||
For cases where authentication should be disabled,
|
||||
e.g. only allowing access via API tokens.
|
||||
|
||||
.. versionadded:: 2.0
|
||||
"""
|
||||
|
||||
# auto_login skips 'Login with...' page on Hub 0.8
|
||||
auto_login = True
|
||||
|
||||
# for Hub 0.7, show 'login with...'
|
||||
login_service = 'null'
|
||||
|
||||
def get_handlers(self, app):
|
||||
return []
|
||||
|
@@ -45,9 +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
|
||||
|
||||
@@ -70,6 +73,12 @@ SESSION_COOKIE_NAME = 'jupyterhub-session-id'
|
||||
class BaseHandler(RequestHandler):
|
||||
"""Base Handler class with access to common methods and properties."""
|
||||
|
||||
# by default, only accept cookie-based authentication
|
||||
# The APIHandler base class enables token auth
|
||||
# versionadded: 2.0
|
||||
_accept_cookie_auth = True
|
||||
_accept_token_auth = False
|
||||
|
||||
async def prepare(self):
|
||||
"""Identify the user during the prepare stage of each request
|
||||
|
||||
@@ -339,6 +348,7 @@ class BaseHandler(RequestHandler):
|
||||
auth_info['auth_state'] = await user.get_auth_state()
|
||||
return await self.auth_to_user(auth_info, user)
|
||||
|
||||
@functools.lru_cache()
|
||||
def get_token(self):
|
||||
"""get token from authorization header"""
|
||||
token = self.get_auth_token()
|
||||
@@ -409,9 +419,11 @@ class BaseHandler(RequestHandler):
|
||||
async def get_current_user(self):
|
||||
"""get current username"""
|
||||
if not hasattr(self, '_jupyterhub_user'):
|
||||
user = None
|
||||
try:
|
||||
user = self.get_current_user_token()
|
||||
if user is None:
|
||||
if self._accept_token_auth:
|
||||
user = self.get_current_user_token()
|
||||
if user is None and self._accept_cookie_auth:
|
||||
user = self.get_current_user_cookie()
|
||||
if user and isinstance(user, User):
|
||||
user = await self.refresh_auth(user)
|
||||
@@ -490,7 +502,7 @@ class BaseHandler(RequestHandler):
|
||||
session_id = self.get_session_cookie()
|
||||
if session_id:
|
||||
# clear session id
|
||||
self.clear_cookie(SESSION_COOKIE_NAME, **kwargs)
|
||||
self.clear_cookie(SESSION_COOKIE_NAME, path=self.base_url, **kwargs)
|
||||
|
||||
if user:
|
||||
# user is logged in, clear any tokens associated with the current session
|
||||
@@ -569,7 +581,9 @@ class BaseHandler(RequestHandler):
|
||||
so other services on this domain can read it.
|
||||
"""
|
||||
session_id = uuid.uuid4().hex
|
||||
self._set_cookie(SESSION_COOKIE_NAME, session_id, encrypted=False)
|
||||
self._set_cookie(
|
||||
SESSION_COOKIE_NAME, session_id, encrypted=False, path=self.base_url
|
||||
)
|
||||
return session_id
|
||||
|
||||
def set_service_cookie(self, user):
|
||||
@@ -620,12 +634,10 @@ 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((f'{proto}://{host}/', f'//{host}/',)) or (
|
||||
self.subdomain_host
|
||||
and urlparse(next_url).netloc
|
||||
and ("." + urlparse(next_url).netloc).endswith(
|
||||
@@ -759,15 +771,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):
|
||||
@@ -781,6 +803,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
|
||||
@@ -1019,7 +1042,7 @@ class BaseHandler(RequestHandler):
|
||||
await gen.with_timeout(
|
||||
timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future
|
||||
)
|
||||
except gen.TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
# waiting_for_response indicates server process has started,
|
||||
# but is yet to become responsive.
|
||||
if spawner._spawn_pending and not spawner._waiting_for_response:
|
||||
@@ -1166,7 +1189,7 @@ class BaseHandler(RequestHandler):
|
||||
|
||||
try:
|
||||
await gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), future)
|
||||
except gen.TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
# hit timeout, but stop is still pending
|
||||
self.log.warning(
|
||||
"User %s:%s server is slow to stop (timeout=%s)",
|
||||
@@ -1355,7 +1378,7 @@ class UserUrlHandler(BaseHandler):
|
||||
|
||||
**Changed Behavior as of 1.0** This handler no longer triggers a spawn. Instead, it checks if:
|
||||
|
||||
1. server is not active, serve page prompting for spawn (status: 503)
|
||||
1. server is not active, serve page prompting for spawn (status: 424)
|
||||
2. server is ready (This shouldn't happen! Proxy isn't updated yet. Wait a bit and redirect.)
|
||||
3. server is active, redirect to /hub/spawn-pending to monitor launch progress
|
||||
(will redirect back when finished)
|
||||
@@ -1369,12 +1392,22 @@ 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(
|
||||
"Failing suspected API request to not-running server: %s", self.request.path
|
||||
)
|
||||
self.set_status(503)
|
||||
|
||||
# If we got here, the server is not running. To differentiate
|
||||
# that the *server* itself is not running, rather than just the particular
|
||||
# resource *in* the server is not found, we return a 424 instead of a 404.
|
||||
# We allow retaining the old behavior to support older JupyterLab versions
|
||||
self.set_status(
|
||||
424 if not self.app.use_legacy_stopped_server_status_code else 503
|
||||
)
|
||||
self.set_header("Content-Type", "application/json")
|
||||
|
||||
spawn_url = urlparse(self.request.full_url())._replace(query="")
|
||||
@@ -1426,54 +1459,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.
|
||||
@@ -1517,14 +1520,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
|
||||
|
||||
@@ -1539,15 +1538,17 @@ class UserUrlHandler(BaseHandler):
|
||||
self.redirect(pending_url, status=303)
|
||||
return
|
||||
|
||||
# if we got here, the server is not running
|
||||
# serve a page prompting for spawn and 503 error
|
||||
# visiting /user/:name no longer triggers implicit spawn
|
||||
# without explicit user action
|
||||
# If we got here, the server is not running. To differentiate
|
||||
# that the *server* itself is not running, rather than just the particular
|
||||
# page *in* the server is not found, we return a 424 instead of a 404.
|
||||
# We allow retaining the old behavior to support older JupyterLab versions
|
||||
spawn_url = url_concat(
|
||||
url_path_join(self.hub.base_url, "spawn", user.escaped_name, server_name),
|
||||
{"next": self.request.uri},
|
||||
)
|
||||
self.set_status(503)
|
||||
self.set_status(
|
||||
424 if not self.app.use_legacy_stopped_server_status_code else 503
|
||||
)
|
||||
|
||||
auth_state = await user.get_auth_state()
|
||||
html = await self.render_template(
|
||||
@@ -1604,7 +1605,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)
|
||||
|
||||
|
@@ -44,6 +44,7 @@ from . import utils
|
||||
from .metrics import CHECK_ROUTES_DURATION_SECONDS
|
||||
from .metrics import PROXY_POLL_DURATION_SECONDS
|
||||
from .objects import Server
|
||||
from .utils import AnyTimeoutError
|
||||
from .utils import exponential_backoff
|
||||
from .utils import url_path_join
|
||||
from jupyterhub.traitlets import Command
|
||||
@@ -718,7 +719,7 @@ class ConfigurableHTTPProxy(Proxy):
|
||||
_check_process()
|
||||
try:
|
||||
await server.wait_up(1)
|
||||
except TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
@@ -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',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -57,7 +59,7 @@ def get_default_roles():
|
||||
{
|
||||
'name': 'token',
|
||||
'description': 'Token with same permissions as its owner',
|
||||
'scopes': ['all'],
|
||||
'scopes': ['inherit'],
|
||||
},
|
||||
]
|
||||
return default_roles
|
||||
@@ -89,6 +91,7 @@ def expand_self_scope(name):
|
||||
'users:activity',
|
||||
'read:users:activity',
|
||||
'servers',
|
||||
'delete:servers',
|
||||
'read:servers',
|
||||
'tokens',
|
||||
'read:tokens',
|
||||
@@ -213,7 +216,7 @@ def _check_scopes(*args, rolename=None):
|
||||
or
|
||||
scopes (list): list of scopes to check
|
||||
|
||||
Raises NameError if scope does not exist
|
||||
Raises KeyError if scope does not exist
|
||||
"""
|
||||
|
||||
allowed_scopes = set(scopes.scope_definitions.keys())
|
||||
@@ -227,35 +230,17 @@ def _check_scopes(*args, rolename=None):
|
||||
for scope in args:
|
||||
scopename, _, filter_ = scope.partition('!')
|
||||
if scopename not in allowed_scopes:
|
||||
raise NameError(f"Scope '{scope}' {log_role} does not exist")
|
||||
if scopename == "all":
|
||||
raise KeyError("Draft scope 'all' is now called 'inherit'")
|
||||
raise KeyError(f"Scope '{scope}' {log_role} does not exist")
|
||||
if filter_:
|
||||
full_filter = f"!{filter_}"
|
||||
if not any(f in scope for f in allowed_filters):
|
||||
raise NameError(
|
||||
raise KeyError(
|
||||
f"Scope filter '{full_filter}' in scope '{scope}' {log_role} does not exist"
|
||||
)
|
||||
|
||||
|
||||
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]$')
|
||||
|
||||
|
||||
@@ -290,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'])
|
||||
@@ -303,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()
|
||||
|
||||
|
||||
@@ -321,81 +331,64 @@ def delete_role(db, rolename):
|
||||
db.commit()
|
||||
app_log.info('Role %s has been deleted', rolename)
|
||||
else:
|
||||
raise NameError('Cannot remove role %r that does not exist', 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
|
||||
@@ -410,58 +403,73 @@ 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 = {'all', 'read:all'}
|
||||
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
|
||||
else:
|
||||
app_log.warning(
|
||||
f"Token requesting scopes exceeding owner {owner.name}: {disallowed_scopes}"
|
||||
f"Token requesting role {role.name} with scopes not held by owner {owner.name}: {disallowed_scopes}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def assign_default_roles(db, entity):
|
||||
"""Assigns default role to an entity:
|
||||
users and services get 'user' role, or admin role if they have admin flag
|
||||
"""Assigns default role(s) to an entity:
|
||||
|
||||
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):
|
||||
app_log.debug('Assigning default roles to tokens')
|
||||
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
|
||||
db.commit()
|
||||
# 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 roles to {kind} {entity.name}')
|
||||
_switch_default_role(db, entity, entity.admin)
|
||||
app_log.debug(f'Assigning default role to {kind} {entity.name}')
|
||||
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):
|
||||
"""Updates object's roles checking for requested permissions
|
||||
if object is orm.APIToken
|
||||
"""Add roles to an entity (token, user, etc.)
|
||||
|
||||
If it is an API token, check role permissions against token owner
|
||||
prior to assignment to avoid permission expansion.
|
||||
|
||||
Otherwise, it just calls `grant_role` for each role.
|
||||
"""
|
||||
standard_permissions = {'all', 'read:all'}
|
||||
for rolename in roles:
|
||||
if isinstance(entity, orm.APIToken):
|
||||
role = orm.Role.find(db, rolename)
|
||||
@@ -474,12 +482,11 @@ def update_roles(db, entity, roles):
|
||||
app_log.info('Adding role %s to token: %s', role.name, entity)
|
||||
else:
|
||||
raise ValueError(
|
||||
f'Requested token role {rolename} of {entity} has more permissions than the token owner'
|
||||
f'Requested token role {rolename} for {entity} has more permissions than the token owner'
|
||||
)
|
||||
else:
|
||||
raise NameError('Role %r does not exist' % rolename)
|
||||
raise KeyError(f'Role {rolename} does not exist')
|
||||
else:
|
||||
app_log.debug('Assigning default roles to %s', type(entity).__name__)
|
||||
grant_role(db, entity=entity, rolename=rolename)
|
||||
|
||||
|
||||
|
@@ -30,19 +30,22 @@ scope_definitions = {
|
||||
'description': 'Your own resources',
|
||||
'doc_description': 'The user’s own resources _(metascope for users, resolves to (no_scope) for services)_',
|
||||
},
|
||||
'all': {
|
||||
'inherit': {
|
||||
'description': 'Anything you have access to',
|
||||
'doc_description': 'Everything that the token-owning entity can access _(metascope for tokens)_',
|
||||
},
|
||||
'admin:users': {
|
||||
'description': 'Read, write, create and delete users and their authentication state, not including their servers or tokens.',
|
||||
'subscopes': ['admin:auth_state', 'users', 'read:roles:users'],
|
||||
'subscopes': ['admin:auth_state', 'users', 'read:roles:users', 'delete:users'],
|
||||
},
|
||||
'admin:auth_state': {'description': 'Read a user’s authentication state.'},
|
||||
'users': {
|
||||
'description': 'Read and write permissions to user models (excluding servers, tokens and authentication state).',
|
||||
'subscopes': ['read:users', 'list:users', 'users:activity'],
|
||||
},
|
||||
'delete:users': {
|
||||
'description': "Delete users.",
|
||||
},
|
||||
'list:users': {
|
||||
'description': 'List users, including at least their names.',
|
||||
'subscopes': ['read:users:name'],
|
||||
@@ -76,12 +79,13 @@ scope_definitions = {
|
||||
'admin:server_state': {'description': 'Read and write users’ server state.'},
|
||||
'servers': {
|
||||
'description': 'Start and stop user servers.',
|
||||
'subscopes': ['read:servers'],
|
||||
'subscopes': ['read:servers', 'delete:servers'],
|
||||
},
|
||||
'read:servers': {
|
||||
'description': 'Read users’ names and their server models (excluding the server state).',
|
||||
'subscopes': ['read:users:name'],
|
||||
},
|
||||
'delete:servers': {'description': "Stop and delete users' servers."},
|
||||
'tokens': {
|
||||
'description': 'Read, write, create and delete user tokens.',
|
||||
'subscopes': ['read:tokens'],
|
||||
@@ -89,7 +93,7 @@ scope_definitions = {
|
||||
'read:tokens': {'description': 'Read user tokens.'},
|
||||
'admin:groups': {
|
||||
'description': 'Read and write group information, create and delete groups.',
|
||||
'subscopes': ['groups', 'read:roles:groups'],
|
||||
'subscopes': ['groups', 'read:roles:groups', 'delete:groups'],
|
||||
},
|
||||
'groups': {
|
||||
'description': 'Read and write group information, including adding/removing users to/from groups.',
|
||||
@@ -104,6 +108,9 @@ scope_definitions = {
|
||||
'subscopes': ['read:groups:name'],
|
||||
},
|
||||
'read:groups:name': {'description': 'Read group names.'},
|
||||
'delete:groups': {
|
||||
'description': "Delete groups.",
|
||||
},
|
||||
'list:services': {
|
||||
'description': 'List services, including at least their names.',
|
||||
'subscopes': ['read:services:name'],
|
||||
@@ -124,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.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -288,7 +298,7 @@ def get_scopes_for(orm_object):
|
||||
)
|
||||
|
||||
if isinstance(orm_object, orm.APIToken):
|
||||
app_log.warning(f"Authenticated with token {orm_object}")
|
||||
app_log.debug(f"Authenticated with token {orm_object}")
|
||||
owner = orm_object.user or orm_object.service
|
||||
token_scopes = roles.expand_roles_to_scopes(orm_object)
|
||||
if orm_object.client_id != "jupyterhub":
|
||||
@@ -310,13 +320,13 @@ def get_scopes_for(orm_object):
|
||||
|
||||
owner_scopes = roles.expand_roles_to_scopes(owner)
|
||||
|
||||
if token_scopes == {'all'}:
|
||||
# token_scopes is only 'all', return owner scopes as-is
|
||||
if token_scopes == {'inherit'}:
|
||||
# token_scopes is only 'inherit', return scopes inherited from owner as-is
|
||||
# short-circuit common case where we don't need to compute an intersection
|
||||
return owner_scopes
|
||||
|
||||
if 'all' in token_scopes:
|
||||
token_scopes.remove('all')
|
||||
if 'inherit' in token_scopes:
|
||||
token_scopes.remove('inherit')
|
||||
token_scopes |= owner_scopes
|
||||
|
||||
intersection = _intersect_expanded_scopes(
|
||||
|
@@ -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)
|
||||
@@ -1023,8 +1067,8 @@ class HubAuthenticated:
|
||||
self._hub_auth_user_cache = None
|
||||
raise
|
||||
|
||||
# store tokens passed via url or header in a cookie for future requests
|
||||
url_token = self.hub_auth.get_token(self)
|
||||
# store ?token=... tokens passed via url in a cookie for future requests
|
||||
url_token = self.get_argument('token', '')
|
||||
if (
|
||||
user_model
|
||||
and url_token
|
||||
|
@@ -1,7 +1,12 @@
|
||||
"""Make a single-user app based on the environment:
|
||||
|
||||
- $JUPYTERHUB_SINGLEUSER_APP, the base Application class, to be wrapped in JupyterHub authentication.
|
||||
default: notebook.notebookapp.NotebookApp
|
||||
default: jupyter_server.serverapp.ServerApp
|
||||
|
||||
.. versionchanged:: 2.0
|
||||
|
||||
Default app changed to launch `jupyter labhub`.
|
||||
Use JUPYTERHUB_SINGLEUSER_APP=notebook.notebookapp.NotebookApp for the legacy 'classic' notebook server.
|
||||
"""
|
||||
import os
|
||||
|
||||
@@ -9,12 +14,55 @@ from traitlets import import_item
|
||||
|
||||
from .mixins import make_singleuser_app
|
||||
|
||||
JUPYTERHUB_SINGLEUSER_APP = (
|
||||
os.environ.get("JUPYTERHUB_SINGLEUSER_APP") or "notebook.notebookapp.NotebookApp"
|
||||
)
|
||||
JUPYTERHUB_SINGLEUSER_APP = os.environ.get("JUPYTERHUB_SINGLEUSER_APP")
|
||||
|
||||
|
||||
if JUPYTERHUB_SINGLEUSER_APP:
|
||||
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
||||
else:
|
||||
App = None
|
||||
_import_error = None
|
||||
for JUPYTERHUB_SINGLEUSER_APP in (
|
||||
"jupyter_server.serverapp.ServerApp",
|
||||
"notebook.notebookapp.NotebookApp",
|
||||
):
|
||||
try:
|
||||
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
||||
except ImportError as e:
|
||||
continue
|
||||
if _import_error is None:
|
||||
_import_error = e
|
||||
else:
|
||||
break
|
||||
if App is None:
|
||||
raise _import_error
|
||||
|
||||
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
||||
|
||||
SingleUserNotebookApp = make_singleuser_app(App)
|
||||
|
||||
main = SingleUserNotebookApp.launch_instance
|
||||
|
||||
def main():
|
||||
"""Launch a jupyterhub single-user server"""
|
||||
if not os.environ.get("JUPYTERHUB_SINGLEUSER_APP"):
|
||||
# app not specified, launch jupyter-labhub by default,
|
||||
# if jupyterlab is recent enough (3.1).
|
||||
# This is a minimally extended ServerApp that does:
|
||||
# 1. ensure lab extension is enabled, and
|
||||
# 2. set default URL to `/lab`
|
||||
import re
|
||||
|
||||
_version_pat = re.compile(r"(\d+)\.(\d+)")
|
||||
try:
|
||||
import jupyterlab
|
||||
from jupyterlab.labhubapp import SingleUserLabApp
|
||||
|
||||
m = _version_pat.match(jupyterlab.__version__)
|
||||
except Exception:
|
||||
m = None
|
||||
|
||||
if m is not None:
|
||||
version_tuple = tuple(int(v) for v in m.groups())
|
||||
if version_tuple >= (3, 1):
|
||||
return SingleUserLabApp.launch_instance()
|
||||
|
||||
return SingleUserNotebookApp.launch_instance()
|
||||
|
@@ -16,8 +16,8 @@ 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
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -492,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:
|
||||
@@ -606,10 +606,34 @@ class SingleUserNotebookAppMixin(Configurable):
|
||||
t = self.hub_activity_interval * (1 + 0.2 * (random.random() - 0.5))
|
||||
await asyncio.sleep(t)
|
||||
|
||||
def _log_app_versions(self):
|
||||
"""Log application versions at startup
|
||||
|
||||
Logs versions of jupyterhub and singleuser-server base versions (jupyterlab, jupyter_server, notebook)
|
||||
"""
|
||||
self.log.info(f"Starting jupyterhub single-user server version {__version__}")
|
||||
|
||||
# don't log these package versions
|
||||
seen = {"jupyterhub", "traitlets", "jupyter_core", "builtins"}
|
||||
|
||||
for cls in self.__class__.mro():
|
||||
module_name = cls.__module__.partition(".")[0]
|
||||
if module_name not in seen:
|
||||
seen.add(module_name)
|
||||
try:
|
||||
mod = import_module(module_name)
|
||||
mod_version = getattr(mod, "__version__")
|
||||
except Exception:
|
||||
mod_version = ""
|
||||
self.log.info(
|
||||
f"Extending {cls.__module__}.{cls.__name__} from {module_name} {mod_version}"
|
||||
)
|
||||
|
||||
def initialize(self, argv=None):
|
||||
# disable trash by default
|
||||
# this can be re-enabled by config
|
||||
self.config.FileContentsManager.delete_to_trash = False
|
||||
self._log_app_versions()
|
||||
return super().initialize(argv)
|
||||
|
||||
def start(self):
|
||||
@@ -655,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'
|
||||
)
|
||||
@@ -682,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
|
||||
@@ -715,6 +752,18 @@ class SingleUserNotebookAppMixin(Configurable):
|
||||
orig_loader = env.loader
|
||||
env.loader = ChoiceLoader([FunctionLoader(get_page), orig_loader])
|
||||
|
||||
def load_server_extensions(self):
|
||||
# Loading LabApp sets $JUPYTERHUB_API_TOKEN on load, which is incorrect
|
||||
r = super().load_server_extensions()
|
||||
# clear the token in PageConfig at this step
|
||||
# so that cookie auth is used
|
||||
# FIXME: in the future,
|
||||
# it would probably make sense to set page_config.token to the token
|
||||
# from the current request.
|
||||
if 'page_config_data' in self.web_app.settings:
|
||||
self.web_app.settings['page_config_data']['token'] = ''
|
||||
return r
|
||||
|
||||
|
||||
def detect_base_package(App):
|
||||
"""Detect the base package for an App class
|
||||
|
@@ -11,12 +11,11 @@ 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
|
||||
|
||||
if os.name == 'nt':
|
||||
import psutil
|
||||
from async_generator import aclosing
|
||||
from sqlalchemy import inspect
|
||||
from tornado.ioloop import PeriodicCallback
|
||||
@@ -38,12 +37,14 @@ from .objects import Server
|
||||
from .traitlets import ByteSpecification
|
||||
from .traitlets import Callable
|
||||
from .traitlets import Command
|
||||
from .utils import AnyTimeoutError
|
||||
from .utils import exponential_backoff
|
||||
from .utils import maybe_future
|
||||
from .utils import random_port
|
||||
from .utils import url_path_join
|
||||
|
||||
# FIXME: remove when we drop Python 3.5 support
|
||||
if os.name == 'nt':
|
||||
import psutil
|
||||
|
||||
|
||||
def _quote_safe(s):
|
||||
@@ -183,17 +184,39 @@ 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'):
|
||||
return self._server
|
||||
if self.orm_spawner and self.orm_spawner.server:
|
||||
return Server(orm_server=self.orm_spawner.server)
|
||||
# always check that we're in sync with orm_spawner
|
||||
if not self.orm_spawner:
|
||||
# no ORM spawner, nothing to check
|
||||
self._server = None
|
||||
return None
|
||||
|
||||
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,6 +224,8 @@ 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
|
||||
|
||||
@property
|
||||
@@ -424,6 +449,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
|
||||
|
||||
@@ -1263,7 +1295,7 @@ class Spawner(LoggingConfigurable):
|
||||
timeout=timeout,
|
||||
)
|
||||
return r
|
||||
except TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
return False
|
||||
|
||||
|
||||
|
@@ -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),
|
||||
]
|
||||
@@ -597,7 +668,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 +708,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 +743,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 +1030,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 +1039,21 @@ 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')
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_slow_bad_spawn(app, no_patience, slow_bad_spawn):
|
||||
db = app.db
|
||||
@@ -1366,8 +1449,8 @@ async def test_get_new_token_deprecated(app, headers, status):
|
||||
@mark.parametrize(
|
||||
"headers, status, note, expires_in",
|
||||
[
|
||||
({}, 200, 'test note', None),
|
||||
({}, 200, '', 100),
|
||||
({}, 201, 'test note', None),
|
||||
({}, 201, '', 100),
|
||||
({'Authorization': 'token bad'}, 403, '', None),
|
||||
],
|
||||
)
|
||||
@@ -1386,7 +1469,7 @@ async def test_get_new_token(app, headers, status, note, expires_in):
|
||||
app, 'users/admin/tokens', method='post', headers=headers, data=body
|
||||
)
|
||||
assert r.status_code == status
|
||||
if status != 200:
|
||||
if status != 201:
|
||||
return
|
||||
# check the new-token reply
|
||||
reply = r.json()
|
||||
@@ -1424,10 +1507,10 @@ async def test_get_new_token(app, headers, status, note, expires_in):
|
||||
@mark.parametrize(
|
||||
"as_user, for_user, status",
|
||||
[
|
||||
('admin', 'other', 200),
|
||||
('admin', 'other', 201),
|
||||
('admin', 'missing', 403),
|
||||
('user', 'other', 403),
|
||||
('user', 'user', 200),
|
||||
('user', 'user', 201),
|
||||
],
|
||||
)
|
||||
async def test_token_for_user(app, as_user, for_user, status):
|
||||
@@ -1448,7 +1531,7 @@ async def test_token_for_user(app, as_user, for_user, status):
|
||||
)
|
||||
assert r.status_code == status
|
||||
reply = r.json()
|
||||
if status != 200:
|
||||
if status != 201:
|
||||
return
|
||||
assert 'token' in reply
|
||||
|
||||
@@ -1486,7 +1569,7 @@ async def test_token_authenticator_noauth(app):
|
||||
data=json.dumps(data) if data else None,
|
||||
noauth=True,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.status_code == 201
|
||||
reply = r.json()
|
||||
assert 'token' in reply
|
||||
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
||||
@@ -1509,7 +1592,7 @@ async def test_token_authenticator_dict_noauth(app):
|
||||
data=json.dumps(data) if data else None,
|
||||
noauth=True,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.status_code == 201
|
||||
reply = r.json()
|
||||
assert 'token' in reply
|
||||
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
||||
@@ -1730,6 +1813,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
|
||||
# -----------------
|
||||
|
@@ -247,6 +247,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
|
||||
|
@@ -3,15 +3,20 @@
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import logging
|
||||
from unittest import mock
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
from requests import HTTPError
|
||||
from traitlets import Any
|
||||
from traitlets.config import Config
|
||||
|
||||
from .mocking import MockPAMAuthenticator
|
||||
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
|
||||
from jupyterhub import orm
|
||||
@@ -515,3 +520,80 @@ def test_deprecated_methods_subclass():
|
||||
assert authenticator.check_whitelist("subclass-allowed")
|
||||
assert not authenticator.check_allowed("otheruser")
|
||||
assert not authenticator.check_whitelist("otheruser")
|
||||
|
||||
|
||||
async def test_nullauthenticator(app):
|
||||
with mock.patch.dict(
|
||||
app.tornado_settings, {"authenticator": auth.NullAuthenticator(parent=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,16 +1,13 @@
|
||||
"""Tests for jupyterhub internal_ssl connections"""
|
||||
import sys
|
||||
import time
|
||||
from subprocess import check_output
|
||||
from unittest import mock
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
from requests.exceptions import ConnectionError
|
||||
from requests.exceptions import SSLError
|
||||
from tornado import gen
|
||||
|
||||
import jupyterhub
|
||||
from ..utils import AnyTimeoutError
|
||||
from .test_api import add_user
|
||||
from .utils import async_requests
|
||||
|
||||
@@ -35,7 +32,7 @@ async def wait_for_spawner(spawner, timeout=10):
|
||||
assert status is None
|
||||
try:
|
||||
await wait()
|
||||
except TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
@@ -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 503
|
||||
assert r.status_code == 503
|
||||
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
|
||||
@@ -172,7 +185,7 @@ async def test_spawn_redirect(app):
|
||||
r = await get_page('user/' + name, app, hub=False, cookies=cookies)
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
|
||||
assert r.status_code == 503
|
||||
assert r.status_code == 424
|
||||
|
||||
|
||||
async def test_spawn_handler_access(app):
|
||||
@@ -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 == 503
|
||||
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 == 503
|
||||
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')}
|
||||
)
|
||||
|
||||
|
||||
@@ -578,6 +697,41 @@ async def test_login_page(app, url, params, redirected_url, form_action):
|
||||
assert action.endswith(form_action)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, token_in",
|
||||
[
|
||||
("/home", "url"),
|
||||
("/home", "header"),
|
||||
("/login", "url"),
|
||||
("/login", "header"),
|
||||
],
|
||||
)
|
||||
async def test_page_with_token(app, user, url, token_in):
|
||||
cookies = await app.login_user(user.name)
|
||||
token = user.new_api_token()
|
||||
if token_in == "url":
|
||||
url = url_concat(url, {"token": token})
|
||||
headers = None
|
||||
elif token_in == "header":
|
||||
headers = {
|
||||
"Authorization": f"token {token}",
|
||||
}
|
||||
|
||||
# request a page with ?token= in URL shouldn't be allowed
|
||||
r = await get_page(
|
||||
url,
|
||||
app,
|
||||
headers=headers,
|
||||
allow_redirects=False,
|
||||
)
|
||||
if "/hub/login" in r.url:
|
||||
assert r.status_code == 200
|
||||
else:
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"].partition("?")[0].endswith("/hub/login")
|
||||
assert not r.cookies
|
||||
|
||||
|
||||
async def test_login_fail(app):
|
||||
name = 'wash'
|
||||
base_url = public_url(app)
|
||||
@@ -1061,24 +1215,18 @@ async def test_token_page(app):
|
||||
async def test_server_not_running_api_request(app):
|
||||
cookies = await app.login_user("bees")
|
||||
r = await get_page("user/bees/api/status", app, hub=False, cookies=cookies)
|
||||
assert r.status_code == 503
|
||||
assert r.status_code == 424
|
||||
assert r.headers["content-type"] == "application/json"
|
||||
message = r.json()['message']
|
||||
assert ujoin(app.base_url, "hub/spawn/bees") in message
|
||||
assert " /user/bees" in message
|
||||
|
||||
|
||||
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_server_not_running_api_request_legacy_status(app):
|
||||
app.use_legacy_stopped_server_status_code = True
|
||||
cookies = await app.login_user("bees")
|
||||
r = await get_page("user/bees/api/status", app, hub=False, cookies=cookies)
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
async def test_health_check_request(app):
|
||||
@@ -1087,7 +1235,7 @@ async def test_health_check_request(app):
|
||||
|
||||
|
||||
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):
|
||||
|
@@ -28,7 +28,7 @@ def test_orm_roles(db):
|
||||
user_role = orm.Role(name='user', scopes=['self'])
|
||||
db.add(user_role)
|
||||
if not token_role:
|
||||
token_role = orm.Role(name='token', scopes=['all'])
|
||||
token_role = orm.Role(name='token', scopes=['inherit'])
|
||||
db.add(token_role)
|
||||
if not service_role:
|
||||
service_role = orm.Role(name='service', scopes=[])
|
||||
@@ -182,6 +182,7 @@ def test_orm_roles_delete_cascade(db):
|
||||
'admin:users',
|
||||
'admin:auth_state',
|
||||
'users',
|
||||
'delete:users',
|
||||
'list:users',
|
||||
'read:users',
|
||||
'users:activity',
|
||||
@@ -218,6 +219,7 @@ def test_orm_roles_delete_cascade(db):
|
||||
{
|
||||
'admin:groups',
|
||||
'groups',
|
||||
'delete:groups',
|
||||
'list:groups',
|
||||
'read:groups',
|
||||
'read:roles:groups',
|
||||
@@ -229,6 +231,7 @@ def test_orm_roles_delete_cascade(db):
|
||||
{
|
||||
'admin:groups',
|
||||
'groups',
|
||||
'delete:groups',
|
||||
'list:groups',
|
||||
'read:groups',
|
||||
'read:roles:groups',
|
||||
@@ -366,7 +369,7 @@ async def test_creating_roles(app, role, role_def, response_type, response):
|
||||
'info',
|
||||
app_log.info('Role user scopes attribute has been changed'),
|
||||
),
|
||||
('non-existing', 'test-role2', 'error', NameError),
|
||||
('non-existing', 'test-role2', 'error', KeyError),
|
||||
('default', 'user', 'error', ValueError),
|
||||
],
|
||||
)
|
||||
@@ -407,9 +410,9 @@ async def test_delete_roles(db, role_type, rolename, response_type, response):
|
||||
},
|
||||
'existing',
|
||||
),
|
||||
({'name': 'test-scopes-2', 'scopes': ['uses']}, NameError),
|
||||
({'name': 'test-scopes-3', 'scopes': ['users:activities']}, NameError),
|
||||
({'name': 'test-scopes-4', 'scopes': ['groups!goup=class-A']}, NameError),
|
||||
({'name': 'test-scopes-2', 'scopes': ['uses']}, KeyError),
|
||||
({'name': 'test-scopes-3', 'scopes': ['users:activities']}, KeyError),
|
||||
({'name': 'test-scopes-4', 'scopes': ['groups!goup=class-A']}, KeyError),
|
||||
],
|
||||
)
|
||||
async def test_scope_existence(tmpdir, request, role, response):
|
||||
@@ -428,7 +431,7 @@ async def test_scope_existence(tmpdir, request, role, response):
|
||||
assert added_role is not None
|
||||
assert added_role.scopes == role['scopes']
|
||||
|
||||
elif response == NameError:
|
||||
elif response == KeyError:
|
||||
with pytest.raises(response):
|
||||
roles.create_role(db, role)
|
||||
added_role = orm.Role.find(db, role['name'])
|
||||
@@ -440,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 = [
|
||||
{
|
||||
@@ -458,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()
|
||||
@@ -575,7 +586,7 @@ async def test_load_roles_groups(tmpdir, request):
|
||||
'name': 'head',
|
||||
'description': 'Whole user access',
|
||||
'scopes': ['users', 'admin:users'],
|
||||
'groups': ['group3'],
|
||||
'groups': ['group3', "group4"],
|
||||
},
|
||||
]
|
||||
kwargs = {'load_groups': groups_to_load, 'load_roles': roles_to_load}
|
||||
@@ -595,11 +606,13 @@ async def test_load_roles_groups(tmpdir, request):
|
||||
group1 = orm.Group.find(db, name='group1')
|
||||
group2 = orm.Group.find(db, name='group2')
|
||||
group3 = orm.Group.find(db, name='group3')
|
||||
group4 = orm.Group.find(db, name='group4')
|
||||
|
||||
# test group roles
|
||||
assert group1.roles == []
|
||||
assert group2 in assist_role.groups
|
||||
assert group3 in head_role.groups
|
||||
assert group4 in head_role.groups
|
||||
|
||||
# delete the test roles
|
||||
for role in roles_to_load:
|
||||
@@ -658,11 +671,15 @@ async def test_load_roles_user_tokens(tmpdir, request):
|
||||
"headers, rolename, scopes, status",
|
||||
[
|
||||
# no role requested - gets default 'token' role
|
||||
({}, None, None, 200),
|
||||
({}, None, None, 201),
|
||||
# role scopes within the user's default 'user' role
|
||||
({}, 'self-reader', ['read:users'], 200),
|
||||
({}, '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'], 200),
|
||||
({}, 'groups-reader', ['read:groups'], 201),
|
||||
# non-existing role request
|
||||
({}, 'non-existing', [], 404),
|
||||
# role scopes outside of both user's role and group's role scopes
|
||||
@@ -1178,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
|
||||
@@ -1193,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():
|
||||
@@ -1217,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():
|
||||
@@ -1327,3 +1406,20 @@ async def test_token_keep_roles_on_restart():
|
||||
for token in user.api_tokens:
|
||||
hub.db.delete(token)
|
||||
hub.db.commit()
|
||||
|
||||
|
||||
async def test_login_default_role(app, username):
|
||||
cookies = await app.login_user(username)
|
||||
user = app.users[username]
|
||||
# assert login new user gets 'user' role
|
||||
assert [role.name for role in user.roles] == ["user"]
|
||||
|
||||
# clear roles, keep user
|
||||
user.roles = []
|
||||
app.db.commit()
|
||||
|
||||
# login *again*; user exists,
|
||||
# login should always trigger "user" role assignment
|
||||
cookies = await app.login_user(username)
|
||||
user = app.users[username]
|
||||
assert [role.name for role in user.roles] == ["user"]
|
||||
|
@@ -477,7 +477,7 @@ async def test_metascope_all_expansion(app, create_user_with_scopes):
|
||||
user = create_user_with_scopes('self')
|
||||
user.new_api_token()
|
||||
token = user.api_tokens[0]
|
||||
# Check 'all' expansion
|
||||
# Check 'inherit' expansion
|
||||
token_scope_set = get_scopes_for(token)
|
||||
user_scope_set = get_scopes_for(user)
|
||||
assert user_scope_set == token_scope_set
|
||||
@@ -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")
|
||||
|
@@ -1,6 +1,10 @@
|
||||
"""Tests for jupyterhub.singleuser"""
|
||||
import os
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from subprocess import CalledProcessError
|
||||
from subprocess import check_output
|
||||
from unittest import mock
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
@@ -14,6 +18,12 @@ from .utils import async_requests
|
||||
from .utils import AsyncSession
|
||||
|
||||
|
||||
@contextmanager
|
||||
def nullcontext():
|
||||
"""Python 3.7+ contextlib.nullcontext, backport for 3.6"""
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_scopes, server_name, expect_success",
|
||||
[
|
||||
@@ -171,3 +181,47 @@ def test_version():
|
||||
[sys.executable, '-m', 'jupyterhub.singleuser', '--version']
|
||||
).decode('utf8', 'replace')
|
||||
assert jupyterhub.__version__ in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"JUPYTERHUB_SINGLEUSER_APP",
|
||||
[
|
||||
"",
|
||||
"notebook.notebookapp.NotebookApp",
|
||||
"jupyter_server.serverapp.ServerApp",
|
||||
],
|
||||
)
|
||||
def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
|
||||
try:
|
||||
import jupyter_server # noqa
|
||||
except ImportError:
|
||||
have_server = False
|
||||
expect_error = "jupyter_server" in JUPYTERHUB_SINGLEUSER_APP
|
||||
else:
|
||||
have_server = True
|
||||
expect_error = False
|
||||
|
||||
if expect_error:
|
||||
ctx = pytest.raises(CalledProcessError)
|
||||
else:
|
||||
ctx = nullcontext()
|
||||
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"JUPYTERHUB_SINGLEUSER_APP": JUPYTERHUB_SINGLEUSER_APP,
|
||||
},
|
||||
):
|
||||
with ctx:
|
||||
out = check_output(
|
||||
[sys.executable, '-m', 'jupyterhub.singleuser', '--help-all']
|
||||
).decode('utf8', 'replace')
|
||||
if expect_error:
|
||||
return
|
||||
# use help-all output to check inheritance
|
||||
if 'NotebookApp' in JUPYTERHUB_SINGLEUSER_APP or not have_server:
|
||||
assert '--NotebookApp.' in out
|
||||
assert '--ServerApp.' not in out
|
||||
else:
|
||||
assert '--ServerApp.' in out
|
||||
assert '--NotebookApp.' not in out
|
||||
|
@@ -21,6 +21,7 @@ from ..objects import Server
|
||||
from ..spawner import LocalProcessSpawner
|
||||
from ..spawner import Spawner
|
||||
from ..user import User
|
||||
from ..utils import AnyTimeoutError
|
||||
from ..utils import new_token
|
||||
from ..utils import url_path_join
|
||||
from .mocking import public_url
|
||||
@@ -80,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
|
||||
|
||||
@@ -95,7 +108,7 @@ async def wait_for_spawner(spawner, timeout=10):
|
||||
assert status is None
|
||||
try:
|
||||
await wait()
|
||||
except TimeoutError:
|
||||
except AnyTimeoutError:
|
||||
continue
|
||||
else:
|
||||
break
|
||||
@@ -427,7 +440,92 @@ 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
|
||||
|
@@ -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
|
||||
|
@@ -26,11 +26,41 @@ from .metrics import RUNNING_SERVERS
|
||||
from .metrics import TOTAL_USERS
|
||||
from .objects import Server
|
||||
from .spawner import LocalProcessSpawner
|
||||
from .utils import AnyTimeoutError
|
||||
from .utils import make_ssl_context
|
||||
from .utils import maybe_future
|
||||
from .utils import url_path_join
|
||||
|
||||
|
||||
# detailed messages about the most common failure-to-start errors,
|
||||
# which manifest timeouts during start
|
||||
start_timeout_message = """
|
||||
Common causes of this timeout, and debugging tips:
|
||||
|
||||
1. Everything is working, but it took too long.
|
||||
To fix: increase `Spawner.start_timeout` configuration
|
||||
to a number of seconds that is enough for spawners to finish starting.
|
||||
2. The server didn't finish starting,
|
||||
or it crashed due to a configuration issue.
|
||||
Check the single-user server's logs for hints at what needs fixing.
|
||||
"""
|
||||
|
||||
http_timeout_message = """
|
||||
Common causes of this timeout, and debugging tips:
|
||||
|
||||
1. The server didn't finish starting,
|
||||
or it crashed due to a configuration issue.
|
||||
Check the single-user server's logs for hints at what needs fixing.
|
||||
2. The server started, but is not accessible at the specified URL.
|
||||
This may be a configuration issue specific to your chosen Spawner.
|
||||
Check the single-user server logs and resource to make sure the URL
|
||||
is correct and accessible from the Hub.
|
||||
3. (unlikely) Everything is working, but the server took too long to respond.
|
||||
To fix: increase `Spawner.http_timeout` configuration
|
||||
to a number of seconds that is enough for servers to become responsive.
|
||||
"""
|
||||
|
||||
|
||||
class UserDict(dict):
|
||||
"""Like defaultdict, but for users
|
||||
|
||||
@@ -84,7 +114,7 @@ class UserDict(dict):
|
||||
if user.name == key:
|
||||
key = user.id
|
||||
break
|
||||
return dict.__contains__(self, key)
|
||||
return super().__contains__(key)
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""UserDict allows retrieval of user by any of:
|
||||
@@ -108,7 +138,7 @@ class UserDict(dict):
|
||||
if orm_user.id not in self:
|
||||
user = self[orm_user.id] = User(orm_user, self.settings)
|
||||
return user
|
||||
user = dict.__getitem__(self, orm_user.id)
|
||||
user = super().__getitem__(orm_user.id)
|
||||
user.db = self.db
|
||||
return user
|
||||
elif isinstance(key, int):
|
||||
@@ -119,7 +149,7 @@ class UserDict(dict):
|
||||
raise KeyError("No such user: %s" % id)
|
||||
user = self.add(orm_user)
|
||||
else:
|
||||
user = dict.__getitem__(self, id)
|
||||
user = super().__getitem__(id)
|
||||
return user
|
||||
else:
|
||||
raise KeyError(repr(key))
|
||||
@@ -145,7 +175,7 @@ class UserDict(dict):
|
||||
self.db.expunge(orm_spawner)
|
||||
if user.orm_user in self.db:
|
||||
self.db.expunge(user.orm_user)
|
||||
dict.__delitem__(self, user.id)
|
||||
super().__delitem__(user.id)
|
||||
|
||||
def delete(self, key):
|
||||
"""Delete a user from the cache and the database"""
|
||||
@@ -223,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("Adding user {self.name} to group(s): {new_groups}")
|
||||
if removed_groups:
|
||||
self.log.info("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:
|
||||
@@ -346,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'):
|
||||
@@ -561,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
|
||||
|
||||
@@ -592,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,
|
||||
@@ -707,11 +803,11 @@ class User:
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, gen.TimeoutError):
|
||||
if isinstance(e, AnyTimeoutError):
|
||||
self.log.warning(
|
||||
"{user}'s server failed to start in {s} seconds, giving up".format(
|
||||
user=self.name, s=spawner.start_timeout
|
||||
)
|
||||
f"{self.name}'s server failed to start"
|
||||
f" in {spawner.start_timeout} seconds, giving up."
|
||||
f"\n{start_timeout_message}"
|
||||
)
|
||||
e.reason = 'timeout'
|
||||
self.settings['statsd'].incr('spawner.failure.timeout')
|
||||
@@ -764,14 +860,11 @@ class User:
|
||||
http=True, timeout=spawner.http_timeout, ssl_context=ssl_context
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, TimeoutError):
|
||||
if isinstance(e, AnyTimeoutError):
|
||||
self.log.warning(
|
||||
"{user}'s server never showed up at {url} "
|
||||
"after {http_timeout} seconds. Giving up".format(
|
||||
user=self.name,
|
||||
url=server.url,
|
||||
http_timeout=spawner.http_timeout,
|
||||
)
|
||||
f"{self.name}'s server never showed up at {server.url}"
|
||||
f" after {spawner.http_timeout} seconds. Giving up."
|
||||
f"\n{http_timeout_message}"
|
||||
)
|
||||
e.reason = 'timeout'
|
||||
self.settings['statsd'].incr('spawner.failure.http_timeout')
|
||||
|
@@ -23,12 +23,12 @@ from operator import itemgetter
|
||||
|
||||
from async_generator import aclosing
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from tornado import gen
|
||||
from tornado import ioloop
|
||||
from tornado import web
|
||||
from tornado.httpclient import AsyncHTTPClient
|
||||
from tornado.httpclient import HTTPError
|
||||
from tornado.log import app_log
|
||||
from tornado.platform.asyncio import to_asyncio_future
|
||||
|
||||
# For compatibility with python versions 3.6 or earlier.
|
||||
# asyncio.Task.all_tasks() is fully moved to asyncio.all_tasks() starting with 3.9. Also applies to current_task.
|
||||
@@ -97,6 +97,10 @@ def make_ssl_context(keyfile, certfile, cafile=None, verify=True, check_hostname
|
||||
return ssl_context
|
||||
|
||||
|
||||
# AnyTimeoutError catches TimeoutErrors coming from asyncio, tornado, stdlib
|
||||
AnyTimeoutError = (gen.TimeoutError, asyncio.TimeoutError, TimeoutError)
|
||||
|
||||
|
||||
async def exponential_backoff(
|
||||
pass_func,
|
||||
fail_message,
|
||||
@@ -182,7 +186,7 @@ async def exponential_backoff(
|
||||
if dt < max_wait:
|
||||
scale *= scale_factor
|
||||
await asyncio.sleep(dt)
|
||||
raise TimeoutError(fail_message)
|
||||
raise asyncio.TimeoutError(fail_message)
|
||||
|
||||
|
||||
async def wait_for_server(ip, port, timeout=10):
|
||||
@@ -288,12 +292,39 @@ def authenticated_403(self):
|
||||
raise web.HTTPError(403)
|
||||
|
||||
|
||||
def admin_only(f):
|
||||
"""Deprecated!"""
|
||||
# write it this way to trigger deprecation warning at decoration time,
|
||||
# not on the method call
|
||||
warnings.warn(
|
||||
"""@jupyterhub.utils.admin_only is deprecated in JupyterHub 2.0.
|
||||
|
||||
Use the new `@jupyterhub.scopes.needs_scope` decorator to resolve permissions,
|
||||
or check against `self.current_user.parsed_scopes`.
|
||||
""",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# the original decorator
|
||||
@auth_decorator
|
||||
def admin_only(self):
|
||||
"""Decorator for restricting access to admin users"""
|
||||
user = self.current_user
|
||||
if user is None or not user.admin:
|
||||
raise web.HTTPError(403)
|
||||
|
||||
return 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
|
||||
@@ -326,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):
|
||||
@@ -654,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
|
||||
|
@@ -5,3 +5,41 @@ target_version = [
|
||||
"py37",
|
||||
"py38",
|
||||
]
|
||||
|
||||
[tool.tbump]
|
||||
# Uncomment this if your project is hosted on GitHub:
|
||||
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||
|
||||
[tool.tbump.version]
|
||||
current = "2.2.0"
|
||||
|
||||
# Example of a semver regexp.
|
||||
# Make sure this matches current_version before
|
||||
# using tbump
|
||||
regex = '''
|
||||
(?P<major>\d+)
|
||||
\.
|
||||
(?P<minor>\d+)
|
||||
\.
|
||||
(?P<patch>\d+)
|
||||
(?P<pre>((a|b|rc)\d+)|)
|
||||
\.?
|
||||
(?P<dev>(?<=\.)dev\d*|)
|
||||
'''
|
||||
|
||||
[tool.tbump.git]
|
||||
message_template = "Bump to {new_version}"
|
||||
tag_template = "{new_version}"
|
||||
|
||||
# For each file to patch, add a [[tool.tbump.file]] config
|
||||
# section containing the path of the file, relative to the
|
||||
# pyproject.toml location.
|
||||
|
||||
[[tool.tbump.file]]
|
||||
src = "jupyterhub/_version.py"
|
||||
version_template = '({major}, {minor}, {patch}, "{pre}", "{dev}")'
|
||||
search = "version_info = {current_version}"
|
||||
|
||||
[[tool.tbump.file]]
|
||||
src = "docs/source/_static/rest-api.yml"
|
||||
search = "version: {current_version}"
|
||||
|
8
setup.py
8
setup.py
@@ -46,10 +46,9 @@ def get_data_files():
|
||||
"""Get data files in share/jupyter"""
|
||||
|
||||
data_files = []
|
||||
ntrim = len(here + os.path.sep)
|
||||
|
||||
for (d, dirs, filenames) in os.walk(share_jupyterhub):
|
||||
data_files.append((d[ntrim:], [pjoin(d, f) for f in filenames]))
|
||||
rel_d = os.path.relpath(d, here)
|
||||
data_files.append((rel_d, [os.path.join(rel_d, f) for f in filenames]))
|
||||
return data_files
|
||||
|
||||
|
||||
@@ -100,6 +99,7 @@ setup_args = dict(
|
||||
'default = jupyterhub.auth:PAMAuthenticator',
|
||||
'pam = jupyterhub.auth:PAMAuthenticator',
|
||||
'dummy = jupyterhub.auth:DummyAuthenticator',
|
||||
'null = jupyterhub.auth:NullAuthenticator',
|
||||
],
|
||||
'jupyterhub.proxies': [
|
||||
'default = jupyterhub.proxy:ConfigurableHTTPProxy',
|
||||
@@ -301,7 +301,7 @@ class develop_js_css(develop):
|
||||
if not self.uninstall:
|
||||
self.distribution.run_command('js')
|
||||
self.distribution.run_command('css')
|
||||
develop.run(self)
|
||||
super().run()
|
||||
|
||||
|
||||
setup_args['cmdclass']['develop'] = develop_js_css
|
||||
|
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user