Compare commits

...

165 Commits

Author SHA1 Message Date
Min RK
3f01bf400b Bump to 2.0.1 2021-12-22 14:15:53 +01:00
Erik Sundell
c528751502 Merge pull request #3728 from minrk/changelog-2.0.1
Changelog for 2.0.1
2021-12-22 12:32:00 +01:00
Min RK
0018184150 Changelog for 2.0.1 2021-12-22 12:01:30 +01:00
Min RK
7903f76e11 Merge pull request #3723 from sgaist/use_login_url_from_authenticator
Use URL from authenticator on default login form
2021-12-22 10:50:26 +01:00
Samuel Gaist
d5551a2f32 Use URL from authenticator also for local authenticator
This patch is related to the implementation of the
MultiAuthenticator in jupyterhub/oauthenticator#459

The issue will be triggered when using more than one local provider
or mixing with oauth providers.

With multiple providers the template generates a set of buttons to
choose from to continue the login process.

For OAuth, the user will be sent to the provider login page and
the redirect at the end will continue nicely the process.

Now for the tricky part: using a local provider (e.g. PAM), the
user will be redirected to the "same page" thus the same template
will be rendered but this time to show the username/password dialog.

This will trip the workflow because of the action URL coming from
the settings and not from the authenticator. Therefore when the button
is clicked, the user will come back to the original multiple choice page
rather than continue the login.
2021-12-22 10:41:24 +01:00
Erik Sundell
ca564a5948 Merge pull request #3735 from minrk/admin-users-roles
initialize new admin users with default roles
2021-12-22 10:28:20 +01:00
Erik Sundell
0fcc559323 Merge pull request #3726 from minrk/service-whoami-update
update service-whoami example
2021-12-22 10:19:02 +01:00
Min RK
a746e8e7fb update service-whoami example
- update models with 2.0.0
- different scopes for oauth, api
  shows model depends on permissions
- update text with more details about scopes
- fix outdated reference to local-system credentials
2021-12-22 10:10:16 +01:00
Min RK
b2ce6023e1 initialize new admin users with default roles
it was possible for a user in `admin_users` to not get the `user` role
2021-12-22 10:00:08 +01:00
Erik Sundell
39b331df1b Merge pull request #3733 from manics/missing-f
Fix missing f-string modifier
2021-12-22 00:37:37 +01:00
Simon Li
a69140ae1b Fix missing f-string modifier 2021-12-21 23:26:45 +00:00
Erik Sundell
225ca9007a Merge pull request #3731 from minrk/allow-token-auth-user-url
accept token auth on `/hub/user/...`
2021-12-20 17:42:41 +01:00
Erik Sundell
11efebf1e2 Merge pull request #3722 from minrk/ensure-user-login
always assign default roles on login
2021-12-20 17:39:40 +01:00
Erik Sundell
3e5082f265 Merge pull request #3727 from minrk/grant-role-twice
clarify `role` argument in grant/strip_role
2021-12-20 17:38:27 +01:00
Min RK
36cb1df27e accept token auth on /hub/user/... which are probably requests to non-running servers
otherwise, requests get redirected to `/hub/login` instead of failing with 404/503
2021-12-20 13:37:47 +01:00
Min RK
fcad2d5695 clarify role argument in grant/strip_role
I got confused with a variable called `rolename` that was actually an orm.Role

casting types in a signature is confusing,
but now `role` input can be Role or name,
and in the body it will always be a Role that exists

Behavior is unchanged
2021-12-20 11:39:50 +01:00
Min RK
2ec722d3af Merge pull request #3708 from minrk/user-role-startup
Avoid clearing user role membership when defining custom user scopes
2021-12-20 10:48:03 +01:00
Min RK
390f50e246 Merge pull request #3705 from minrk/intersect-token-scopes
use intersect_scopes utility to check token permissions
2021-12-20 10:30:13 +01:00
Min RK
3276e4a58f Merge pull request #3720 from minrk/fix-initial-user-role
simplify default role assignment
2021-12-20 10:30:01 +01:00
Min RK
2a8428dbb0 always assign default roles on login
successful authentication of a user always grants 'user' role

rather than only on first user creation in db
2021-12-16 12:42:47 +01:00
Min RK
7febb3aa06 simplify default role assignment
- always assign 'user' role, not just when no other roles are assigned
- 'admin' role is in addition, not instead
2021-12-16 12:15:31 +01:00
Simon Li
92c6a23a13 Merge pull request #3716 from minrk/pre_spawn_start_msg
Fix error message about Authenticator.pre_spawn_start
2021-12-15 14:00:18 +00:00
Min RK
bb75081086 Fix error message about pre_spawn_start
This isn't the only or even main thing likely to raise here,
so don't blame it, which is confusing, especially in a message shown to users.

Log the full exception, and show a more opaque message to the user to avoid confusion
2021-12-15 12:44:14 +01:00
Min RK
915c244d02 Test loading user/admin role membership from config
Cover different combinations of:

- existing assignments in db
- additive allowed_users/admin_users config
- strict users membership assignment in load_roles
2021-12-15 12:40:54 +01:00
Min RK
b5e0f46796 rbac_upgrade detection only when users already exist in the db
Instead of just checking for absent roles, also check for present users

otherwise, this will run on all first launches post-2.0, which we don't want
2021-12-15 12:37:55 +01:00
Min RK
34e8e2d828 Avoid clearing user role membership when defining custom user role
If the user role was defined but did not specify a user membership list,
users granted access by the Authenticator would lose their status

Instead, do nothing on an undefined user membership list,
leaving any users with their existing default role assignment
2021-12-15 12:37:55 +01:00
Min RK
c2cbeda9e4 Merge pull request #3714 from team-monolith-product/main
Grant role after user creation during config load
2021-12-15 12:36:53 +01:00
이창환
92a33bd358 Use assign_default_role not grant_role 2021-12-15 20:27:18 +09:00
이창환
e19700348d Move grant role into _get_or_create_user 2021-12-15 19:05:16 +09:00
Simon Li
04ac02c09d Merge pull request #3717 from minrk/allowed-roles-type
fix Spawner.oauth_roles config
2021-12-14 15:46:07 +00:00
Min RK
2b61c16c06 fix Spawner.oauth_roles config
missing cast to orm.Role from config when populating oauth client

test included
2021-12-14 13:20:11 +01:00
Min RK
028722a5ac Merge pull request #3719 from minrk/dist-upgrade-apt
check for db clients before requesting install
2021-12-14 13:12:28 +01:00
Min RK
ca7e07de54 check for db clients before requesting install
workaround weird issue where mysql-client install fails because it's present with a weird pinning
2021-12-14 11:51:39 +01:00
Min RK
c523e74644 Merge pull request #3715 from naatebarber/pass-base-url
Pass Base Url
2021-12-14 10:43:40 +01:00
pre-commit-ci[bot]
dd932784ed [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2021-12-14 01:46:27 +00:00
Nathan Barber
4704217dc5 Fix bug with umwarranted error messages 2021-12-13 20:36:00 -05:00
Nathan Barber
3893fb6d2c Pass base_url 2021-12-13 19:55:23 -05:00
이창환
59b2b36a27 Grant role after user creation during config load 2021-12-13 21:32:25 +09:00
Min RK
f6eaaebdf4 use intersect_scopes utility to check token permissions
we didn't have this function when we started checking token scopes
2021-12-07 13:55:32 +01:00
Erik Sundell
bb20002aea Merge pull request #3704 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-12-06 22:18:08 +01:00
pre-commit-ci[bot]
d1995ba7eb [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 21.11b1 → 21.12b0](https://github.com/psf/black/compare/21.11b1...21.12b0)
- [github.com/pre-commit/mirrors-prettier: v2.5.0 → v2.5.1](https://github.com/pre-commit/mirrors-prettier/compare/v2.5.0...v2.5.1)
2021-12-06 21:09:54 +00:00
Yuvi Panda
b06f4cda33 Merge pull request #3697 from naatebarber/react-error-handling
React Error Handling
2021-12-03 12:22:22 +05:30
Erik Sundell
9d7a235107 Merge pull request #3701 from minrk/extra-cors-check
cors: handle mismatched implicit/explicit ports in host header
2021-12-02 12:46:26 +01:00
Erik Sundell
18459bad11 Merge pull request #3698 from minrk/separate-jest
run jsx tests in their own job
2021-12-02 12:30:43 +01:00
Min RK
ced941a6aa cors: handle mismatched implicit/explicit ports in host header
http://host:80 should match http://host

cors tests are parametrized to make it easier to add more cases
2021-12-02 11:02:21 +01:00
Min RK
85e37e7f8c Merge pull request #3195 from kylewm/x-forwarded-host
add option to use a different Host header for referer checks
2021-12-02 10:03:33 +01:00
Min RK
53067de596 finalize forwarded_host_header tests 2021-12-02 09:37:02 +01:00
Kyle Mahan
9c13861eb8 add configuration value to use a different Host key for CORS checks 2021-12-02 09:18:38 +01:00
Min RK
b0ed9f5928 run jsx tests in their own job
no need to re-run them for each entry in our Python matrix
2021-12-02 08:57:45 +01:00
Min RK
ff0d15fa43 Bump to 2.1.0.dev 2021-12-02 08:53:50 +01:00
Nathan Barber
81bb05d0ef Merge branch 'jupyterhub:main' into react-error-handling 2021-12-01 10:27:40 -05:00
Min RK
95649a3ece Bump to 2.0.0 2021-12-01 14:58:11 +01:00
Erik Sundell
08288f5b0f Merge pull request #3696 from minrk/changelog-2.0
Changelog for 2.0
2021-12-01 14:56:30 +01:00
Min RK
01b1ce3995 Link to upgrading doc from the changelog 2021-12-01 14:36:07 +01:00
Min RK
cbe93810be remove redundant admin/upgrading ref target
confuses myst to have a ref: and doc: target with the same name
2021-12-01 14:36:06 +01:00
Min RK
75309d9dc4 Changelog for 2.0
ready to go!
2021-12-01 14:36:06 +01:00
pre-commit-ci[bot]
8594b3fa70 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2021-12-01 06:54:56 +00:00
Nathan Barber
1e956df4c7 Re-lint withAPI 2021-12-01 01:54:18 -05:00
Nathan Barber
8ba2bcdfd4 Merge branch 'react-error-handling' of github.com:naatebarber/jupyterhub into react-error-handling 2021-12-01 01:52:59 -05:00
Nathan Barber
999cc0a37c Clean and lint 2021-12-01 01:52:18 -05:00
pre-commit-ci[bot]
a6611e5999 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2021-12-01 06:45:29 +00:00
Nathan Barber
c0d5778d93 Merge branch 'jupyterhub:main' into react-error-handling 2021-12-01 01:35:14 -05:00
Nathan Barber
293fe4e838 Updated ServerDashboard to testing-library, added tests 2021-12-01 01:32:19 -05:00
Nathan Barber
dfee471e22 Updated Groups to testing-library 2021-12-01 00:20:16 -05:00
Nathan Barber
db7cdc4aa7 Updated GroupEdit to testing-library. Added tests 2021-12-01 00:03:56 -05:00
Nathan Barber
c048ad4aac Updated CreateGroup, EditUser to testing-library. Added tests 2021-11-30 23:23:52 -05:00
Nathan Barber
9e245379e8 Begin replacing enzyme with react-testing-library 2021-11-30 22:23:22 -05:00
Nathan Barber
496f414a2e Update structure for AddUser tests, add tests 2021-11-30 16:43:55 -05:00
Nathan Barber
df67a75893 Add UI error dialogues to api requests 2021-11-30 15:35:00 -05:00
Erik Sundell
249b4af59f Merge pull request #3695 from minrk/service-auth-doc
Service auth doc
2021-11-30 16:09:12 +01:00
Min RK
db3b2d8961 refine service auth docs
favor HubOAuth, as that should really be the default for most services

- Remove some outdated 'new in' text
- Remove docs for some deprecated features (hub_users, hub_groups)
- more detail on what's required
2021-11-30 10:48:53 +01:00
Min RK
7d44a0ffc8 add tornado to intersphinx 2021-11-30 10:45:38 +01:00
Min RK
202b2590e9 doc: remove redundant TOC from services doc
The same TOC is automatically generated on the sidebar, no need for a manual copy
2021-11-30 09:18:57 +01:00
Erik Sundell
c98ef547a8 Merge pull request #3693 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-11-29 22:16:35 +01:00
pre-commit-ci[bot]
8a866a9102 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-prettier: v2.4.1 → v2.5.0](https://github.com/pre-commit/mirrors-prettier/compare/v2.4.1...v2.5.0)
2021-11-29 20:06:06 +00:00
Min RK
b186bdbce3 Bump to 2.0.0rc5 2021-11-26 09:07:15 +01:00
Min RK
36fe6c6f66 Merge pull request #3692 from minrk/clrc5
changelog for 2.0.0rc5
2021-11-26 09:06:21 +01:00
Min RK
8bf559db52 changelog for 2.0.0rc5 2021-11-26 09:05:21 +01:00
Simon Li
750085f627 Merge pull request #3690 from minrk/gha-singleuser
build jupyterhub/singleuser along with other images
2021-11-25 20:17:12 +00:00
Min RK
2dc2c99b4a Merge pull request #3640 from minrk/doc-api-only
add api-only doc
2021-11-25 20:26:25 +01:00
pre-commit-ci[bot]
e703555888 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2021-11-25 19:16:41 +00:00
Min RK
7e102f0511 Apply suggestions from code review
Co-authored-by: Carol Willing <carolcode@willingconsulting.com>
2021-11-25 20:16:10 +01:00
Min RK
facde96425 build jupyterhub/singleuser along with other images
got lost in the migration to GHA docker builds
2021-11-24 21:15:59 +01:00
Erik Sundell
608c746a59 Merge pull request #3689 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-11-22 22:26:25 +01:00
pre-commit-ci[bot]
a8c834410f [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.29.0 → v2.29.1](https://github.com/asottile/pyupgrade/compare/v2.29.0...v2.29.1)
- [github.com/psf/black: 21.10b0 → 21.11b1](https://github.com/psf/black/compare/21.10b0...21.11b1)
2021-11-22 20:51:45 +00:00
Min RK
bda14b487a Bump to 2.0.0rc4 2021-11-18 15:33:12 +01:00
Min RK
fd5cf8c360 Merge pull request #3687 from minrk/rc4-changelog
update 2.0 changelog
2021-11-18 15:32:27 +01:00
Min RK
03758e5b46 update 2.0 changelog
prep for rc4
2021-11-18 14:50:10 +01:00
Erik Sundell
e540d143bb Merge pull request #3685 from minrk/session-id-model
Add Session id to token/identify models
2021-11-18 13:39:34 +01:00
Erik Sundell
b2c5ad40c5 Merge pull request #3686 from minrk/login_with_token
Hub: only accept tokens in API requests
2021-11-18 13:27:41 +01:00
Min RK
edfdf672d8 Hub: only accept tokens in API requests
do not allow token-based access to pages

Tokens are only accepted via Authorization header, which doesn't make sense to pass to pages,
so disallow it explicitly to avoid surprises
2021-11-18 09:36:49 +01:00
Min RK
39f19aef49 add session_id to token model 2021-11-17 09:46:26 +01:00
Min RK
8813bb63d4 update to openapi 3.0
easier to implement oneOf schemas

document scopes, session_id in /api/user model
2021-11-17 09:44:38 +01:00
Yuvi Panda
7c18d6fe14 Merge pull request #3681 from minrk/log-app-versions
Log single-user app versions at startup
2021-11-16 00:11:32 +05:30
Erik Sundell
d1fe17d3cb Merge pull request #3682 from minrk/relpath
always use relative paths in data_files
2021-11-08 14:06:20 +01:00
Min RK
b8965c2017 always use relative paths in data_files
instead of absolute paths for sources

seems to have started failing on Windows
2021-11-08 13:29:26 +01:00
Min RK
733d7bc158 Log single-user app versions at startup
Reports jupyterlab, jupyter_server versions during startup
2021-11-08 09:21:32 +01:00
Min RK
88f31c29bb add api-only doc
Describe how to use JupyterHub as a "behind the scenes" implementation detail,
rather than a user-facing application.
2021-11-04 17:16:59 +01:00
Min RK
3caf3cfda8 Bump to 2.0.0rc3 2021-11-04 15:52:37 +01:00
Erik Sundell
d076c55cca Merge pull request #3679 from minrk/forward-1.5
Forward-port fixes from 1.5.0 security release
2021-11-04 15:30:04 +01:00
Min RK
3e185022c8 changelog for 1.5.0 2021-11-04 15:04:40 +01:00
Min RK
857ee2885f jupyterlab: don't use $JUPYTERHUB_API_TOKEN in PageConfig.token 2021-11-04 15:03:12 +01:00
Min RK
cd8dd56213 Revert "store tokens passed via url or header, not only url."
This reverts commit 53c3201c17.

Only tokens in URLs should be persisted in cookies.
Tokens in headers should not have any effect on cookies.
2021-11-04 15:03:12 +01:00
Erik Sundell
f06902aa8f Merge pull request #3675 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-11-02 01:56:07 +01:00
pre-commit-ci[bot]
bb109c6f75 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 21.9b0 → 21.10b0](https://github.com/psf/black/compare/21.9b0...21.10b0)
2021-11-01 20:25:25 +00:00
Erik Sundell
e525ec7b5b Merge pull request #3674 from minrk/verify-login-role
verify that successful login assigns default role
2021-10-30 17:50:01 +02:00
Min RK
356b98e19f verify that successful login assigns default role
and that repeated login after revoked role doesn't reassign role
2021-10-30 14:30:33 +02:00
Erik Sundell
8c803e7a53 Merge pull request #3673 from minrk/main
more calculators
2021-10-30 14:21:17 +02:00
Min RK
2e21a6f4e0 more calculators 2021-10-30 14:07:04 +02:00
Min RK
cfd31b14e3 Bump to 2.0.0rc2 2021-10-30 13:36:54 +02:00
Erik Sundell
f03a620424 Merge pull request #3672 from minrk/prerelease
use v2 of jupyterhub/action-major-minor-tag-calculator
2021-10-30 13:29:43 +02:00
Min RK
440ad77ad5 use v2 of jupyterhub/action-major-minor-tag-calculator
needed for handling of prerelease tags
2021-10-30 12:42:29 +02:00
Min RK
68835e97a2 Bump to 2.0.0rc1 2021-10-30 12:37:39 +02:00
Min RK
ce80c9c9cf Merge pull request #3669 from minrk/bumpversion
use tbump to tag versions
2021-10-30 12:34:28 +02:00
Min RK
3c299fbfb7 use tbump for bumping versions
avoids needing to manually keep files in sync,
and dramatically reduces RELEASE steps
2021-10-30 12:18:14 +02:00
Min RK
597f8ea6eb Merge pull request #3670 from manics/support-bot
Add support-bot
2021-10-30 12:17:47 +02:00
Erik Sundell
d1181085bf Merge pull request #3665 from minrk/openapi-test
Tests for our openapi spec
2021-10-29 16:05:05 +02:00
Simon Li
913832da48 Add support-bot
The old support-bot was disabled https://github.com/jupyterhub/.github/issues/15
This is the recommended replacement https://github.com/dessant/support-requests/issues/8
2021-10-29 14:09:49 +01:00
Min RK
42f57f4a72 Merge pull request #3662 from minrk/2.0rc-changelog
changelog for 2.0 release candidate
2021-10-29 13:40:34 +02:00
Min RK
d01a518c41 add updating rest-api version to release
and make real release checklist, to match other repos
2021-10-29 13:13:41 +02:00
Min RK
65ce06b116 test feedback
use global PYTEST_ARGS for nicer, simpler always-on arguments for pytest
2021-10-29 13:13:41 +02:00
Min RK
468aa5e93c render openapi spec client-side
- move spec to _static/rest-api.yml, since the original yaml must be served
- copy javascript rendering code from FastAPI (uses swagger-ui)
- remove link to pet store, since there isn't a big enough difference to duplicate it
- remove bootprint rendering with node
2021-10-29 13:13:41 +02:00
Min RK
5c01370e6f set version as long as we are rewriting the file 2021-10-29 13:13:41 +02:00
Min RK
21d08883a8 resolve rest-api validation errors
- regen scopes by running generate-scopes.py
2021-10-29 13:13:41 +02:00
Min RK
59de506f20 validate the rest-api
both with github action that runs openapi validation,
and a couple local tests to verify some maintenance tasks are done
2021-10-29 13:13:41 +02:00
Erik Sundell
b34120ed81 Merge pull request #3663 from minrk/clarify-default-roles
clarify some log messages during role assignment
2021-10-29 12:19:45 +02:00
Erik Sundell
617978179d Merge pull request #3667 from minrk/autodoc-traits
use stable autodoc-traits
2021-10-29 12:16:30 +02:00
Min RK
0985d6fdf2 use stable autodoc-traits 2021-10-29 11:32:02 +02:00
Min RK
2049fb0491 clarify some log messages during role assignment
and only commit on change
2021-10-29 11:22:12 +02:00
Erik Sundell
a58fc6534b Merge pull request #3664 from minrk/create-groups-on-startup
create groups declared in roles
2021-10-28 03:30:25 +02:00
Min RK
a14f97b7aa create groups declared in roles
matches behavior of users
2021-10-27 21:00:49 +02:00
Min RK
0a4cd5b4f2 add auto-changelog for 2.0rc 2021-10-27 16:22:43 +02:00
Min RK
dca6d372df Merge pull request #3661 from minrk/owner-metascope
Rename 'all' metascope to more descriptive 'inherit'
2021-10-27 16:20:29 +02:00
pre-commit-ci[bot]
3898c72921 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2021-10-27 14:01:05 +00:00
Min RK
b25517efe8 Rename 'all' metascope to more descriptive 'inherit'
since it means 'inheriting' the owner's permissions

'all' prompted the question 'all of what, exactly?'

Additionally, fix some NameErrors that should have been KeyErrors
2021-10-27 16:00:21 +02:00
Erik Sundell
392dffd11e Merge pull request #3659 from minrk/deprecate-admin-only
deprecate instead of remove `@admin_only` auth decorator
2021-10-25 23:32:01 +02:00
Erik Sundell
510f6ea7e6 Merge pull request #3660 from minrk/scope-error
minor refinement of excessive scopes error message
2021-10-25 15:53:43 +02:00
Min RK
296a0ad2f2 minor refinement of excessive scopes error message
show the role name
2021-10-25 14:10:57 +02:00
Min RK
487c4524ad deprecate instead of remove @admin_only auth decorator
no harm in keeping it around for a deprecation cycle
2021-10-25 13:00:45 +02:00
Erik Sundell
b2f0208fcc Merge pull request #3658 from minrk/better-timeout-message
improve timeout handling and messages
2021-10-20 22:15:26 +02:00
Min RK
84b9c3848c more detailed error messages for start timeouts
these are the most common error for any number of reasons spawn may fail
2021-10-20 20:08:34 +02:00
Min RK
9adbafdfb3 consistent handling of any timeout error
some things raise standard TimeoutError, others may raise tornado gen.TimeoutError (gen.with_timeout)

For consistency, add AnyTimeoutError tuple to allow catching any timeout, no matter what kind

Where we were raising `TimeoutError`,
we should have been raising `asyncio.TimeoutError`.

The base TimeoutError is an OSError for ETIMEO, which is for system calls
2021-10-20 20:07:45 +02:00
Erik Sundell
9cf2b5101e Merge pull request #3657 from edgarcosta/patch-1
docs: fix typo in proxy config example
2021-10-20 09:12:30 +02:00
Edgar Costa
725fa3a48a typo 2021-10-19 22:39:41 -04:00
Erik Sundell
534dda3dc7 Merge pull request #3653 from minrk/admin-no-such-user
raise 404 on admin attempt to spawn nonexistent user
2021-10-15 15:23:18 +02:00
Min RK
b0c7df04ac raise 404 on admin attempt to spawn nonexistent user 2021-10-15 14:40:47 +02:00
Min RK
61b0e8bef5 2.0.0b3 2021-10-14 12:49:20 +02:00
Erik Sundell
64f3938528 Merge pull request #3649 from minrk/cl-beta-3
add 424 status code change to changelog
2021-10-13 09:52:33 +02:00
Min RK
85bc92d88e Merge pull request #3646 from joegasewicz/joegasewicz-New-user-token-returns-200-instead-of-201-1
new user token returns 200 instead of 201
2021-10-13 09:24:30 +02:00
Min RK
7bcda18564 add 424 status code change to changelog 2021-10-13 09:17:47 +02:00
Erik Sundell
86da36857e Merge pull request #3647 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-10-12 00:13:22 +02:00
pre-commit-ci[bot]
530833e930 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/PyCQA/flake8: 3.9.2 → 4.0.1](https://github.com/PyCQA/flake8/compare/3.9.2...4.0.1)
2021-10-11 19:42:10 +00:00
Joe Gasewicz
3b0850fa9b Fixed test_roles 2021-10-07 23:14:10 +01:00
josefgasewicz
1366911be6 Fixed tests & set status after writing json 2021-10-07 22:21:16 +01:00
Joe Gasewicz
fe276eac64 Update users.py
New user token returns 200 instead of 201 Fixes #3642
2021-10-07 16:31:23 +01:00
Min RK
9209ccd0de Merge pull request #3636 from yuvipanda/404
Fail suspected API requests with 424, not 503
2021-10-05 15:16:18 +02:00
YuviPanda
3b2a1a37f9 Update tests that were looking for 503s 2021-10-05 18:10:52 +05:30
YuviPanda
6007ba78b0 Preserve older 503 behavior behind a flag 2021-10-05 17:56:51 +05:30
YuviPanda
9cb19cc342 Use 424 rather than 404 to indicate non-running server
404 is also used to identify that a particular resource
(like a kernel or terminal) is not present, maybe because
it is deleted. That comes from the notebook server, while
here we are responding from JupyterHub. Saying that the
user server they are trying to request the resource (kernel, etc)
from does not exist seems right.
2021-10-05 17:44:17 +05:30
YuviPanda
0f471f4e12 Fail suspected API requests with 404, not 503
Non-running user servers making requests is a fairly
common occurance - user servers get culled while their
browser tabs are left open. So we now have a background level
of 503s responses on the hub *all* the time, making it
very difficult to detect *real* 503s, which should ideally
be closely monitored and alerted on.

I *think* 404 is a more appropriate response, as the resource
(API) being requested is no longer present.
2021-10-05 03:00:16 +05:30
Erik Sundell
68db740998 Merge pull request #3635 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-10-04 22:38:05 +02:00
pre-commit-ci[bot]
9c0c6f25b7 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.28.0 → v2.29.0](https://github.com/asottile/pyupgrade/compare/v2.28.0...v2.29.0)
2021-10-04 19:48:13 +00:00
Min RK
5f0077cb5b Merge pull request #3445 from rpwagner/patch-1
Initial SECURITY.md
2021-09-29 09:42:59 +02:00
Rick Wagner
3610454a12 adding initial security policy 2021-06-01 09:51:20 -07:00
Rick Wagner
abc4bbebe4 Initial SECURITY.md
Proposing a basic security policy, similar to the README or contributors guide, based on the [GitHub documentation](https://docs.github.com/en/code-security/security-advisories/adding-a-security-policy-to-your-repository) and current Project Jupyter recommendations. This may be better as a [default file for the organization](https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions/creating-a-default-community-health-file).
2021-04-23 23:12:51 -07:00
85 changed files with 4708 additions and 2331 deletions

View File

@@ -129,7 +129,7 @@ jobs:
# If GITHUB_TOKEN isn't available (e.g. in PRs) returns no tags []. # If GITHUB_TOKEN isn't available (e.g. in PRs) returns no tags [].
- name: Get list of jupyterhub tags - name: Get list of jupyterhub tags
id: jupyterhubtags id: jupyterhubtags
uses: jupyterhub/action-major-minor-tag-calculator@v1 uses: jupyterhub/action-major-minor-tag-calculator@v2
with: with:
githubToken: ${{ secrets.GITHUB_TOKEN }} githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub:" prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub:"
@@ -150,7 +150,7 @@ jobs:
- name: Get list of jupyterhub-onbuild tags - name: Get list of jupyterhub-onbuild tags
id: onbuildtags id: onbuildtags
uses: jupyterhub/action-major-minor-tag-calculator@v1 uses: jupyterhub/action-major-minor-tag-calculator@v2
with: with:
githubToken: ${{ secrets.GITHUB_TOKEN }} githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:" prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:"
@@ -171,7 +171,7 @@ jobs:
- name: Get list of jupyterhub-demo tags - name: Get list of jupyterhub-demo tags
id: demotags id: demotags
uses: jupyterhub/action-major-minor-tag-calculator@v1 uses: jupyterhub/action-major-minor-tag-calculator@v2
with: with:
githubToken: ${{ secrets.GITHUB_TOKEN }} githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:" prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:"
@@ -190,3 +190,23 @@ jobs:
platforms: linux/amd64 platforms: linux/amd64
push: true push: true
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }} tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}
# 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 # associated tag: v2.4.0
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
View 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"

View File

@@ -14,12 +14,59 @@ on:
env: env:
# UTF-8 content may be interpreted as ascii and causes errors without this. # UTF-8 content may be interpreted as ascii and causes errors without this.
LANG: C.UTF-8 LANG: C.UTF-8
PYTEST_ADDOPTS: "--verbose --color=yes"
jobs: jobs:
rest-api:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Validate REST API
uses: char0n/swagger-editor-validate@182d1a5d26ff5c2f4f452c43bd55e2c7d8064003
with:
definition-file: docs/source/_static/rest-api.yml
- uses: actions/setup-python@v2
with:
python-version: "3.9"
# in addition to the doc requirements
# the docs *tests* require pre-commit and pytest
- run: |
pip install -r docs/requirements.txt pytest pre-commit -e .
- run: |
pytest docs/
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 # Run "pytest jupyterhub/tests" in various configurations
pytest: pytest:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
timeout-minutes: 10 timeout-minutes: 15
strategy: strategy:
# Keep running even if one variation of the job fail # Keep running even if one variation of the job fail
@@ -106,7 +153,6 @@ jobs:
run: | run: |
npm install npm install
npm install -g configurable-http-proxy npm install -g configurable-http-proxy
npm install -g yarn
npm list npm list
# NOTE: actions/setup-python@v2 make use of a cache within the GitHub base # NOTE: actions/setup-python@v2 make use of a cache within the GitHub base
@@ -169,26 +215,25 @@ jobs:
if: ${{ matrix.db }} if: ${{ matrix.db }}
run: | run: |
if [ "${{ matrix.db }}" == "mysql" ]; then if [ "${{ matrix.db }}" == "mysql" ]; then
if [[ -z "$(which mysql)" ]]; then
sudo apt-get update sudo apt-get update
sudo apt-get install -y mysql-client sudo apt-get install -y mysql-client
fi
DB=mysql bash ci/docker-db.sh DB=mysql bash ci/docker-db.sh
DB=mysql bash ci/init-db.sh DB=mysql bash ci/init-db.sh
fi fi
if [ "${{ matrix.db }}" == "postgres" ]; then if [ "${{ matrix.db }}" == "postgres" ]; then
if [[ -z "$(which psql)" ]]; then
sudo apt-get update sudo apt-get update
sudo apt-get install -y postgresql-client sudo apt-get install -y postgresql-client
fi
DB=postgres bash ci/docker-db.sh DB=postgres bash ci/docker-db.sh
DB=postgres bash ci/init-db.sh DB=postgres bash ci/init-db.sh
fi fi
- name: Run pytest - name: Run pytest
# FIXME: --color=yes explicitly set because:
# https://github.com/actions/runner/issues/241
run: | run: |
pytest -v --maxfail=2 --color=yes --cov=jupyterhub jupyterhub/tests pytest --maxfail=2 --cov=jupyterhub jupyterhub/tests
- name: Run yarn jest test
run: |
cd jsx && yarn && yarn test
- name: Submit codecov report - name: Submit codecov report
run: | run: |
codecov codecov

View File

@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v2.28.0 rev: v2.29.1
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: args:
@@ -10,15 +10,15 @@ repos:
hooks: hooks:
- id: reorder-python-imports - id: reorder-python-imports
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 21.9b0 rev: 21.12b0
hooks: hooks:
- id: black - id: black
- repo: https://github.com/pre-commit/mirrors-prettier - repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.4.1 rev: v2.5.1
hooks: hooks:
- id: prettier - id: prettier
- repo: https://github.com/PyCQA/flake8 - repo: https://github.com/PyCQA/flake8
rev: "3.9.2" rev: "4.0.1"
hooks: hooks:
- id: flake8 - id: flake8
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks

View File

@@ -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

View File

@@ -56,9 +56,11 @@ Basic principles for operation are:
servers. servers.
JupyterHub also provides a 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. for administration of the Hub and its users.
[rest api]: https://juptyerhub.readthedocs.io/en/latest/reference/rest-api.html
## Installation ## Installation
### Check prerequisites ### Check prerequisites
@@ -239,7 +241,7 @@ You can also talk with us on our JupyterHub [Gitter](https://gitter.im/jupyterhu
- [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues) - [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
- [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial) - [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](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) - [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 website](https://jupyter.org)
- [Project Jupyter community](https://jupyter.org/community) - [Project Jupyter community](https://jupyter.org/community)

50
RELEASE.md Normal file
View 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
View File

@@ -0,0 +1,5 @@
# Reporting a Vulnerability
If you believe youve 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).

View File

@@ -14,6 +14,7 @@ pytest>=3.3
pytest-asyncio pytest-asyncio
pytest-cov pytest-cov
requests-mock requests-mock
tbump
# blacklist urllib3 releases affected by https://github.com/urllib3/urllib3/issues/1683 # blacklist urllib3 releases affected by https://github.com/urllib3/urllib3/issues/1683
# I *think* this should only affect testing, not production # I *think* this should only affect testing, not production
urllib3!=1.25.4,!=1.25.5 urllib3!=1.25.4,!=1.25.5

View File

@@ -53,14 +53,6 @@ help:
clean: clean:
rm -rf $(BUILDDIR)/* 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 metrics: source/reference/metrics.rst
source/reference/metrics.rst: generate-metrics.py 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 source/rbac/scope-table.md: source/rbac/generate-scope-table.py
python3 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 $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo @echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html." @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."

View File

@@ -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"
}
}

View File

@@ -1,9 +1,7 @@
-r ../requirements.txt -r ../requirements.txt
alabaster_jupyterhub alabaster_jupyterhub
# Temporary fix of #3021. Revert back to released autodoc-traits when autodoc-traits
# 0.1.0 released.
https://github.com/jupyterhub/autodoc-traits/archive/d22282c1c18c6865436e06d8b329c06fe12a07f8.zip
myst-parser myst-parser
pydata-sphinx-theme pydata-sphinx-theme
pytablewriter>=0.56 pytablewriter>=0.56

File diff suppressed because it is too large Load Diff

View File

@@ -2,3 +2,9 @@
.navbar-brand { .navbar-brand {
height: 4rem !important; height: 4rem !important;
} }
/* hide redundant funky-formatted swagger-ui version */
.swagger-ui .info .title small {
display: none !important;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,3 @@
.. _admin/upgrading:
==================== ====================
Upgrading JupyterHub Upgrading JupyterHub
==================== ====================

View File

@@ -17,11 +17,6 @@ information on:
- making an API request programmatically using the requests library - making an API request programmatically using the requests library
- learning more about JupyterHub's API - 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: JupyterHub API Reference:
.. toctree:: .. toctree::

File diff suppressed because one or more lines are too long

View File

@@ -205,7 +205,10 @@ epub_exclude_files = ['search.html']
# -- Intersphinx ---------------------------------------------------------- # -- 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 -------------------------------------------------------- # -- Read The Docs --------------------------------------------------------
@@ -215,7 +218,7 @@ if on_rtd:
# build both metrics and rest-api, since RTD doesn't run make # build both metrics and rest-api, since RTD doesn't run make
from subprocess import check_call as sh from subprocess import check_call as sh
sh(['make', 'metrics', 'rest-api', 'scopes'], cwd=docs) sh(['make', 'metrics', 'scopes'], cwd=docs)
# -- Spell checking ------------------------------------------------------- # -- Spell checking -------------------------------------------------------

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -43,7 +43,7 @@ JupyterHub performs the following functions:
notebook servers notebook servers
For convenient administration of the Hub, its users, and services, 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 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>`_. 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 .. _JupyterHub: https://github.com/jupyterhub/jupyterhub
.. _Jupyter notebook: https://jupyter-notebook.readthedocs.io/en/latest/ .. _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

View File

@@ -5,10 +5,12 @@ from pathlib import Path
from pytablewriter import MarkdownTableWriter from pytablewriter import MarkdownTableWriter
from ruamel.yaml import YAML from ruamel.yaml import YAML
import jupyterhub
from jupyterhub.scopes import scope_definitions from jupyterhub.scopes import scope_definitions
HERE = os.path.abspath(os.path.dirname(__file__)) 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")
class ScopeTableGenerator: class ScopeTableGenerator:
@@ -98,22 +100,26 @@ class ScopeTableGenerator:
def write_api(self): def write_api(self):
"""Generates the API description in markdown format and writes it into `rest-api.yml`""" """Generates the API description in markdown format and writes it into `rest-api.yml`"""
filename = f"{PARENT}/rest-api.yml" filename = REST_API_YAML
yaml = YAML(typ='rt') yaml = YAML(typ='rt')
yaml.preserve_quotes = True yaml.preserve_quotes = True
scope_dict = {} scope_dict = {}
with open(filename, 'r+') as f: with open(filename) as f:
content = yaml.load(f.read()) content = yaml.load(f.read())
f.seek(0)
content["info"]["version"] = jupyterhub.__version__
for scope in self.scopes: for scope in self.scopes:
description = self.scopes[scope]['description'] description = self.scopes[scope]['description']
doc_description = self.scopes[scope].get('doc_description', '') doc_description = self.scopes[scope].get('doc_description', '')
if doc_description: if doc_description:
description = doc_description description = doc_description
scope_dict[scope] = description scope_dict[scope] = description
content['securityDefinitions']['oauth2']['scopes'] = scope_dict content['components']['securitySchemes']['oauth2']['flows'][
'authorizationCode'
]['scopes'] = scope_dict
with open(filename, 'w') as f:
yaml.dump(content, f) yaml.dump(content, f)
f.truncate()
def main(): def main():

View File

@@ -123,13 +123,13 @@ has,
define the `server` role. define the `server` role.
To restore the JupyterHub 1.x behavior of servers being able to do anything their owners can do, 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 ```python
c.JupyterHub.load_roles = [ c.JupyterHub.load_roles = [
{ {
'name': 'server', 'name': 'server',
'scopes': ['all'], 'scopes': ['inherit'],
} }
] ]
``` ```

View 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:
![Binder launch form](../images/binderhub-form.png)
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:
![screenshot of hub page for server not running](../images/server-not-running.png)
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:
![screenshot of CHP default 404](../images/chp-404.png)
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:
![screenshot of mybinder.org 404](../images/binder-404.png)
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.
:::

View File

@@ -219,7 +219,7 @@ In case of the need to run the jupyterhub under /jhub/ or other location please
httpd.conf amendments: httpd.conf amendments:
```bash ```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] RewriteRule /jhub/(.*) http://127.0.0.1:8000/jhub/$1 [NE,P,L]
ProxyPass /jhub/ http://127.0.0.1:8000/jhub/ ProxyPass /jhub/ http://127.0.0.1:8000/jhub/

View File

@@ -16,10 +16,12 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
proxy proxy
separate-proxy separate-proxy
rest rest
rest-api
server-api server-api
monitoring monitoring
database database
templates templates
api-only
../events/index ../events/index
config-user-env config-user-env
config-examples config-examples

View 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>

View File

@@ -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.

View File

@@ -1,3 +1,5 @@
(rest-api)=
# Using JupyterHub's REST API # Using JupyterHub's REST API
This section will give you information on: This section will give you information on:
@@ -302,12 +304,8 @@ or kubernetes pods.
## Learn more about the API ## Learn more about the API
You can see the full [JupyterHub REST API][] for details. This REST API Spec can You can see the full [JupyterHub REST API][] for details.
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][].
[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/ [openapi initiative]: https://www.openapis.org/
[jupyterhub rest api]: ./rest-api [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 [jupyter notebook rest api]: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/notebook/HEAD/notebook/services/api/api.yaml

View File

@@ -1,17 +1,5 @@
# Services # 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 ## Definition of a Service
When working with JupyterHub, a **Service** is defined as a process that interacts When working with JupyterHub, a **Service** is defined as a process that interacts
@@ -115,6 +103,8 @@ parameters, which describe the environment needed to start the Service process:
The Hub will pass the following environment variables to launch the Service: The Hub will pass the following environment variables to launch the Service:
(service-env)=
```bash ```bash
JUPYTERHUB_SERVICE_NAME: The name of the service JUPYTERHUB_SERVICE_NAME: The name of the service
JUPYTERHUB_API_TOKEN: API token assigned to the service JUPYTERHUB_API_TOKEN: API token assigned to the service
@@ -196,18 +186,38 @@ extra slash you might get unexpected behavior. For example if your service has a
## Hub Authentication and Services ## Hub Authentication and Services
JupyterHub 0.7 introduces some utilities for using the Hub's authentication JupyterHub provides some utilities for using the Hub's authentication
mechanism to govern access to your service. When a user logs into JupyterHub, mechanism to govern access to your service.
the Hub sets a **cookie (`jupyterhub-services`)**. The service can use this
cookie to authenticate requests.
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 can be used by services. You may go beyond this reference implementation and
create custom hub-authenticating clients and services. We describe the process create custom hub-authenticating clients and services. We describe the process
below. below.
The reference, or base, implementation is the [`HubAuth`][hubauth] class, The reference, or base, implementation is the [`HubAuth`][hubauth] class,
which implements the requests to the Hub. 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:
- [`HubAuth`][hubauth] - the most basic authentication,
for services that should only accept API requests authorized with a token.
- [`HubOAuth`][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, To use HubAuth, you must set the `.api_token`, either programmatically when constructing the class,
or via the `JUPYTERHUB_API_TOKEN` environment variable. or via the `JUPYTERHUB_API_TOKEN` environment variable.
@@ -250,18 +260,17 @@ for more details.
### Authenticating tornado services with JupyterHub ### Authenticating tornado services with JupyterHub
Since most Jupyter services are written with tornado, 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. for quickly authenticating your own tornado services with JupyterHub.
Tornado's `@web.authenticated` method calls a Handler's `.get_current_user` 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 `HubAuthenticated` defines method to identify the user. Mixing in {class}`.HubAuthenticated` defines
`get_current_user` to use HubAuth. If you want to configure the HubAuth {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 `initialize` method, instance beyond the default, you'll want to define an {py:meth}`~.tornado.web.RequestHandler.initialize` method,
such as: such as:
```python ```python
class MyHandler(HubAuthenticated, web.RequestHandler): class MyHandler(HubOAuthenticated, web.RequestHandler):
hub_users = {'inara', 'mal'}
def initialize(self, hub_auth): def initialize(self, hub_auth):
self.hub_auth = hub_auth self.hub_auth = hub_auth
@@ -271,14 +280,21 @@ class MyHandler(HubAuthenticated, web.RequestHandler):
... ...
``` ```
The HubAuth will automatically load the desired configuration from the Service The HubAuth class will automatically load the desired configuration from the Service
environment variables. [environment variables](service-env).
If you want to limit user access, you can specify allowed users through either the :::{versionchanged} 2.0
`.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 Access scopes are used to govern access to services.
list nor the group list, they will not be allowed access. If both are left Prior to 2.0,
undefined, then any user will be allowed. 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 ### Implementing your own Authentication with JupyterHub
@@ -354,9 +370,11 @@ section on securing the notebook viewer.
[requests]: http://docs.python-requests.org/en/master/ [requests]: http://docs.python-requests.org/en/master/
[services_auth]: ../api/services.auth.html [services_auth]: ../api/services.auth.html
[hubauth]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth
[huboauth]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuth [huboauth]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuth
[hubauth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token [hubauth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
[hubauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated [hubauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
[huboauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuthenticated
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer [nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi [fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
[fastapi]: https://fastapi.tiangolo.com [fastapi]: https://fastapi.tiangolo.com

45
docs/test_docs.py Normal file
View File

@@ -0,0 +1,45 @@
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():
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_restapi_scopes():
run([sys.executable, "source/rbac/generate-scope-table.py"], cwd=here, check=True)
run(
['pre-commit', 'run', 'prettier', '--files', 'source/_static/rest-api.yml'],
cwd=here,
check=False,
)
run(
[
"git",
"diff",
"--no-pager",
"--exit-code",
str(here.joinpath("source", "_static", "rest-api.yml")),
],
cwd=here,
check=True,
)

View File

@@ -8,59 +8,72 @@ There is an implementation each of api-token-based `HubAuthenticated` and OAuth-
1. Launch JupyterHub and the `whoami` services with 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 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 ```json
{ {
"admin": false, "admin": false,
"last_activity": "2016-05-27T14:05:18.016372", "groups": [],
"kind": "user",
"name": "queequeg", "name": "queequeg",
"pending": null, "scopes": ["access:services!service=whoami-oauth"],
"server": "/user/queequeg" "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, 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: and making a direct request:
```bash ```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, "admin": false,
"created": "2021-05-21T09:47:41.299400Z", "created": "2021-12-20T09:49:37.258427Z",
"groups": [], "groups": [],
"kind": "user", "kind": "user",
"last_activity": "2021-05-21T09:49:08.290745Z", "last_activity": "2021-12-20T10:07:31.298056Z",
"name": "test", "name": "queequeg",
"pending": null, "pending": null,
"roles": [ "roles": ["user"],
"user"
],
"scopes": [ "scopes": [
"access:servers!user=queequeg",
"access:services", "access:services",
"access:servers!user=test", "delete:servers!user=queequeg",
"read:users!user=test", "read:servers!user=queequeg",
"read:users:activity!user=test", "read:tokens!user=queequeg",
"read:users:groups!user=test", "read:users!user=queequeg",
"read:users:name!user=test", "read:users:activity!user=queequeg",
"read:servers!user=test", "read:users:groups!user=queequeg",
"read:tokens!user=test", "read:users:name!user=queequeg",
"users!user=test", "servers!user=queequeg",
"users:activity!user=test", "tokens!user=queequeg",
"users:groups!user=test", "users:activity!user=queequeg"
"users:name!user=test",
"servers!user=test",
"tokens!user=test"
], ],
"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)). 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`, To govern access to the services, create **roles** with the scope `access:services!service=$service-name`,

View File

@@ -10,7 +10,15 @@ c.JupyterHub.services = [
'name': 'whoami-oauth', 'name': 'whoami-oauth',
'url': 'http://127.0.0.1:10102', 'url': 'http://127.0.0.1:10102',
'command': [sys.executable, './whoami-oauth.py'], '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'],
}, },
] ]

View File

@@ -31,6 +31,9 @@
"@babel/core": "^7.12.3", "@babel/core": "^7.12.3",
"@babel/preset-env": "^7.12.11", "@babel/preset-env": "^7.12.11",
"@babel/preset-react": "^7.12.10", "@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", "babel-loader": "^8.2.1",
"bootstrap": "^4.5.3", "bootstrap": "^4.5.3",
"css-loader": "^5.0.1", "css-loader": "^5.0.1",
@@ -54,7 +57,7 @@
"webpack-dev-server": "^3.11.0" "webpack-dev-server": "^3.11.0"
}, },
"devDependencies": { "devDependencies": {
"@wojtekmaj/enzyme-adapter-react-17": "^0.4.1", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
"babel-jest": "^26.6.3", "babel-jest": "^26.6.3",
"enzyme": "^3.11.0", "enzyme": "^3.11.0",
"eslint": "^7.18.0", "eslint": "^7.18.0",

View File

@@ -25,11 +25,20 @@ const AddUser = (props) => {
return ( return (
<> <>
<div className="container"> <div className="container" data-testid="container">
{errorAlert != null ? ( {errorAlert != null ? (
<div className="row"> <div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2"> <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>&times;</span>
</button>
</div>
</div> </div>
</div> </div>
) : ( ) : (
@@ -49,6 +58,7 @@ const AddUser = (props) => {
id="add-user-textarea" id="add-user-textarea"
rows="3" rows="3"
placeholder="usernames separated by line" placeholder="usernames separated by line"
data-testid="user-textarea"
onBlur={(e) => { onBlur={(e) => {
let split_users = e.target.value.split("\n"); let split_users = e.target.value.split("\n");
setUsers(split_users); setUsers(split_users);
@@ -57,10 +67,11 @@ const AddUser = (props) => {
<br></br> <br></br>
<input <input
className="form-check-input" className="form-check-input"
data-testid="check"
type="checkbox" type="checkbox"
value=""
id="admin-check" id="admin-check"
onChange={(e) => setAdmin(e.target.checked)} checked={admin}
onChange={() => setAdmin(!admin)}
/> />
<span> </span> <span> </span>
<label className="form-check-label">Admin</label> <label className="form-check-label">Admin</label>
@@ -74,6 +85,7 @@ const AddUser = (props) => {
<span> </span> <span> </span>
<button <button
id="submit" id="submit"
data-testid="submit"
className="btn btn-primary" className="btn btn-primary"
onClick={() => { onClick={() => {
let filtered_users = users.filter( let filtered_users = users.filter(
@@ -92,14 +104,16 @@ const AddUser = (props) => {
? updateUsers(0, limit) ? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0)) .then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/")) .then(() => history.push("/"))
.catch((err) => console.log(err)) .catch(() =>
setErrorAlert(`Failed to update users.`)
)
: setErrorAlert( : setErrorAlert(
`[${data.status}] Failed to create user. ${ `Failed to create user. ${
data.status == 409 ? "User already exists." : "" data.status == 409 ? "User already exists." : ""
}` }`
) )
) )
.catch((err) => console.log(err)); .catch(() => setErrorAlert(`Failed to create user.`));
}} }}
> >
Add Users Add Users

View File

@@ -1,12 +1,15 @@
import React from "react"; import React from "react";
import Enzyme, { mount } from "enzyme"; import "@testing-library/jest-dom";
import AddUser from "./AddUser"; import { act } from "react-dom/test-utils";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider, useDispatch, useSelector } from "react-redux"; import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux"; import { createStore } from "redux";
import { HashRouter } from "react-router-dom"; 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.mock("react-redux", () => ({
...jest.requireActual("react-redux"), ...jest.requireActual("react-redux"),
@@ -14,19 +17,19 @@ jest.mock("react-redux", () => ({
useSelector: jest.fn(), useSelector: jest.fn(),
})); }));
describe("AddUser Component: ", () => { var mockAsync = (result) =>
var mockAsync = () => jest.fn().mockImplementation(() => Promise.resolve(result));
jest
.fn()
.mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
var addUserJsx = (callbackSpy) => ( var mockAsyncRejection = () =>
jest.fn().mockImplementation(() => Promise.reject());
var addUserJsx = (spy, spy2, spy3) => (
<Provider store={createStore(() => {}, {})}> <Provider store={createStore(() => {}, {})}>
<HashRouter> <HashRouter>
<AddUser <AddUser
addUsers={callbackSpy} addUsers={spy}
failRegexEvent={callbackSpy} failRegexEvent={spy2 || spy}
updateUsers={callbackSpy} updateUsers={spy3 || spy2 || spy}
history={{ push: () => {} }} history={{ push: () => {} }}
/> />
</HashRouter> </HashRouter>
@@ -50,28 +53,87 @@ describe("AddUser Component: ", () => {
useDispatch.mockClear(); useDispatch.mockClear();
}); });
it("Renders", () => { test("Renders", async () => {
let component = mount(addUserJsx(mockAsync())); await act(async () => {
expect(component.find(".container").length).toBe(1); 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);
}); });
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); expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar"], false);
}); });
it("Correctly submits admin", () => { test("Correctly submits admin", async () => {
let callbackSpy = mockAsync(), let callbackSpy = mockAsync();
component = mount(addUserJsx(callbackSpy)),
input = component.find("input").first(); await act(async () => {
input.simulate("change", { target: { checked: true } }); render(addUserJsx(callbackSpy));
let submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenCalledWith([], true);
}); });
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();
}); });

View File

@@ -24,11 +24,20 @@ const CreateGroup = (props) => {
return ( return (
<> <>
<div className="container"> <div className="container" data-testid="container">
{errorAlert != null ? ( {errorAlert != null ? (
<div className="row"> <div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2"> <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>&times;</span>
</button>
</div>
</div> </div>
</div> </div>
) : ( ) : (
@@ -44,6 +53,7 @@ const CreateGroup = (props) => {
<div className="input-group"> <div className="input-group">
<input <input
className="group-name-input" className="group-name-input"
data-testid="group-input"
type="text" type="text"
id="group-name" id="group-name"
value={groupName} value={groupName}
@@ -61,6 +71,7 @@ const CreateGroup = (props) => {
<span> </span> <span> </span>
<button <button
id="submit" id="submit"
data-testid="submit"
className="btn btn-primary" className="btn btn-primary"
onClick={() => { onClick={() => {
createGroup(groupName) createGroup(groupName)
@@ -69,16 +80,18 @@ const CreateGroup = (props) => {
? updateGroups(0, limit) ? updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0)) .then((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups")) .then(() => history.push("/groups"))
.catch((err) => console.log(err)) .catch(() =>
setErrorAlert(`Could not update groups list.`)
)
: setErrorAlert( : setErrorAlert(
`[${data.status}] Failed to create group. ${ `Failed to create group. ${
data.status == 409 data.status == 409
? "Group already exists." ? "Group already exists."
: "" : ""
}` }`
); );
}) })
.catch((err) => console.log(err)); .catch(() => setErrorAlert(`Failed to create group.`));
}} }}
> >
Create Create

View File

@@ -1,13 +1,14 @@
import React from "react"; import React from "react";
import Enzyme, { mount } from "enzyme"; import "@testing-library/jest-dom";
import CreateGroup from "./CreateGroup"; import { act } from "react-dom/test-utils";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider, useDispatch, useSelector } from "react-redux"; import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux"; import { createStore } from "redux";
import { HashRouter } from "react-router-dom"; import { HashRouter } from "react-router-dom";
import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line // eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() }); import CreateGroup from "./CreateGroup";
jest.mock("react-redux", () => ({ jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"), ...jest.requireActual("react-redux"),
@@ -15,10 +16,12 @@ jest.mock("react-redux", () => ({
useSelector: jest.fn(), useSelector: jest.fn(),
})); }));
describe("CreateGroup Component: ", () => {
var mockAsync = (result) => var mockAsync = (result) =>
jest.fn().mockImplementation(() => Promise.resolve(result)); jest.fn().mockImplementation(() => Promise.resolve(result));
var mockAsyncRejection = () =>
jest.fn().mockImplementation(() => Promise.reject());
var createGroupJsx = (callbackSpy) => ( var createGroupJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}> <Provider store={createStore(() => {}, {})}>
<HashRouter> <HashRouter>
@@ -48,19 +51,65 @@ describe("CreateGroup Component: ", () => {
useDispatch.mockClear(); useDispatch.mockClear();
}); });
it("Renders", () => { test("Renders", async () => {
let component = mount(createGroupJsx()); await act(async () => {
expect(component.find(".container").length).toBe(1); render(createGroupJsx());
});
expect(screen.getByTestId("container")).toBeVisible();
}); });
it("Calls createGroup on submit", () => { test("Calls createGroup on submit", async () => {
let callbackSpy = mockAsync({ status: 200 }), let callbackSpy = mockAsync({ status: 200 });
component = mount(createGroupJsx(callbackSpy)),
input = component.find("input").first(), await act(async () => {
submit = component.find("#submit").first(); render(createGroupJsx(callbackSpy));
input.simulate("change", { target: { value: "" } });
submit.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, "");
expect(component.find(".alert.alert-danger").length).toBe(0);
}); });
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();
}); });

View File

@@ -19,14 +19,7 @@ const EditUser = (props) => {
}); });
}; };
var { var { editUser, deleteUser, noChangeEvent, updateUsers, history } = props;
editUser,
deleteUser,
failRegexEvent,
noChangeEvent,
updateUsers,
history,
} = props;
if (props.location.state == undefined) { if (props.location.state == undefined) {
props.history.push("/"); props.history.push("/");
@@ -40,11 +33,20 @@ const EditUser = (props) => {
return ( return (
<> <>
<div className="container"> <div className="container" data-testid="container">
{errorAlert != null ? ( {errorAlert != null ? (
<div className="row"> <div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2"> <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>&times;</span>
</button>
</div>
</div> </div>
</div> </div>
) : ( ) : (
@@ -61,6 +63,7 @@ const EditUser = (props) => {
<div className="form-group"> <div className="form-group">
<textarea <textarea
className="form-control" className="form-control"
data-testid="edit-username-input"
id="exampleFormControlTextarea1" id="exampleFormControlTextarea1"
rows="3" rows="3"
placeholder="updated username" placeholder="updated username"
@@ -81,20 +84,26 @@ const EditUser = (props) => {
<br></br> <br></br>
<button <button
id="delete-user" id="delete-user"
data-testid="delete-user"
className="btn btn-danger btn-sm" className="btn btn-danger btn-sm"
onClick={() => { onClick={(e) => {
e.preventDefault();
deleteUser(username) deleteUser(username)
.then((data) => { .then((data) => {
data.status < 300 data.status < 300
? updateUsers(0, limit) ? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0)) .then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/")) .then(() => history.push("/"))
.catch((err) => console.log(err)) .catch(() =>
: setErrorAlert( setErrorAlert(
`[${data.status}] Failed to edit user.` `Could not update users list.`
); )
)
: setErrorAlert(`Failed to edit user.`);
}) })
.catch((err) => console.log(err)); .catch(() => {
setErrorAlert(`Failed to edit user.`);
});
}} }}
> >
Delete user Delete user
@@ -109,8 +118,10 @@ const EditUser = (props) => {
<span> </span> <span> </span>
<button <button
id="submit" id="submit"
data-testid="submit"
className="btn btn-primary" className="btn btn-primary"
onClick={() => { onClick={(e) => {
e.preventDefault();
if (updatedUsername == "" && admin == has_admin) { if (updatedUsername == "" && admin == has_admin) {
noChangeEvent(); noChangeEvent();
return; return;
@@ -129,17 +140,20 @@ const EditUser = (props) => {
? updateUsers(0, limit) ? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0)) .then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/")) .then(() => history.push("/"))
.catch((err) => console.log(err)) .catch(() =>
: setErrorAlert( setErrorAlert(
`[${data.status}] Failed to edit user.` `Could not update users list.`
); )
)
: setErrorAlert(`Failed to edit user.`);
}) })
.catch((err) => { .catch(() => {
console.log(err); setErrorAlert(`Failed to edit user.`);
}); });
} else { } else {
setUpdatedUsername(""); setErrorAlert(
failRegexEvent(); `Failed to edit user. Make sure the username does not contain special characters.`
);
} }
} else { } else {
editUser(username, username, admin) editUser(username, username, admin)
@@ -148,13 +162,13 @@ const EditUser = (props) => {
? updateUsers(0, limit) ? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0)) .then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/")) .then(() => history.push("/"))
.catch((err) => console.log(err)) .catch(() =>
: setErrorAlert( setErrorAlert(`Could not update users list.`)
`[${data.status}] Failed to edit user.` )
); : setErrorAlert(`Failed to edit user.`);
}) })
.catch((err) => { .catch(() => {
console.log(err); setErrorAlert(`Failed to edit user.`);
}); });
} }
}} }}

View File

@@ -1,12 +1,14 @@
import React from "react"; import React from "react";
import Enzyme, { mount } from "enzyme"; import "@testing-library/jest-dom";
import EditUser from "./EditUser"; import { act } from "react-dom/test-utils";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { render, screen, fireEvent } from "@testing-library/react";
import { Provider, useDispatch, useSelector } from "react-redux"; import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux"; import { createStore } from "redux";
import { HashRouter } from "react-router-dom"; 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.mock("react-redux", () => ({
...jest.requireActual("react-redux"), ...jest.requireActual("react-redux"),
@@ -14,20 +16,17 @@ jest.mock("react-redux", () => ({
useSelector: jest.fn(), useSelector: jest.fn(),
})); }));
describe("EditUser Component: ", () => { var mockAsync = (data) =>
var mockAsync = () => jest.fn().mockImplementation(() => Promise.resolve(data));
jest
.fn() var mockAsyncRejection = () =>
.mockImplementation(() => Promise.resolve({ key: "value", status: 200 })); jest.fn().mockImplementation(() => Promise.reject());
var mockSync = () => jest.fn();
var editUserJsx = (callbackSpy, empty) => ( var editUserJsx = (callbackSpy, empty) => (
<Provider store={createStore(() => {}, {})}> <Provider store={createStore(() => {}, {})}>
<HashRouter> <HashRouter>
<EditUser <EditUser
location={ location={empty ? {} : { state: { username: "foo", has_admin: false } }}
empty ? {} : { state: { username: "foo", has_admin: false } }
}
deleteUser={callbackSpy} deleteUser={callbackSpy}
editUser={callbackSpy} editUser={callbackSpy}
updateUsers={callbackSpy} updateUsers={callbackSpy}
@@ -56,25 +55,85 @@ describe("EditUser Component: ", () => {
useDispatch.mockClear(); useDispatch.mockClear();
}); });
it("Calls the delete user function when the button is pressed", () => { test("Renders", async () => {
let callbackSpy = mockAsync(), let callbackSpy = mockAsync({ key: "value", status: 200 });
component = mount(editUserJsx(callbackSpy)),
deleteUser = component.find("#delete-user"); await act(async () => {
deleteUser.simulate("click"); 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(); expect(callbackSpy).toHaveBeenCalled();
}); });
it("Submits the edits when the button is pressed", () => { test("Submits the edits when the button is pressed", async () => {
let callbackSpy = mockSync(), let callbackSpy = mockAsync({ key: "value", status: 200 });
component = mount(editUserJsx(callbackSpy)),
submit = component.find("#submit"); await act(async () => {
submit.simulate("click"); render(editUserJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
await act(async () => {
fireEvent.click(submit);
});
expect(callbackSpy).toHaveBeenCalled(); expect(callbackSpy).toHaveBeenCalled();
}); });
it("Doesn't render when no data is provided", () => { test("Shows a UI error dialogue when user edit fails", async () => {
let callbackSpy = mockSync(), let callbackSpy = mockAsyncRejection();
component = mount(editUserJsx(callbackSpy, true));
expect(component.find(".container").length).toBe(0); 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();
}); });

View File

@@ -7,6 +7,7 @@ import GroupSelect from "../GroupSelect/GroupSelect";
const GroupEdit = (props) => { const GroupEdit = (props) => {
var [selected, setSelected] = useState([]), var [selected, setSelected] = useState([]),
[changed, setChanged] = useState(false), [changed, setChanged] = useState(false),
[errorAlert, setErrorAlert] = useState(null),
limit = useSelector((state) => state.limit); limit = useSelector((state) => state.limit);
var dispatch = useDispatch(); var dispatch = useDispatch();
@@ -41,7 +42,25 @@ const GroupEdit = (props) => {
if (!group_data) return <div></div>; if (!group_data) return <div></div>;
return ( 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>&times;</span>
</button>
</div>
</div>
</div>
) : (
<></>
)}
<div className="row"> <div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2"> <div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<h3>Editing Group {group_data.name}</h3> <h3>Editing Group {group_data.name}</h3>
@@ -65,6 +84,7 @@ const GroupEdit = (props) => {
<span> </span> <span> </span>
<button <button
id="submit" id="submit"
data-testid="submit"
className="btn btn-primary" className="btn btn-primary"
onClick={() => { onClick={() => {
// check for changes // check for changes
@@ -89,29 +109,43 @@ const GroupEdit = (props) => {
); );
Promise.all(promiseQueue) Promise.all(promiseQueue)
.then(() => { .then((data) => {
updateGroups(0, limit) // 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((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups")); .then(() => history.push("/groups"))
: setErrorAlert(`Failed to edit group.`);
}) })
.catch((err) => console.log(err)); .catch(() => {
console.log("outer");
setErrorAlert(`Failed to edit group.`);
});
}} }}
> >
Apply Apply
</button> </button>
<button <button
id="delete-group" id="delete-group"
data-testid="delete-group"
className="btn btn-danger" className="btn btn-danger"
style={{ float: "right" }} style={{ float: "right" }}
onClick={() => { onClick={() => {
var groupName = group_data.name; var groupName = group_data.name;
deleteGroup(groupName) deleteGroup(groupName)
.then(() => { // TODO add error if res not ok
updateGroups(0, limit) .then((data) => {
data.status < 300
? updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0)) .then((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups")); .then(() => history.push("/groups"))
: setErrorAlert(`Failed to delete group.`);
}) })
.catch((err) => console.log(err)); .catch(() => setErrorAlert(`Failed to delete group.`));
}} }}
> >
Delete Group Delete Group

View File

@@ -1,22 +1,26 @@
import React from "react"; import React from "react";
import Enzyme, { mount } from "enzyme"; import "@testing-library/jest-dom";
import GroupEdit from "./GroupEdit"; import { act } from "react-dom/test-utils";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider, useSelector } from "react-redux"; import { Provider, useSelector } from "react-redux";
import { createStore } from "redux"; import { createStore } from "redux";
import { HashRouter } from "react-router-dom"; import { HashRouter } from "react-router-dom";
import { act } from "react-dom/test-utils"; // eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() }); import GroupEdit from "./GroupEdit";
jest.mock("react-redux", () => ({ jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"), ...jest.requireActual("react-redux"),
useSelector: jest.fn(), useSelector: jest.fn(),
})); }));
describe("GroupEdit Component: ", () => { var mockAsync = (data) =>
var mockAsync = () => jest.fn().mockImplementation(() => Promise.resolve()); jest.fn().mockImplementation(() => Promise.resolve(data));
var mockAsyncRejection = () =>
jest.fn().mockImplementation(() => Promise.reject());
var okPacket = new Promise((resolve) => resolve(true)); var okPacket = new Promise((resolve) => resolve(true));
@@ -55,46 +59,170 @@ describe("GroupEdit Component: ", () => {
useSelector.mockClear(); useSelector.mockClear();
}); });
it("Adds user from input to user selectables on button click", async () => { test("Renders", async () => {
let callbackSpy = mockAsync(), let callbackSpy = mockAsync();
component = mount(groupEditJsx(callbackSpy)),
input = component.find("#username-input"), await act(async () => {
validateUser = component.find("#validate-user"), render(groupEditJsx(callbackSpy));
submit = component.find("#submit"); });
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);
});
input.simulate("change", { target: { value: "bar" } });
validateUser.simulate("click");
await act(() => okPacket);
submit.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["bar"], "group"); expect(callbackSpy).toHaveBeenNthCalledWith(1, ["bar"], "group");
}); });
it("Removes a user recently added from input from the selectables list", () => { test("Removes a user recently added from input from the selectables list", async () => {
let callbackSpy = mockAsync(), let callbackSpy = mockAsync();
component = mount(groupEditJsx(callbackSpy)),
unsubmittedUser = component.find(".item.selected").last(); await act(async () => {
unsubmittedUser.simulate("click"); render(groupEditJsx(callbackSpy));
expect(component.find(".item").length).toBe(1);
}); });
it("Grays out a user, already in the group, when unselected and calls deleteUser on submit", () => { let selectedUser = screen.getByText("foo");
let callbackSpy = mockAsync(), fireEvent.click(selectedUser);
component = mount(groupEditJsx(callbackSpy)),
groupUser = component.find(".item.selected").first(); let unselectedUser = screen.getByText("foo");
groupUser.simulate("click");
expect(component.find(".item.unselected").length).toBe(1); expect(unselectedUser.className).toBe("item unselected");
expect(component.find(".item").length).toBe(1); });
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 // test deleteUser call
let submit = component.find("#submit"); await act(async () => {
submit.simulate("click"); fireEvent.click(submit);
});
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["foo"], "group"); expect(callbackSpy).toHaveBeenNthCalledWith(1, ["foo"], "group");
}); });
it("Calls deleteGroup on button click", () => { test("Calls deleteGroup on button click", async () => {
let callbackSpy = mockAsync(), let callbackSpy = mockAsync();
component = mount(groupEditJsx(callbackSpy)),
deleteGroup = component.find("#delete-group").first(); await act(async () => {
deleteGroup.simulate("click"); render(groupEditJsx(callbackSpy));
});
let deleteGroup = screen.getByTestId("delete-group");
await act(async () => {
fireEvent.click(deleteGroup);
});
expect(callbackSpy).toHaveBeenNthCalledWith(1, "group"); 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();
}); });

View File

@@ -24,6 +24,7 @@ const GroupSelect = (props) => {
<div className="input-group"> <div className="input-group">
<input <input
id="username-input" id="username-input"
data-testid="username-input"
type="text" type="text"
className="form-control" className="form-control"
placeholder="Add by username" placeholder="Add by username"
@@ -35,6 +36,7 @@ const GroupSelect = (props) => {
<span className="input-group-btn"> <span className="input-group-btn">
<button <button
id="validate-user" id="validate-user"
data-testid="validate-user"
className="btn btn-default" className="btn btn-default"
type="button" type="button"
onClick={() => { onClick={() => {

View File

@@ -19,7 +19,7 @@ const Groups = (props) => {
var { updateGroups, history } = props; var { updateGroups, history } = props;
if (!groups_data || !user_data) { if (!groups_data || !user_data) {
return <div></div>; return <div data-testid="no-show"></div>;
} }
const dispatchPageChange = (data, page) => { const dispatchPageChange = (data, page) => {
@@ -39,7 +39,7 @@ const Groups = (props) => {
} }
return ( return (
<div className="container"> <div className="container" data-testid="container">
<div className="row"> <div className="row">
<div className="col-md-12 col-lg-10 col-lg-offset-1"> <div className="col-md-12 col-lg-10 col-lg-offset-1">
<div className="panel panel-default"> <div className="panel panel-default">

View File

@@ -1,12 +1,14 @@
import React from "react"; import React from "react";
import Enzyme, { mount } from "enzyme"; import "@testing-library/jest-dom";
import Groups from "./Groups"; import { act } from "react-dom/test-utils";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { render, screen } from "@testing-library/react";
import { Provider, useDispatch, useSelector } from "react-redux"; import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux"; import { createStore } from "redux";
import { HashRouter } from "react-router-dom"; 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.mock("react-redux", () => ({
...jest.requireActual("react-redux"), ...jest.requireActual("react-redux"),
@@ -14,7 +16,6 @@ jest.mock("react-redux", () => ({
useDispatch: jest.fn(), useDispatch: jest.fn(),
})); }));
describe("Groups Component: ", () => {
var mockAsync = () => var mockAsync = () =>
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" })); jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
@@ -33,6 +34,7 @@ describe("Groups Component: ", () => {
groups_data: JSON.parse( groups_data: JSON.parse(
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]' '[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
), ),
limit: 10,
}); });
beforeEach(() => { beforeEach(() => {
@@ -48,18 +50,41 @@ describe("Groups Component: ", () => {
useSelector.mockClear(); useSelector.mockClear();
}); });
it("Renders groups_data prop into links", () => { test("Renders", async () => {
let callbackSpy = mockAsync(), let callbackSpy = mockAsync();
component = mount(groupsJsx(callbackSpy)),
links = component.find("li"); await act(async () => {
expect(links.length).toBe(2); render(groupsJsx(callbackSpy));
}); });
it("Renders nothing if required data is not available", () => { 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) => { useSelector.mockImplementation((callback) => {
return callback({}); return callback({});
}); });
let component = mount(groupsJsx());
expect(component.html()).toBe("<div></div>"); let callbackSpy = mockAsync();
await act(async () => {
render(groupsJsx(callbackSpy));
}); });
let noShow = screen.getByTestId("no-show");
expect(noShow).toBeVisible();
}); });

View File

@@ -27,6 +27,7 @@ const ServerDashboard = (props) => {
runningAsc = (e) => e.sort((a) => (a.server == null ? -1 : 1)), runningAsc = (e) => e.sort((a) => (a.server == null ? -1 : 1)),
runningDesc = (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 [sortMethod, setSortMethod] = useState(null);
var user_data = useSelector((state) => state.user_data), var user_data = useSelector((state) => state.user_data),
@@ -60,7 +61,7 @@ const ServerDashboard = (props) => {
}; };
if (!user_data) { if (!user_data) {
return <div></div>; return <div data-testid="no-show"></div>;
} }
if (page != user_page) { if (page != user_page) {
@@ -72,7 +73,25 @@ const ServerDashboard = (props) => {
} }
return ( 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>&times;</span>
</button>
</div>
</div>
</div>
) : (
<></>
)}
<div className="manage-groups" style={{ float: "right", margin: "20px" }}> <div className="manage-groups" style={{ float: "right", margin: "20px" }}>
<Link to="/groups">{"> Manage Groups"}</Link> <Link to="/groups">{"> Manage Groups"}</Link>
</div> </div>
@@ -85,6 +104,7 @@ const ServerDashboard = (props) => {
<SortHandler <SortHandler
sorts={{ asc: usernameAsc, desc: usernameDesc }} sorts={{ asc: usernameAsc, desc: usernameDesc }}
callback={(method) => setSortMethod(() => method)} callback={(method) => setSortMethod(() => method)}
testid="user-sort"
/> />
</th> </th>
<th id="admin-header"> <th id="admin-header">
@@ -92,6 +112,7 @@ const ServerDashboard = (props) => {
<SortHandler <SortHandler
sorts={{ asc: adminAsc, desc: adminDesc }} sorts={{ asc: adminAsc, desc: adminDesc }}
callback={(method) => setSortMethod(() => method)} callback={(method) => setSortMethod(() => method)}
testid="admin-sort"
/> />
</th> </th>
<th id="last-activity-header"> <th id="last-activity-header">
@@ -99,6 +120,7 @@ const ServerDashboard = (props) => {
<SortHandler <SortHandler
sorts={{ asc: dateAsc, desc: dateDesc }} sorts={{ asc: dateAsc, desc: dateDesc }}
callback={(method) => setSortMethod(() => method)} callback={(method) => setSortMethod(() => method)}
testid="last-activity-sort"
/> />
</th> </th>
<th id="running-status-header"> <th id="running-status-header">
@@ -106,6 +128,7 @@ const ServerDashboard = (props) => {
<SortHandler <SortHandler
sorts={{ asc: runningAsc, desc: runningDesc }} sorts={{ asc: runningAsc, desc: runningDesc }}
callback={(method) => setSortMethod(() => method)} callback={(method) => setSortMethod(() => method)}
testid="running-status-sort"
/> />
</th> </th>
<th id="actions-header">Actions</th> <th id="actions-header">Actions</th>
@@ -125,17 +148,33 @@ const ServerDashboard = (props) => {
<Button <Button
variant="primary" variant="primary"
className="start-all" className="start-all"
data-testid="start-all"
onClick={() => { onClick={() => {
Promise.all(startAll(user_data.map((e) => e.name))) 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) => { .then((res) => {
updateUsers(...slice) updateUsers(...slice)
.then((data) => { .then((data) => {
dispatchPageUpdate(data, page); dispatchPageUpdate(data, page);
}) })
.catch((err) => console.log(err)); .catch(() =>
setErrorAlert(`Failed to update users list.`)
);
return res; return res;
}) })
.catch((err) => console.log(err)); .catch(() => setErrorAlert(`Failed to start servers.`));
}} }}
> >
Start All Start All
@@ -145,17 +184,33 @@ const ServerDashboard = (props) => {
<Button <Button
variant="danger" variant="danger"
className="stop-all" className="stop-all"
data-testid="stop-all"
onClick={() => { onClick={() => {
Promise.all(stopAll(user_data.map((e) => e.name))) 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) => { .then((res) => {
updateUsers(...slice) updateUsers(...slice)
.then((data) => { .then((data) => {
dispatchPageUpdate(data, page); dispatchPageUpdate(data, page);
}) })
.catch((err) => console.log(err)); .catch(() =>
setErrorAlert(`Failed to update users list.`)
);
return res; return res;
}) })
.catch((err) => console.log(err)); .catch(() => setErrorAlert(`Failed to stop servers.`));
}} }}
> >
Stop All Stop All
@@ -174,12 +229,12 @@ const ServerDashboard = (props) => {
</tr> </tr>
{user_data.map((e, i) => ( {user_data.map((e, i) => (
<tr key={i + "row"} className="user-row"> <tr key={i + "row"} className="user-row">
<td>{e.name}</td> <td data-testid="user-row-name">{e.name}</td>
<td>{e.admin ? "admin" : ""}</td> <td data-testid="user-row-admin">{e.admin ? "admin" : ""}</td>
<td> <td data-testid="user-row-last-activity">
{e.last_activity ? timeSince(e.last_activity) : "Never"} {e.last_activity ? timeSince(e.last_activity) : "Never"}
</td> </td>
<td> <td data-testid="user-row-server-activity">
{e.server != null ? ( {e.server != null ? (
// Stop Single-user server // Stop Single-user server
<button <button
@@ -187,12 +242,20 @@ const ServerDashboard = (props) => {
onClick={() => onClick={() =>
stopServer(e.name) stopServer(e.name)
.then((res) => { .then((res) => {
updateUsers(...slice).then((data) => { if (res.status < 300) {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page); dispatchPageUpdate(data, page);
}); })
.catch(() =>
setErrorAlert(`Failed to update users list.`)
);
} else {
setErrorAlert(`Failed to stop server.`);
}
return res; return res;
}) })
.catch((err) => console.log(err)) .catch(() => setErrorAlert(`Failed to stop server.`))
} }
> >
Stop Server Stop Server
@@ -204,12 +267,22 @@ const ServerDashboard = (props) => {
onClick={() => onClick={() =>
startServer(e.name) startServer(e.name)
.then((res) => { .then((res) => {
updateUsers(...slice).then((data) => { if (res.status < 300) {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page); dispatchPageUpdate(data, page);
}); })
.catch(() =>
setErrorAlert(`Failed to update users list.`)
);
} else {
setErrorAlert(`Failed to start server.`);
}
return res; return res;
}) })
.catch((err) => console.log(err)) .catch(() => {
setErrorAlert(`Failed to start server.`);
})
} }
> >
Start Server Start Server
@@ -269,13 +342,14 @@ ServerDashboard.propTypes = {
}; };
const SortHandler = (props) => { const SortHandler = (props) => {
var { sorts, callback } = props; var { sorts, callback, testid } = props;
var [direction, setDirection] = useState(undefined); var [direction, setDirection] = useState(undefined);
return ( return (
<div <div
className="sort-icon" className="sort-icon"
data-testid={testid}
onClick={() => { onClick={() => {
if (!direction) { if (!direction) {
callback(sorts.desc); callback(sorts.desc);
@@ -303,6 +377,7 @@ const SortHandler = (props) => {
SortHandler.propTypes = { SortHandler.propTypes = {
sorts: PropTypes.object, sorts: PropTypes.object,
callback: PropTypes.func, callback: PropTypes.func,
testid: PropTypes.string,
}; };
export default ServerDashboard; export default ServerDashboard;

View File

@@ -1,42 +1,42 @@
import React from "react"; import React from "react";
import Enzyme, { mount } from "enzyme"; import "@testing-library/jest-dom";
import ServerDashboard from "./ServerDashboard"; import { act } from "react-dom/test-utils";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { render, screen, fireEvent } from "@testing-library/react";
import { HashRouter, Switch } from "react-router-dom"; import { HashRouter, Switch } from "react-router-dom";
import { Provider, useSelector } from "react-redux"; import { Provider, useSelector } from "react-redux";
import { createStore } from "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.mock("react-redux", () => ({
...jest.requireActual("react-redux"), ...jest.requireActual("react-redux"),
useSelector: jest.fn(), useSelector: jest.fn(),
})); }));
describe("ServerDashboard Component: ", () => { var serverDashboardJsx = (spy) => (
var serverDashboardJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}> <Provider store={createStore(() => {}, {})}>
<HashRouter> <HashRouter>
<Switch> <Switch>
<ServerDashboard <ServerDashboard
updateUsers={callbackSpy} updateUsers={spy}
shutdownHub={callbackSpy} shutdownHub={spy}
startServer={callbackSpy} startServer={spy}
stopServer={callbackSpy} stopServer={spy}
startAll={callbackSpy} startAll={spy}
stopAll={callbackSpy} stopAll={spy}
/> />
</Switch> </Switch>
</HashRouter> </HashRouter>
</Provider> </Provider>
); );
var mockAsync = () => var mockAsync = (data) =>
jest jest.fn().mockImplementation(() => Promise.resolve(data ? data : { k: "v" }));
.fn()
.mockImplementation(() => var mockAsyncRejection = () =>
Promise.resolve({ json: () => Promise.resolve({ k: "v" }) }) jest.fn().mockImplementation(() => Promise.reject());
);
var mockAppState = () => ({ var mockAppState = () => ({
user_data: JSON.parse( user_data: JSON.parse(
@@ -54,108 +54,384 @@ describe("ServerDashboard Component: ", () => {
useSelector.mockClear(); useSelector.mockClear();
}); });
it("Renders users from props.user_data into table", () => { test("Renders", async () => {
let component = mount(serverDashboardJsx(mockAsync())), let callbackSpy = mockAsync();
userRows = component.find(".user-row");
expect(userRows.length).toBe(2); await act(async () => {
render(serverDashboardJsx(callbackSpy));
}); });
it("Renders correctly the status of a single-user server", () => { expect(screen.getByTestId("container")).toBeVisible();
let component = mount(serverDashboardJsx(mockAsync())), });
userRows = component.find(".user-row");
// Renders .stop-button when server is started test("Renders users from props.user_data into table", async () => {
// Should be 1 since user foo is started let callbackSpy = mockAsync();
expect(userRows.at(0).find(".stop-button").length).toBe(1);
// Renders .start-button when server is stopped await act(async () => {
// Should be 1 since user bar is stopped render(serverDashboardJsx(callbackSpy));
expect(userRows.at(1).find(".start-button").length).toBe(1); });
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);
}); });
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(); expect(callbackSpy).toHaveBeenCalled();
}); });
it("Invokes the stopServer event on button click", () => { test("Invokes the stopServer event on button click", async () => {
let callbackSpy = mockAsync(), let callbackSpy = mockAsync();
component = mount(serverDashboardJsx(callbackSpy)),
stopBtn = component.find(".stop-button"); await act(async () => {
stopBtn.simulate("click"); render(serverDashboardJsx(callbackSpy));
});
let stop = screen.getByText("Stop Server");
await act(async () => {
fireEvent.click(stop);
});
expect(callbackSpy).toHaveBeenCalled(); expect(callbackSpy).toHaveBeenCalled();
}); });
it("Invokes the shutdownHub event on button click", () => { test("Invokes the shutdownHub event on button click", async () => {
let callbackSpy = mockAsync(), let callbackSpy = mockAsync();
component = mount(serverDashboardJsx(callbackSpy)),
shutdownBtn = component.find("#shutdown-button").first(); await act(async () => {
shutdownBtn.simulate("click"); render(serverDashboardJsx(callbackSpy));
});
let shutdown = screen.getByText("Shutdown Hub");
await act(async () => {
fireEvent.click(shutdown);
});
expect(callbackSpy).toHaveBeenCalled(); expect(callbackSpy).toHaveBeenCalled();
}); });
it("Sorts according to username", () => { test("Sorts according to username", async () => {
let component = mount(serverDashboardJsx(mockAsync())).find( let callbackSpy = mockAsync();
"ServerDashboard"
), await act(async () => {
handler = component.find("SortHandler").first(); render(serverDashboardJsx(callbackSpy));
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 handler = screen.getByTestId("user-sort");
let component = mount(serverDashboardJsx(mockAsync())).find( fireEvent.click(handler);
"ServerDashboard"
), let first = screen.getAllByTestId("user-row-name")[0];
handler = component.find("SortHandler").at(1); expect(first.textContent).toBe("bar");
handler.simulate("click");
let first = component.find(".user-row").first(); fireEvent.click(handler);
expect(first.html().includes("admin")).toBe(true);
handler.simulate("click"); first = screen.getAllByTestId("user-row-name")[0];
first = component.find(".user-row").first(); expect(first.textContent).toBe("foo");
expect(first.html().includes("admin")).toBe(false);
}); });
it("Sorts according to last activity", () => { test("Sorts according to admin", async () => {
let component = mount(serverDashboardJsx(mockAsync())).find( let callbackSpy = mockAsync();
"ServerDashboard"
), await act(async () => {
handler = component.find("SortHandler").at(2); render(serverDashboardJsx(callbackSpy));
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 handler = screen.getByTestId("admin-sort");
let component = mount(serverDashboardJsx(mockAsync())).find( fireEvent.click(handler);
"ServerDashboard"
), let first = screen.getAllByTestId("user-row-admin")[0];
handler = component.find("SortHandler").at(3); expect(first.textContent).toBe("admin");
handler.simulate("click");
let first = component.find(".user-row").first(); fireEvent.click(handler);
// foo running
expect(first.html().includes("foo")).toBe(true); first = screen.getAllByTestId("user-row-admin")[0];
handler.simulate("click"); expect(first.textContent).toBe("");
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", () => { 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) => { useSelector.mockImplementation((callback) => {
return callback({}); return callback({});
}); });
let component = mount(serverDashboardJsx(jest.fn()));
expect(component.html()).toBe("<div></div>"); 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();
}); });

View File

@@ -1,5 +1,7 @@
export const jhapiRequest = (endpoint, method, data) => { 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, method: method,
json: true, json: true,
headers: { headers: {

View File

@@ -36,13 +36,14 @@ const withAPI = withProps(() => ({
jhapiRequest("/users/" + username, "GET") jhapiRequest("/users/" + username, "GET")
.then((data) => data.status) .then((data) => data.status)
.then((data) => (data > 200 ? false : true)), .then((data) => (data > 200 ? false : true)),
failRegexEvent: () => // Temporarily Unused
alert( failRegexEvent: () => {
"Cannot change username - either contains special characters or is too short." return null;
),
noChangeEvent: () => {
returns;
}, },
noChangeEvent: () => {
return null;
},
//
refreshGroupsData: () => refreshGroupsData: () =>
jhapiRequest("/groups", "GET").then((data) => data.json()), jhapiRequest("/groups", "GET").then((data) => data.json()),
refreshUserData: () => refreshUserData: () =>

View File

@@ -935,6 +935,14 @@
"@babel/plugin-transform-react-jsx-development" "^7.12.7" "@babel/plugin-transform-react-jsx-development" "^7.12.7"
"@babel/plugin-transform-react-pure-annotations" "^7.12.1" "@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": "@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" version "7.12.5"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e"
@@ -942,6 +950,13 @@
dependencies: dependencies:
regenerator-runtime "^0.13.4" 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": "@babel/template@^7.10.4":
version "7.10.4" version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278"
@@ -1224,6 +1239,17 @@
"@types/yargs" "^15.0.0" "@types/yargs" "^15.0.0"
chalk "^4.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": "@popperjs/core@^2.5.3":
version "2.5.4" version "2.5.4"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.5.4.tgz#de25b5da9f727985a3757fd59b5d028aba75841a" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.5.4.tgz#de25b5da9f727985a3757fd59b5d028aba75841a"
@@ -1256,6 +1282,55 @@
dependencies: dependencies:
"@sinonjs/commons" "^1.7.0" "@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": "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
version "7.1.12" version "7.1.12"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.12.tgz#4d8e9e51eb265552a7e4f1ff2219ab6133bdfb2d" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.12.tgz#4d8e9e51eb265552a7e4f1ff2219ab6133bdfb2d"
@@ -1354,6 +1429,14 @@
dependencies: dependencies:
"@types/istanbul-lib-report" "*" "@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": "@types/json-schema@*", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6":
version "7.0.6" version "7.0.6"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0" 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" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff"
integrity sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw== 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": "@types/warning@^3.0.0":
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.0.tgz#0d2501268ad8f9962b740d387c4654f5f8e23e52" resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.0.tgz#0d2501268ad8f9962b740d387c4654f5f8e23e52"
@@ -1428,6 +1518,13 @@
dependencies: dependencies:
"@types/yargs-parser" "*" "@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": "@webassemblyjs/ast@1.9.0":
version "1.9.0" version "1.9.0"
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964"
@@ -1573,20 +1670,30 @@
"@webassemblyjs/wast-parser" "1.9.0" "@webassemblyjs/wast-parser" "1.9.0"
"@xtuc/long" "4.2.2" "@xtuc/long" "4.2.2"
"@wojtekmaj/enzyme-adapter-react-17@^0.4.1": "@wojtekmaj/enzyme-adapter-react-17@^0.6.5":
version "0.4.1" version "0.6.5"
resolved "https://registry.yarnpkg.com/@wojtekmaj/enzyme-adapter-react-17/-/enzyme-adapter-react-17-0.4.1.tgz#a9d4a2873025c6de19e1142ca076661bac69f587" resolved "https://registry.yarnpkg.com/@wojtekmaj/enzyme-adapter-react-17/-/enzyme-adapter-react-17-0.6.5.tgz#1925e17aaea7089e7ec66c7c35e5771e49b6bf7e"
integrity sha512-WZr8i4C6WVDV7Mb8sbm7GdlEPmk1f+xOMjUKThqrkWgwsfvu90zJyyX54wyAvsS91sjtKZ0JipGj2cJnEDaxPA== integrity sha512-ChIObUiXXYUiqzXPqOai+p6KF5dlbItpDDYsftUOQiAiygbMDlLeJIjynC6ZrJIa2U2MpRp4YJmtR2GQyIHjgA==
dependencies: dependencies:
enzyme-adapter-utils "^1.14.0" "@wojtekmaj/enzyme-adapter-utils" "^0.1.1"
enzyme-shallow-equal "^1.0.4" enzyme-shallow-equal "^1.0.0"
has "^1.0.3" has "^1.0.0"
object.assign "^4.1.0" object.assign "^4.1.0"
object.values "^1.1.1" object.values "^1.1.0"
prop-types "^15.7.2" prop-types "^15.7.0"
react-is "^17.0.0" react-is "^17.0.2"
react-test-renderer "^17.0.0" 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": "@xtuc/ieee754@^1.2.0":
version "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" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.0.4.tgz#7a3ae4191466a6984eee0fe3407a4f3aa9db8354"
integrity sha512-XNP0PqF1XD19ZlLKvB7cMmnZswW4C/03pRHgirB30uSJTaS3A3V1/P4sS3HPvFmjoriPCJQs+JDSbm4bL1TxGQ== 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: ajv-errors@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d" 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" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75"
integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== 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: ansi-styles@^3.2.0, ansi-styles@^3.2.1:
version "3.2.1" version "3.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" 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: dependencies:
color-convert "^2.0.1" 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: anymatch@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb"
@@ -1758,6 +1860,19 @@ argparse@^1.0.7:
dependencies: dependencies:
sprintf-js "~1.0.2" 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: arr-diff@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" 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" resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= 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: array.prototype.flat@^1.2.3:
version "1.2.4" version "1.2.4"
resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz#6ef638b43312bd401b4c6199fdec7e2dc9e9a123" 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" escape-string-regexp "^1.0.5"
supports-color "^5.3.0" 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: chalk@^4.0.0:
version "4.1.0" version "4.1.0"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" 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" ansi-styles "^4.1.0"
supports-color "^7.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: change-emitter@^0.1.2:
version "0.1.6" version "0.1.6"
resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515" 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" browserslist "^4.16.0"
semver "7.0.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: core-js@^1.0.0:
version "1.2.7" version "1.2.7"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" 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" resolved "https://registry.yarnpkg.com/css-what/-/css-what-4.0.0.tgz#35e73761cab2eeb3d3661126b23d7aa0e8432233"
integrity sha512-teijzG7kwYfNVsUh2H/YN62xW3KK9YhXEgSlbxMlcyjPNvdKJqFx5lrwlJgoFP1ZHlB89iGDlo/JyshKeRhv5A== 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: cssesc@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" 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" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1"
integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q== 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: discontinuous-range@1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a" resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a"
@@ -2758,6 +2905,11 @@ doctrine@^3.0.0:
dependencies: dependencies:
esutils "^2.0.2" 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: dom-helpers@^5.0.1, dom-helpers@^5.1.2, dom-helpers@^5.2.0:
version "5.2.0" version "5.2.0"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.0.tgz#57fd054c5f8f34c52a3eeffdb7e7e93cd357d95b" 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" resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5"
integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==
enzyme-adapter-utils@^1.14.0: enzyme-shallow-equal@^1.0.0, enzyme-shallow-equal@^1.0.1:
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:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.4.tgz#b9256cb25a5f430f9bfe073a84808c1d74fced2e" resolved "https://registry.yarnpkg.com/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.4.tgz#b9256cb25a5f430f9bfe073a84808c1d74fced2e"
integrity sha512-MttIwB8kKxypwHvRynuC3ahyNc+cFbR8mjVIltnmzQ0uKGqmsfO4bfBuLxb0beLNPhjblUEYvEbsg+VSygvF1Q== integrity sha512-MttIwB8kKxypwHvRynuC3ahyNc+cFbR8mjVIltnmzQ0uKGqmsfO4bfBuLxb0beLNPhjblUEYvEbsg+VSygvF1Q==
@@ -2952,7 +3091,7 @@ error-ex@^1.3.1:
dependencies: dependencies:
is-arrayish "^0.2.1" 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" version "1.17.7"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c"
integrity sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g== 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.trimend "^1.0.1"
string.prototype.trimstart "^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: es-to-primitive@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a"
@@ -3562,7 +3727,17 @@ function-bind@^1.1.1:
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== 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" version "1.1.3"
resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.3.tgz#0bb034bb308e7682826f215eb6b2ae64918847fe" resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.3.tgz#0bb034bb308e7682826f215eb6b2ae64918847fe"
integrity sha512-H51qkbNSp8mtkJt+nyW1gyStBiKZxfRqySNUR99ylq6BPXHKI4SEvIlTKp4odLfjRKJV04DFWMU3G/YRlQOsag== 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" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= 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" version "1.2.2"
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.2.tgz#98d93991c39da9361f8e50b337c4f6e41f120e21" resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.2.tgz#98d93991c39da9361f8e50b337c4f6e41f120e21"
integrity sha512-bLgc3asbWdwPbx2mNk2S49kmJCuQeu0nfmaOgbs8WIyzzkw3r4htszdIi9Q9EMezDPTYuJx2wvjZ/EwgAthpnA== integrity sha512-bLgc3asbWdwPbx2mNk2S49kmJCuQeu0nfmaOgbs8WIyzzkw3r4htszdIi9Q9EMezDPTYuJx2wvjZ/EwgAthpnA==
@@ -3610,6 +3785,15 @@ get-intrinsic@^1.0.1, get-intrinsic@^1.0.2:
has "^1.0.3" has "^1.0.3"
has-symbols "^1.0.1" 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: get-package-type@^0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" 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: dependencies:
pump "^3.0.0" 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: get-value@^2.0.3, get-value@^2.0.6:
version "2.0.6" version "2.0.6"
resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" 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" resolved "https://registry.yarnpkg.com/harmony-reflect/-/harmony-reflect-1.6.1.tgz#c108d4f2bb451efef7a37861fdbdae72c9bdefa9"
integrity sha512-WJTeyp0JzGtHcuMsi7rw2VwtkvLa+JyfEKJCFyfcS0+CDkjQ5lHPu7zEhFZP+PDSRrEgXa5Ah0l1MbgbE41XjA== 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: has-flag@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" 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" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== 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: has-value@^0.3.1:
version "0.3.1" version "0.3.1"
resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" 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" is-number "^3.0.0"
kind-of "^4.0.0" kind-of "^4.0.0"
has@^1.0.3: has@^1.0.0, has@^1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== 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" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= 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: indexes-of@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607" 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" once "^1.3.0"
wrappy "1" 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" version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -4090,6 +4304,15 @@ internal-slot@^1.0.2:
has "^1.0.3" has "^1.0.3"
side-channel "^1.0.2" 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: interpret@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" 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" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= 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: is-binary-path@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" 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: dependencies:
call-bind "^1.0.0" 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: is-buffer@^1.1.5:
version "1.1.6" version "1.1.6"
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" 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" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9"
integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== 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: is-ci@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" 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" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.0.tgz#9553b121b0fac28869da9ed459e20c7543788461"
integrity sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE= 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: is-number-object@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197" 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" resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397"
integrity sha1-DFLlS8yjkbssSUsh6GJtczbG45c= 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" version "1.1.1"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9"
integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg== integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==
dependencies: dependencies:
has-symbols "^1.0.1" 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: is-stream@^1.0.1, is-stream@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" 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" resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6"
integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ== 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: is-subset@^0.1.1:
version "0.1.1" version "0.1.1"
resolved "https://registry.yarnpkg.com/is-subset/-/is-subset-0.1.1.tgz#8a59117d932de1de00f245fcdd39ce43f1e939a6" resolved "https://registry.yarnpkg.com/is-subset/-/is-subset-0.1.1.tgz#8a59117d932de1de00f245fcdd39ce43f1e939a6"
@@ -4359,11 +4627,25 @@ is-symbol@^1.0.2:
dependencies: dependencies:
has-symbols "^1.0.1" 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: is-typedarray@^1.0.0, is-typedarray@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= 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: is-windows@^1.0.1, is-windows@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" 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" jest-get-type "^26.3.0"
pretty-format "^26.6.2" 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: jest-docblock@^26.0.0:
version "26.0.0" version "26.0.0"
resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-26.0.0.tgz#3e2fa20899fc928cb13bd0ff68bd3711a36889b5" 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" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0"
integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig== 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: jest-haste-map@^26.6.2:
version "26.6.2" version "26.6.2"
resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.6.2.tgz#dd7e60fe7dc0e9f911a23d79c5ff7fb5c2cafeaa" 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: dependencies:
yallist "^4.0.0" 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: make-dir@^2.0.0, make-dir@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" 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" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== 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: mini-create-react-context@^0.4.0:
version "0.4.1" version "0.4.1"
resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz#072171561bfdc922da08a60c2197a497cc2d1d5e" resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz#072171561bfdc922da08a60c2197a497cc2d1d5e"
@@ -5487,6 +5794,11 @@ object-copy@^0.1.0:
define-property "^0.2.5" define-property "^0.2.5"
kind-of "^3.0.3" 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: object-inspect@^1.7.0, object-inspect@^1.9.0:
version "1.9.0" version "1.9.0"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.9.0.tgz#c90521d74e1127b67266ded3394ad6116986533a" 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" es-abstract "^1.18.0-next.1"
has "^1.0.3" 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" version "2.0.3"
resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.3.tgz#13cefcffa702dc67750314a3305e8cb3fad1d072" resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.3.tgz#13cefcffa702dc67750314a3305e8cb3fad1d072"
integrity sha512-IDUSMXs6LOSJBWE++L0lzIbSqHl9KDCfff2x/JSEIDtEUavUnyMYC2ZGay/04Zq4UT8lvd4xNhU4/YHKibAOlw== integrity sha512-IDUSMXs6LOSJBWE++L0lzIbSqHl9KDCfff2x/JSEIDtEUavUnyMYC2ZGay/04Zq4UT8lvd4xNhU4/YHKibAOlw==
@@ -5562,6 +5883,15 @@ object.pick@^1.3.0:
dependencies: dependencies:
isobject "^3.0.1" 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: object.values@^1.1.1:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.2.tgz#7a2015e06fcb0f546bd652486ce8583a4731c731" 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" ansi-styles "^4.0.0"
react-is "^17.0.1" 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: process-nextick-args@~2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" 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" kleur "^3.0.3"
sisteransi "^1.0.5" 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: prop-types-extra@^1.1.0:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/prop-types-extra/-/prop-types-extra-1.1.1.tgz#58c3b74cbfbb95d304625975aa2f0848329a010b" 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" react-is "^16.3.2"
warning "^4.0.0" 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" version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== 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" resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.1.0.tgz#9ca9bcbf2e3aee8e86e378bb9d465842947bbfc3"
integrity sha512-FCXBg1JbbR0vWALXIxmFAfozHdVIJmmwCD81Jk0EKOt7Ax4AdBNcaRkWhR0NaKy9ugJgoY3fFvo0PHpte55pXg== 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" version "17.0.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339"
integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== 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" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== 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: react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4:
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" 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" react-lifecycles-compat "^3.0.2"
symbol-observable "^1.0.4" 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: redux@^4.0.5:
version "4.0.5" version "4.0.5"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" 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" loose-envify "^1.4.0"
symbol-observable "^1.2.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: regenerate-unicode-properties@^8.2.0:
version "8.2.0" version "8.2.0"
resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec" 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: dependencies:
node-forge "^0.10.0" 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" version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
@@ -6794,7 +7133,7 @@ shellwords@^0.1.1:
resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b"
integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== 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" version "1.0.4"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==
@@ -6899,6 +7238,14 @@ source-map-resolve@^0.5.0:
source-map-url "^0.4.0" source-map-url "^0.4.0"
urix "^0.1.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: source-map-support@^0.5.6, source-map-support@~0.5.19:
version "0.5.19" version "0.5.19"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" 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" define-properties "^1.1.3"
es-abstract "^1.18.0-next.1" 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: string.prototype.trimstart@^1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.2.tgz#22d45da81015309cd0cdd79787e8919fc5c613e7" 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" define-properties "^1.1.3"
es-abstract "^1.18.0-next.1" 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: string_decoder@^1.1.1:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" 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" resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== 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: strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
@@ -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" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31"
integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g== 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: uncontrollable@^7.0.0:
version "7.1.1" version "7.1.1"
resolved "https://registry.yarnpkg.com/uncontrollable/-/uncontrollable-7.1.1.tgz#f67fed3ef93637126571809746323a9db815d556" resolved "https://registry.yarnpkg.com/uncontrollable/-/uncontrollable-7.1.1.tgz#f67fed3ef93637126571809746323a9db815d556"
@@ -7792,6 +8172,17 @@ whatwg-url@^8.0.0:
tr46 "^2.0.2" tr46 "^2.0.2"
webidl-conversions "^6.1.0" 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: which-module@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"

View File

@@ -1,14 +1,8 @@
"""JupyterHub version info""" """JupyterHub version info"""
# Copyright (c) Jupyter Development Team. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
# version_info updated by running `tbump`
version_info = ( version_info = (2, 0, 1, "", "")
2,
0,
0,
"b2", # release (b1, rc1, or "" for final or dev)
# "dev", # dev or nothing for beta/rc/stable releases
)
# pep 440 version: no dot before beta/rc, but before .dev # pep 440 version: no dot before beta/rc, but before .dev
# 0.1.0rc1 # 0.1.0rc1
@@ -16,7 +10,9 @@ version_info = (
# 0.1.0b1.dev # 0.1.0b1.dev
# 0.1.0.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. # Singleton flag to only log the major/minor mismatch warning once per mismatch combo.
_version_mismatch_warning_logged = {} _version_mismatch_warning_logged = {}

View File

@@ -308,12 +308,14 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
"filter": "", "filter": "",
} }
] ]
elif 'all' in raw_scopes: elif 'inherit' in raw_scopes:
raw_scopes = ['all'] raw_scopes = ['inherit']
scope_descriptions = [ scope_descriptions = [
{ {
"scope": "all", "scope": "inherit",
"description": scopes.scope_definitions['all']['description'], "description": scopes.scope_definitions['inherit'][
'description'
],
"filter": "", "filter": "",
} }
] ]

View File

@@ -31,6 +31,9 @@ class APIHandler(BaseHandler):
- methods for REST API models - methods for REST API models
""" """
# accept token-based authentication for API requests
_accept_token_auth = True
@property @property
def content_security_policy(self): def content_security_policy(self):
return '; '.join([super().content_security_policy, "default-src 'none'"]) return '; '.join([super().content_security_policy, "default-src 'none'"])
@@ -55,7 +58,8 @@ class APIHandler(BaseHandler):
- allow unspecified host/referer (e.g. scripts) - 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)
referer = self.request.headers.get("Referer") referer = self.request.headers.get("Referer")
# If no header is provided, assume it comes from a script/curl. # If no header is provided, assume it comes from a script/curl.
@@ -67,13 +71,24 @@ class APIHandler(BaseHandler):
self.log.warning("Blocking API request with no referer") self.log.warning("Blocking API request with no referer")
return False return False
host_path = url_path_join(host, self.hub.base_url) proto = self.request.protocol
referer_path = referer.split('://', 1)[-1] full_host = f"{proto}://{host}{self.hub.base_url}"
if not (referer_path + '/').startswith(host_path): 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( self.log.warning(
"Blocking Cross Origin API request. Referer: %s, Host: %s", f"Blocking Cross Origin API request. Referer: {referer},"
referer, f" {host_header}: {host}, Host URL: {full_host}",
host_path,
) )
return False return False
return True return True
@@ -210,6 +225,7 @@ class APIHandler(BaseHandler):
'last_activity': isoformat(token.last_activity), 'last_activity': isoformat(token.last_activity),
'expires_at': isoformat(token.expires_at), 'expires_at': isoformat(token.expires_at),
'note': token.note, 'note': token.note,
'session_id': token.session_id,
'oauth_client': token.oauth_client.description 'oauth_client': token.oauth_client.description
or token.oauth_client.identifier, or token.oauth_client.identifier,
} }

View File

@@ -58,6 +58,14 @@ class SelfAPIHandler(APIHandler):
model = get_model(user) 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, # add scopes to identify model,
# but not the scopes we added to ensure we could read our own model # but not the scopes we added to ensure we could read our own model
model["scopes"] = sorted(self.expanded_scopes.difference(_added_scopes)) model["scopes"] = sorted(self.expanded_scopes.difference(_added_scopes))
@@ -397,9 +405,11 @@ class UserTokenListAPIHandler(APIHandler):
token_roles = body.get('roles') token_roles = body.get('roles')
try: try:
api_token = user.new_api_token( 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) raise web.HTTPError(404, "Requested roles %r not found" % token_roles)
except ValueError: except ValueError:
raise web.HTTPError( raise web.HTTPError(
@@ -421,6 +431,7 @@ class UserTokenListAPIHandler(APIHandler):
token_model = self.token_model(orm.APIToken.find(self.db, api_token)) token_model = self.token_model(orm.APIToken.find(self.db, api_token))
token_model['token'] = api_token token_model['token'] = api_token
self.write(json.dumps(token_model)) self.write(json.dumps(token_model))
self.set_status(201)
class UserTokenAPIHandler(APIHandler): class UserTokenAPIHandler(APIHandler):
@@ -483,6 +494,11 @@ class UserServerAPIHandler(APIHandler):
@needs_scope('servers') @needs_scope('servers')
async def post(self, user_name, server_name=''): async def post(self, user_name, server_name=''):
user = self.find_user(user_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 server_name:
if not self.allow_named_servers: if not self.allow_named_servers:
raise web.HTTPError(400, "Named servers are not enabled.") raise web.HTTPError(400, "Named servers are not enabled.")

View File

@@ -90,6 +90,7 @@ from .log import CoroutineLogFormatter, log_request
from .proxy import Proxy, ConfigurableHTTPProxy from .proxy import Proxy, ConfigurableHTTPProxy
from .traitlets import URLPrefix, Command, EntryPointType, Callable from .traitlets import URLPrefix, Command, EntryPointType, Callable
from .utils import ( from .utils import (
AnyTimeoutError,
catch_db_error, catch_db_error,
maybe_future, maybe_future,
url_path_join, 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 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( hub_port = Integer(
8081, 8081,
help="""The internal port for the Hub process. help="""The internal port for the Hub process.
@@ -1518,6 +1529,25 @@ class JupyterHub(Application):
""", """,
).tag(config=True) ).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): def init_handlers(self):
h = [] h = []
# load handlers from the authenticator # load handlers from the authenticator
@@ -1873,6 +1903,7 @@ class JupyterHub(Application):
user = orm.User.find(db, name) user = orm.User.find(db, name)
if user is None: if user is None:
user = orm.User(name=name, admin=True) user = orm.User(name=name, admin=True)
roles.assign_default_roles(self.db, entity=user)
new_users.append(user) new_users.append(user)
db.add(user) db.add(user)
else: else:
@@ -1963,6 +1994,7 @@ class JupyterHub(Application):
self.log.info(f"Creating user {username}") self.log.info(f"Creating user {username}")
user = orm.User(name=username) user = orm.User(name=username)
self.db.add(user) self.db.add(user)
roles.assign_default_roles(self.db, entity=user)
self.db.commit() self.db.commit()
return user return user
@@ -1984,14 +2016,25 @@ class JupyterHub(Application):
async def init_role_creation(self): async def init_role_creation(self):
"""Load default and predefined roles into the database""" """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() default_roles = roles.get_default_roles()
config_role_names = [r['name'] for r in self.load_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 = [] roles_with_new_permissions = []
for role_spec in self.load_roles: for role_spec in self.load_roles:
role_name = role_spec['name'] 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 # Check for duplicates
if config_role_names.count(role_name) > 1: if config_role_names.count(role_name) > 1:
raise ValueError( raise ValueError(
@@ -2002,10 +2045,13 @@ class JupyterHub(Application):
old_role = orm.Role.find(self.db, name=role_name) old_role = orm.Role.find(self.db, name=role_name)
if old_role: if old_role:
if not set(role_spec['scopes']).issubset(old_role.scopes): if not set(role_spec['scopes']).issubset(old_role.scopes):
app_log.warning( self.log.warning(
"Role %s has obtained extra permissions" % role_name "Role %s has obtained extra permissions" % role_name
) )
roles_with_new_permissions.append(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: if roles_with_new_permissions:
unauthorized_oauth_tokens = ( unauthorized_oauth_tokens = (
self.db.query(orm.APIToken) self.db.query(orm.APIToken)
@@ -2017,7 +2063,7 @@ class JupyterHub(Application):
.filter(orm.APIToken.client_id != 'jupyterhub') .filter(orm.APIToken.client_id != 'jupyterhub')
) )
for token in unauthorized_oauth_tokens: 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" "Deleting OAuth token %s; one of its roles obtained new permissions that were not authorized by user"
% token % token
) )
@@ -2025,14 +2071,19 @@ class JupyterHub(Application):
self.db.commit() self.db.commit()
init_role_names = [r['name'] for r in init_roles] 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 self._rbac_upgrade = True
else: else:
self._rbac_upgrade = False self._rbac_upgrade = False
for role in self.db.query(orm.Role).filter( for role in self.db.query(orm.Role).filter(
orm.Role.name.notin_(init_role_names) 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.delete(role)
self.db.commit() self.db.commit()
for role in init_roles: for role in init_roles:
@@ -2048,66 +2099,89 @@ class JupyterHub(Application):
if config_admin_users: if config_admin_users:
for role_spec in self.load_roles: for role_spec in self.load_roles:
if role_spec['name'] == 'admin': if role_spec['name'] == 'admin':
app_log.warning( self.log.warning(
"Configuration specifies both admin_users and users in the admin role specification. " "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" "Merging admin_users set with users list in admin role"
) )
role_spec['users'] = set(role_spec.get('users', [])) role_spec['users'] = set(role_spec.get('users', []))
role_spec['users'] |= config_admin_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} has_admin_role_spec = {role_bearer: False for role_bearer in admin_role_objects}
for predef_role in self.load_roles: for role_spec in self.load_roles:
predef_role_obj = orm.Role.find(db, name=predef_role['name']) role = orm.Role.find(db, name=role_spec['name'])
if predef_role['name'] == 'admin': role_name = role_spec["name"]
if role_name == 'admin':
for kind in admin_role_objects: 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]: 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: else:
app_log.info( self.log.info(
f"Admin role does not specify {kind}, preserving admin membership in database" f"Admin role does not specify {kind}, preserving admin membership in database"
) )
# add users, services, and/or groups, # add users, services, and/or groups,
# tokens need to be checked for permissions # tokens need to be checked for permissions
for kind in kinds: for kind in kinds:
orm_role_bearers = [] orm_role_bearers = []
if kind in predef_role.keys(): if kind in role_spec:
for bname in predef_role[kind]: for name in role_spec[kind]:
if kind == 'users': if kind == 'users':
bname = self.authenticator.normalize_username(bname) name = self.authenticator.normalize_username(name)
if not ( if not (
await maybe_future( await maybe_future(
self.authenticator.check_allowed(bname, None) self.authenticator.check_allowed(name, None)
) )
): ):
raise ValueError( raise ValueError(
"Username %r is not in Authenticator.allowed_users" f"Username {name} is not in Authenticator.allowed_users"
% bname
) )
Class = orm.get_class(kind) Class = orm.get_class(kind)
orm_obj = Class.find(db, bname) orm_obj = Class.find(db, name)
if orm_obj: if orm_obj is not None:
orm_role_bearers.append(orm_obj) orm_role_bearers.append(orm_obj)
else: else:
app_log.info( self.log.info(
f"Found unexisting {kind} {bname} in role definition {predef_role['name']}" f"Found unexisting {kind} {name} in role definition {role_name}"
) )
if kind == 'users': 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) 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: else:
raise ValueError( 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 # Ensure all with admin role have admin flag
if predef_role['name'] == 'admin': if role_name == 'admin':
orm_obj.admin = True 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() db.commit()
if self.authenticator.allowed_users: 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( allowed_users = db.query(orm.User).filter(
orm.User.name.in_(self.authenticator.allowed_users) orm.User.name.in_(self.authenticator.allowed_users)
) )
@@ -2124,8 +2198,8 @@ class JupyterHub(Application):
db.commit() db.commit()
# make sure that on hub upgrade, all users, services and tokens have at least one role (update with default) # 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): if getattr(self, '_rbac_upgrade', False):
app_log.warning( self.log.warning(
"No admin role found; assuming hub upgrade. Initializing default roles for all entities" "No roles found; assuming hub upgrade. Initializing default roles for all entities"
) )
for kind in kinds: for kind in kinds:
roles.check_for_default_roles(db, kind) roles.check_for_default_roles(db, kind)
@@ -2331,7 +2405,7 @@ class JupyterHub(Application):
continue continue
try: try:
await Server.from_orm(service.orm.server).wait_up(timeout=1, http=True) await Server.from_orm(service.orm.server).wait_up(timeout=1, http=True)
except TimeoutError: except AnyTimeoutError:
self.log.warning( self.log.warning(
"Cannot connect to %s service %s at %s", "Cannot connect to %s service %s at %s",
service.kind, service.kind,
@@ -2409,7 +2483,7 @@ class JupyterHub(Application):
) )
try: try:
await user._wait_up(spawner) await user._wait_up(spawner)
except TimeoutError: except AnyTimeoutError:
self.log.error( self.log.error(
"%s does not appear to be running at %s, shutting it down.", "%s does not appear to be running at %s, shutting it down.",
spawner._log_name, spawner._log_name,
@@ -2773,7 +2847,7 @@ class JupyterHub(Application):
await gen.with_timeout( await gen.with_timeout(
timedelta(seconds=max(init_spawners_timeout, 1)), init_spawners_future timedelta(seconds=max(init_spawners_timeout, 1)), init_spawners_future
) )
except gen.TimeoutError: except AnyTimeoutError:
self.log.warning( self.log.warning(
"init_spawners did not complete within %i seconds. " "init_spawners did not complete within %i seconds. "
"Allowing to complete in the background.", "Allowing to complete in the background.",
@@ -3036,7 +3110,7 @@ class JupyterHub(Application):
await Server.from_orm(service.orm.server).wait_up( await Server.from_orm(service.orm.server).wait_up(
http=True, timeout=1, ssl_context=ssl_context http=True, timeout=1, ssl_context=ssl_context
) )
except TimeoutError: except AnyTimeoutError:
if service.managed: if service.managed:
status = await service.spawner.poll() status = await service.spawner.poll()
if status is not None: if status is not None:

View File

@@ -47,6 +47,7 @@ from ..metrics import TOTAL_USERS
from ..objects import Server from ..objects import Server
from ..spawner import LocalProcessSpawner from ..spawner import LocalProcessSpawner
from ..user import User from ..user import User
from ..utils import AnyTimeoutError
from ..utils import get_accepted_mimetype from ..utils import get_accepted_mimetype
from ..utils import maybe_future from ..utils import maybe_future
from ..utils import url_path_join from ..utils import url_path_join
@@ -70,6 +71,12 @@ SESSION_COOKIE_NAME = 'jupyterhub-session-id'
class BaseHandler(RequestHandler): class BaseHandler(RequestHandler):
"""Base Handler class with access to common methods and properties.""" """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): async def prepare(self):
"""Identify the user during the prepare stage of each request """Identify the user during the prepare stage of each request
@@ -339,6 +346,7 @@ class BaseHandler(RequestHandler):
auth_info['auth_state'] = await user.get_auth_state() auth_info['auth_state'] = await user.get_auth_state()
return await self.auth_to_user(auth_info, user) return await self.auth_to_user(auth_info, user)
@functools.lru_cache()
def get_token(self): def get_token(self):
"""get token from authorization header""" """get token from authorization header"""
token = self.get_auth_token() token = self.get_auth_token()
@@ -409,9 +417,11 @@ class BaseHandler(RequestHandler):
async def get_current_user(self): async def get_current_user(self):
"""get current username""" """get current username"""
if not hasattr(self, '_jupyterhub_user'): if not hasattr(self, '_jupyterhub_user'):
user = None
try: try:
if self._accept_token_auth:
user = self.get_current_user_token() user = self.get_current_user_token()
if user is None: if user is None and self._accept_cookie_auth:
user = self.get_current_user_cookie() user = self.get_current_user_cookie()
if user and isinstance(user, User): if user and isinstance(user, User):
user = await self.refresh_auth(user) user = await self.refresh_auth(user)
@@ -761,8 +771,9 @@ class BaseHandler(RequestHandler):
# Only set `admin` if the authenticator returned an explicit value. # Only set `admin` if the authenticator returned an explicit value.
if admin is not None and admin != user.admin: if admin is not None and admin != user.admin:
user.admin = admin user.admin = admin
# always ensure default roles ('user', 'admin' if admin) are assigned
# after a successful login
roles.assign_default_roles(self.db, entity=user) roles.assign_default_roles(self.db, entity=user)
self.db.commit()
# always set auth_state and commit, # always set auth_state and commit,
# because there could be key-rotation or clearing of previous values # because there could be key-rotation or clearing of previous values
# going on. # going on.
@@ -1021,7 +1032,7 @@ class BaseHandler(RequestHandler):
await gen.with_timeout( await gen.with_timeout(
timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future
) )
except gen.TimeoutError: except AnyTimeoutError:
# waiting_for_response indicates server process has started, # waiting_for_response indicates server process has started,
# but is yet to become responsive. # but is yet to become responsive.
if spawner._spawn_pending and not spawner._waiting_for_response: if spawner._spawn_pending and not spawner._waiting_for_response:
@@ -1168,7 +1179,7 @@ class BaseHandler(RequestHandler):
try: try:
await gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), future) await gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), future)
except gen.TimeoutError: except AnyTimeoutError:
# hit timeout, but stop is still pending # hit timeout, but stop is still pending
self.log.warning( self.log.warning(
"User %s:%s server is slow to stop (timeout=%s)", "User %s:%s server is slow to stop (timeout=%s)",
@@ -1357,7 +1368,7 @@ class UserUrlHandler(BaseHandler):
**Changed Behavior as of 1.0** This handler no longer triggers a spawn. Instead, it checks if: **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.) 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 3. server is active, redirect to /hub/spawn-pending to monitor launch progress
(will redirect back when finished) (will redirect back when finished)
@@ -1371,12 +1382,22 @@ class UserUrlHandler(BaseHandler):
Note that this only occurs if bob's server is not already running. 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=''): def _fail_api_request(self, user_name='', server_name=''):
"""Fail an API request to a not-running server""" """Fail an API request to a not-running server"""
self.log.warning( self.log.warning(
"Failing suspected API request to not-running server: %s", self.request.path "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") self.set_header("Content-Type", "application/json")
spawn_url = urlparse(self.request.full_url())._replace(query="") spawn_url = urlparse(self.request.full_url())._replace(query="")
@@ -1541,15 +1562,17 @@ class UserUrlHandler(BaseHandler):
self.redirect(pending_url, status=303) self.redirect(pending_url, status=303)
return return
# if we got here, the server is not running # If we got here, the server is not running. To differentiate
# serve a page prompting for spawn and 503 error # that the *server* itself is not running, rather than just the particular
# visiting /user/:name no longer triggers implicit spawn # page *in* the server is not found, we return a 424 instead of a 404.
# without explicit user action # We allow retaining the old behavior to support older JupyterLab versions
spawn_url = url_concat( spawn_url = url_concat(
url_path_join(self.hub.base_url, "spawn", user.escaped_name, server_name), url_path_join(self.hub.base_url, "spawn", user.escaped_name, server_name),
{"next": self.request.uri}, {"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() auth_state = await user.get_auth_state()
html = await self.render_template( html = await self.render_template(

View File

@@ -308,10 +308,13 @@ class SpawnHandler(BaseHandler):
# otherwise it may cause a redirect loop # otherwise it may cause a redirect loop
if f.done() and f.exception(): if f.done() and f.exception():
exc = 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( raise web.HTTPError(
500, 500,
"Error in Authenticator.pre_spawn_start: %s %s" f"Unhandled error starting server {spawner._log_name}",
% (type(exc).__name__, str(exc)),
) )
return self.redirect(pending_url) return self.redirect(pending_url)
@@ -465,6 +468,7 @@ class AdminHandler(BaseHandler):
named_server_limit_per_user=self.named_server_limit_per_user, named_server_limit_per_user=self.named_server_limit_per_user,
server_version=f'{__version__} {self.version_hash}', server_version=f'{__version__} {self.version_hash}',
api_page_limit=self.settings["api_page_default_limit"], api_page_limit=self.settings["api_page_default_limit"],
base_url=self.settings["base_url"],
) )
self.finish(html) self.finish(html)

View File

@@ -44,6 +44,7 @@ from . import utils
from .metrics import CHECK_ROUTES_DURATION_SECONDS from .metrics import CHECK_ROUTES_DURATION_SECONDS
from .metrics import PROXY_POLL_DURATION_SECONDS from .metrics import PROXY_POLL_DURATION_SECONDS
from .objects import Server from .objects import Server
from .utils import AnyTimeoutError
from .utils import exponential_backoff from .utils import exponential_backoff
from .utils import url_path_join from .utils import url_path_join
from jupyterhub.traitlets import Command from jupyterhub.traitlets import Command
@@ -718,7 +719,7 @@ class ConfigurableHTTPProxy(Proxy):
_check_process() _check_process()
try: try:
await server.wait_up(1) await server.wait_up(1)
except TimeoutError: except AnyTimeoutError:
continue continue
else: else:
break break

View File

@@ -2,6 +2,7 @@
# Copyright (c) Jupyter Development Team. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
import re import re
from functools import wraps
from itertools import chain from itertools import chain
from sqlalchemy import func from sqlalchemy import func
@@ -57,7 +58,7 @@ def get_default_roles():
{ {
'name': 'token', 'name': 'token',
'description': 'Token with same permissions as its owner', 'description': 'Token with same permissions as its owner',
'scopes': ['all'], 'scopes': ['inherit'],
}, },
] ]
return default_roles return default_roles
@@ -214,7 +215,7 @@ def _check_scopes(*args, rolename=None):
or or
scopes (list): list of scopes to check 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()) allowed_scopes = set(scopes.scope_definitions.keys())
@@ -228,35 +229,17 @@ def _check_scopes(*args, rolename=None):
for scope in args: for scope in args:
scopename, _, filter_ = scope.partition('!') scopename, _, filter_ = scope.partition('!')
if scopename not in allowed_scopes: 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_: if filter_:
full_filter = f"!{filter_}" full_filter = f"!{filter_}"
if not any(f in scope for f in allowed_filters): 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" 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]$') _role_name_pattern = re.compile(r'^[a-z][a-z0-9\-_~\.]{1,253}[a-z0-9]$')
@@ -291,6 +274,17 @@ def create_role(db, role_dict):
description = role_dict.get('description') description = role_dict.get('description')
scopes = role_dict.get('scopes') 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 # check if the provided scopes exist
if scopes: if scopes:
_check_scopes(*scopes, rolename=role_dict['name']) _check_scopes(*scopes, rolename=role_dict['name'])
@@ -304,8 +298,22 @@ def create_role(db, role_dict):
if role_dict not in default_roles: if role_dict not in default_roles:
app_log.info('Role %s added to database', name) app_log.info('Role %s added to database', name)
else: 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() db.commit()
@@ -322,81 +330,64 @@ def delete_role(db, rolename):
db.commit() db.commit()
app_log.info('Role %s has been deleted', rolename) app_log.info('Role %s has been deleted', rolename)
else: 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): def _existing_only(func):
"""Decorator for checking if objects and roles exist""" """Decorator for checking if roles exist"""
def _check_existence(db, entity, rolename): @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) role = orm.Role.find(db, rolename)
if entity is None: if role is None:
raise ValueError( raise ValueError(f"Role {rolename} does not exist")
f"{entity!r} of kind {type(entity).__name__!r} does not exist"
) return func(db, entity, role)
elif role is None:
raise ValueError("Role %r does not exist" % rolename)
else:
func(db, entity, role)
return _check_existence return _check_existence
@existing_only @_existing_only
def grant_role(db, entity, rolename): def grant_role(db, entity, role):
"""Adds a role for users, services, groups or tokens""" """Adds a role for users, services, groups or tokens"""
if isinstance(entity, orm.APIToken): if isinstance(entity, orm.APIToken):
entity_repr = entity entity_repr = entity
else: else:
entity_repr = entity.name entity_repr = entity.name
if rolename not in entity.roles: if role not in entity.roles:
entity.roles.append(rolename) entity.roles.append(role)
db.commit() db.commit()
app_log.info( app_log.info(
'Adding role %s for %s: %s', 'Adding role %s for %s: %s',
rolename.name, role.name,
type(entity).__name__, type(entity).__name__,
entity_repr, entity_repr,
) )
@existing_only @_existing_only
def strip_role(db, entity, rolename): def strip_role(db, entity, role):
"""Removes a role for users, services, groups or tokens""" """Removes a role for users, services, groups or tokens"""
if isinstance(entity, orm.APIToken): if isinstance(entity, orm.APIToken):
entity_repr = entity entity_repr = entity
else: else:
entity_repr = entity.name entity_repr = entity.name
if rolename in entity.roles: if role in entity.roles:
entity.roles.remove(rolename) entity.roles.remove(role)
db.commit() db.commit()
app_log.info( app_log.info(
'Removing role %s for %s: %s', 'Removing role %s for %s: %s',
rolename.name, role.name,
type(entity).__name__, type(entity).__name__,
entity_repr, 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): def _token_allowed_role(db, token, role):
"""Checks if requested role for token does not grant the token """Checks if requested role for token does not grant the token
higher permissions than the token's owner has higher permissions than the token's owner has
@@ -413,56 +404,67 @@ def _token_allowed_role(db, token, role):
expanded_scopes = _get_subscopes(role, owner=owner) expanded_scopes = _get_subscopes(role, owner=owner)
implicit_permissions = {'all', 'read:all'} implicit_permissions = {'inherit', 'read:inherit'}
explicit_scopes = expanded_scopes - implicit_permissions 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 # find the owner's scopes
expanded_owner_scopes = expand_roles_to_scopes(owner) expanded_owner_scopes = expand_roles_to_scopes(owner)
# ignore horizontal filters allowed_scopes = scopes._intersect_expanded_scopes(
no_filter_owner_scopes = { explicit_scopes, expanded_owner_scopes, db
scope.split('!', 1)[0] if '!' in scope else scope )
for scope in expanded_owner_scopes disallowed_scopes = explicit_scopes.difference(allowed_scopes)
}
disallowed_scopes = no_filter_scopes.difference(no_filter_owner_scopes)
if not disallowed_scopes: if not disallowed_scopes:
# no scopes requested outside owner's own scopes # no scopes requested outside owner's own scopes
return True return True
else: else:
app_log.warning( 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 return False
def assign_default_roles(db, entity): def assign_default_roles(db, entity):
"""Assigns default role to an entity: """Assigns default role(s) to an entity:
users and services get 'user' role, or admin role if they have admin flag
tokens get 'token' role 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): if isinstance(entity, orm.Group):
pass return
elif isinstance(entity, orm.APIToken):
app_log.debug('Assigning default roles to tokens') if isinstance(entity, orm.APIToken):
app_log.debug('Assigning default role to token')
default_token_role = orm.Role.find(db, 'token') default_token_role = orm.Role.find(db, 'token')
if not entity.roles and (entity.user or entity.service) is not None: if not entity.roles and (entity.user or entity.service) is not None:
default_token_role.tokens.append(entity) default_token_role.tokens.append(entity)
app_log.info('Added role %s to token %s', default_token_role.name, entity) app_log.info('Added role %s to token %s', default_token_role.name, entity)
db.commit() db.commit()
# users and services can have 'user' or 'admin' roles as default # users and services all have 'user' role by default
# and optionally 'admin' as well
else: else:
kind = type(entity).__name__ kind = type(entity).__name__
app_log.debug(f'Assigning default roles to {kind} {entity.name}') app_log.debug(f'Assigning default role to {kind} {entity.name}')
_switch_default_role(db, entity, entity.admin) if entity.admin:
grant_role(db, entity=entity, rolename="admin")
else:
admin_role = orm.Role.find(db, 'admin')
if admin_role in entity.roles:
strip_role(db, entity=entity, rolename="admin")
if kind == "User":
grant_role(db, entity=entity, rolename="user")
def update_roles(db, entity, roles): def update_roles(db, entity, roles):
"""Updates object's roles checking for requested permissions """Add roles to an entity (token, user, etc.)
if object is orm.APIToken
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: for rolename in roles:
if isinstance(entity, orm.APIToken): if isinstance(entity, orm.APIToken):
role = orm.Role.find(db, rolename) role = orm.Role.find(db, rolename)
@@ -475,12 +477,11 @@ def update_roles(db, entity, roles):
app_log.info('Adding role %s to token: %s', role.name, entity) app_log.info('Adding role %s to token: %s', role.name, entity)
else: else:
raise ValueError( 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: else:
raise NameError('Role %r does not exist' % rolename) raise KeyError(f'Role {rolename} does not exist')
else: else:
app_log.debug('Assigning default roles to %s', type(entity).__name__)
grant_role(db, entity=entity, rolename=rolename) grant_role(db, entity=entity, rolename=rolename)

View File

@@ -30,7 +30,7 @@ scope_definitions = {
'description': 'Your own resources', 'description': 'Your own resources',
'doc_description': 'The users own resources _(metascope for users, resolves to (no_scope) for services)_', 'doc_description': 'The users own resources _(metascope for users, resolves to (no_scope) for services)_',
}, },
'all': { 'inherit': {
'description': 'Anything you have access to', 'description': 'Anything you have access to',
'doc_description': 'Everything that the token-owning entity can access _(metascope for tokens)_', 'doc_description': 'Everything that the token-owning entity can access _(metascope for tokens)_',
}, },
@@ -295,7 +295,7 @@ def get_scopes_for(orm_object):
) )
if isinstance(orm_object, orm.APIToken): 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 owner = orm_object.user or orm_object.service
token_scopes = roles.expand_roles_to_scopes(orm_object) token_scopes = roles.expand_roles_to_scopes(orm_object)
if orm_object.client_id != "jupyterhub": if orm_object.client_id != "jupyterhub":
@@ -317,13 +317,13 @@ def get_scopes_for(orm_object):
owner_scopes = roles.expand_roles_to_scopes(owner) owner_scopes = roles.expand_roles_to_scopes(owner)
if token_scopes == {'all'}: if token_scopes == {'inherit'}:
# token_scopes is only 'all', return owner scopes as-is # 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 # short-circuit common case where we don't need to compute an intersection
return owner_scopes return owner_scopes
if 'all' in token_scopes: if 'inherit' in token_scopes:
token_scopes.remove('all') token_scopes.remove('inherit')
token_scopes |= owner_scopes token_scopes |= owner_scopes
intersection = _intersect_expanded_scopes( intersection = _intersect_expanded_scopes(

View File

@@ -3,10 +3,24 @@
Tokens are sent to the Hub for verification. Tokens are sent to the Hub for verification.
The Hub replies with a JSON model describing the authenticated user. 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 - :class:`HubOAuth` - Use OAuth 2 to authenticate browsers with the Hub.
authenticate 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 import base64
@@ -212,6 +226,7 @@ class HubAuth(SingletonConfigurable):
help="""The base API URL of the Hub. help="""The base API URL of the Hub.
Typically `http://hub-ip:hub-port/hub/api` Typically `http://hub-ip:hub-port/hub/api`
Default: $JUPYTERHUB_API_URL
""", """,
).tag(config=True) ).tag(config=True)
@@ -227,7 +242,10 @@ class HubAuth(SingletonConfigurable):
os.getenv('JUPYTERHUB_API_TOKEN', ''), os.getenv('JUPYTERHUB_API_TOKEN', ''),
help="""API key for accessing Hub API. 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) ).tag(config=True)
@@ -236,6 +254,7 @@ class HubAuth(SingletonConfigurable):
help="""The URL prefix for the Hub itself. help="""The URL prefix for the Hub itself.
Typically /hub/ Typically /hub/
Default: $JUPYTERHUB_BASE_URL
""", """,
).tag(config=True) ).tag(config=True)
@@ -854,8 +873,6 @@ class HubAuthenticated:
Examples:: Examples::
class MyHandler(HubAuthenticated, web.RequestHandler): class MyHandler(HubAuthenticated, web.RequestHandler):
hub_users = {'inara', 'mal'}
def initialize(self, hub_auth): def initialize(self, hub_auth):
self.hub_auth = hub_auth self.hub_auth = hub_auth
@@ -865,6 +882,7 @@ class HubAuthenticated:
""" """
# deprecated, pre-2.0 allow sets
hub_services = None # set of allowed services hub_services = None # set of allowed services
hub_users = None # set of allowed users hub_users = None # set of allowed users
hub_groups = None # set of allowed groups hub_groups = None # set of allowed groups
@@ -960,6 +978,10 @@ class HubAuthenticated:
raise UserNotAllowed(model) raise UserNotAllowed(model)
# proceed with the pre-2.0 way if hub_scopes is not set # 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): if self.allow_admin and model.get('admin', False):
app_log.debug("Allowing Hub admin %s", name) app_log.debug("Allowing Hub admin %s", name)
@@ -1023,8 +1045,8 @@ class HubAuthenticated:
self._hub_auth_user_cache = None self._hub_auth_user_cache = None
raise raise
# store tokens passed via url or header in a cookie for future requests # store ?token=... tokens passed via url in a cookie for future requests
url_token = self.hub_auth.get_token(self) url_token = self.get_argument('token', '')
if ( if (
user_model user_model
and url_token and url_token

View File

@@ -18,6 +18,7 @@ import sys
import warnings import warnings
from datetime import datetime from datetime import datetime
from datetime import timezone from datetime import timezone
from importlib import import_module
from textwrap import dedent from textwrap import dedent
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -606,10 +607,34 @@ class SingleUserNotebookAppMixin(Configurable):
t = self.hub_activity_interval * (1 + 0.2 * (random.random() - 0.5)) t = self.hub_activity_interval * (1 + 0.2 * (random.random() - 0.5))
await asyncio.sleep(t) 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): def initialize(self, argv=None):
# disable trash by default # disable trash by default
# this can be re-enabled by config # this can be re-enabled by config
self.config.FileContentsManager.delete_to_trash = False self.config.FileContentsManager.delete_to_trash = False
self._log_app_versions()
return super().initialize(argv) return super().initialize(argv)
def start(self): def start(self):
@@ -715,6 +740,18 @@ class SingleUserNotebookAppMixin(Configurable):
orig_loader = env.loader orig_loader = env.loader
env.loader = ChoiceLoader([FunctionLoader(get_page), orig_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): def detect_base_package(App):
"""Detect the base package for an App class """Detect the base package for an App class

View File

@@ -15,8 +15,6 @@ from subprocess import Popen
from tempfile import mkdtemp from tempfile import mkdtemp
from urllib.parse import urlparse from urllib.parse import urlparse
if os.name == 'nt':
import psutil
from async_generator import aclosing from async_generator import aclosing
from sqlalchemy import inspect from sqlalchemy import inspect
from tornado.ioloop import PeriodicCallback from tornado.ioloop import PeriodicCallback
@@ -38,12 +36,14 @@ from .objects import Server
from .traitlets import ByteSpecification from .traitlets import ByteSpecification
from .traitlets import Callable from .traitlets import Callable
from .traitlets import Command from .traitlets import Command
from .utils import AnyTimeoutError
from .utils import exponential_backoff from .utils import exponential_backoff
from .utils import maybe_future from .utils import maybe_future
from .utils import random_port from .utils import random_port
from .utils import url_path_join 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): def _quote_safe(s):
@@ -1263,7 +1263,7 @@ class Spawner(LoggingConfigurable):
timeout=timeout, timeout=timeout,
) )
return r return r
except TimeoutError: except AnyTimeoutError:
return False return False

View File

@@ -9,6 +9,7 @@ from datetime import timedelta
from unittest import mock from unittest import mock
from urllib.parse import quote from urllib.parse import quote
from urllib.parse import urlparse from urllib.parse import urlparse
from urllib.parse import urlunparse
from pytest import fixture from pytest import fixture
from pytest import mark from pytest import mark
@@ -65,7 +66,15 @@ async def test_auth_api(app):
assert r.status_code == 403 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) url = ujoin(public_host(app), app.hub.base_url)
host = urlparse(url).netloc host = urlparse(url).netloc
# add admin user # add admin user
@@ -74,42 +83,6 @@ async def test_cors_checks(app):
user = add_user(app.db, name='admin', admin=True) user = add_user(app.db, name='admin', admin=True)
cookies = await app.login_user('admin') 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( r = await api_request(
app, app,
'users', 'users',
@@ -117,24 +90,79 @@ async def test_cors_checks(app):
data='{}', data='{}',
headers={ headers={
"Authorization": "", "Authorization": "",
"Content-Type": "text/plain", "Content-Type": content_type,
}, },
cookies=cookies, cookies=cookies,
) )
assert r.status_code == 403 assert r.status_code == status
@mark.parametrize(
"host, referer, 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),
],
)
async def test_cors_check(request, app, host, referer, 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
# 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( r = await api_request(
app, app,
'users', 'users',
method='post', headers=headers,
data='{}',
headers={
"Authorization": "",
"Content-Type": "application/json; charset=UTF-8",
},
cookies=cookies, cookies=cookies,
) )
assert r.status_code == 400 # accepted, but invalid assert r.status_code == status
# -------------- # --------------
@@ -160,6 +188,8 @@ def normalize_user(user):
""" """
for key in ('created', 'last_activity'): for key in ('created', 'last_activity'):
user[key] = normalize_timestamp(user[key]) user[key] = normalize_timestamp(user[key])
if 'roles' in user:
user['roles'] = sorted(user['roles'])
if 'servers' in user: if 'servers' in user:
for server in user['servers'].values(): for server in user['servers'].values():
for key in ('started', 'last_activity'): for key in ('started', 'last_activity'):
@@ -212,7 +242,12 @@ async def test_get_users(app):
} }
assert users == [ assert users == [
fill_user( 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), fill_user(user_model),
] ]
@@ -597,7 +632,7 @@ async def test_add_multi_user_admin(app):
assert user is not None assert user is not None
assert user.name == name assert user.name == name
assert user.admin 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 assert orm.Role.find(db, 'admin') in user.roles
@@ -637,7 +672,7 @@ async def test_add_admin(app):
assert user.name == name assert user.name == name
assert user.admin assert user.admin
# assert newadmin has default 'admin' role # 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 assert orm.Role.find(db, 'admin') in user.roles
@@ -672,7 +707,7 @@ async def test_make_admin(app):
assert user is not None assert user is not None
assert user.name == name assert user.name == name
assert user.admin 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 assert orm.Role.find(db, 'admin') in user.roles
@@ -972,6 +1007,11 @@ async def test_bad_spawn(app, bad_spawn):
assert app.users.count_active_users()['pending'] == 0 assert app.users.count_active_users()['pending'] == 0
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): async def test_slow_bad_spawn(app, no_patience, slow_bad_spawn):
db = app.db db = app.db
name = 'zaphod' name = 'zaphod'
@@ -1366,8 +1406,8 @@ async def test_get_new_token_deprecated(app, headers, status):
@mark.parametrize( @mark.parametrize(
"headers, status, note, expires_in", "headers, status, note, expires_in",
[ [
({}, 200, 'test note', None), ({}, 201, 'test note', None),
({}, 200, '', 100), ({}, 201, '', 100),
({'Authorization': 'token bad'}, 403, '', None), ({'Authorization': 'token bad'}, 403, '', None),
], ],
) )
@@ -1386,7 +1426,7 @@ async def test_get_new_token(app, headers, status, note, expires_in):
app, 'users/admin/tokens', method='post', headers=headers, data=body app, 'users/admin/tokens', method='post', headers=headers, data=body
) )
assert r.status_code == status assert r.status_code == status
if status != 200: if status != 201:
return return
# check the new-token reply # check the new-token reply
reply = r.json() reply = r.json()
@@ -1424,10 +1464,10 @@ async def test_get_new_token(app, headers, status, note, expires_in):
@mark.parametrize( @mark.parametrize(
"as_user, for_user, status", "as_user, for_user, status",
[ [
('admin', 'other', 200), ('admin', 'other', 201),
('admin', 'missing', 403), ('admin', 'missing', 403),
('user', 'other', 403), ('user', 'other', 403),
('user', 'user', 200), ('user', 'user', 201),
], ],
) )
async def test_token_for_user(app, as_user, for_user, status): async def test_token_for_user(app, as_user, for_user, status):
@@ -1448,7 +1488,7 @@ async def test_token_for_user(app, as_user, for_user, status):
) )
assert r.status_code == status assert r.status_code == status
reply = r.json() reply = r.json()
if status != 200: if status != 201:
return return
assert 'token' in reply assert 'token' in reply
@@ -1486,7 +1526,7 @@ async def test_token_authenticator_noauth(app):
data=json.dumps(data) if data else None, data=json.dumps(data) if data else None,
noauth=True, noauth=True,
) )
assert r.status_code == 200 assert r.status_code == 201
reply = r.json() reply = r.json()
assert 'token' in reply assert 'token' in reply
r = await api_request(app, 'authorizations', 'token', reply['token']) r = await api_request(app, 'authorizations', 'token', reply['token'])
@@ -1509,7 +1549,7 @@ async def test_token_authenticator_dict_noauth(app):
data=json.dumps(data) if data else None, data=json.dumps(data) if data else None,
noauth=True, noauth=True,
) )
assert r.status_code == 200 assert r.status_code == 201
reply = r.json() reply = r.json()
assert 'token' in reply assert 'token' in reply
r = await api_request(app, 'authorizations', 'token', reply['token']) r = await api_request(app, 'authorizations', 'token', reply['token'])

View File

@@ -247,6 +247,7 @@ async def test_load_groups(tmpdir, request):
kwargs['internal_certs_location'] = str(tmpdir) kwargs['internal_certs_location'] = str(tmpdir)
hub = MockHub(**kwargs) hub = MockHub(**kwargs)
hub.init_db() hub.init_db()
await hub.init_role_creation()
await hub.init_users() await hub.init_users()
await hub.init_groups() await hub.init_groups()
db = hub.db db = hub.db

View File

@@ -1,16 +1,13 @@
"""Tests for jupyterhub internal_ssl connections""" """Tests for jupyterhub internal_ssl connections"""
import sys import sys
import time import time
from subprocess import check_output
from unittest import mock from unittest import mock
from urllib.parse import urlparse
import pytest import pytest
from requests.exceptions import ConnectionError from requests.exceptions import ConnectionError
from requests.exceptions import SSLError from requests.exceptions import SSLError
from tornado import gen
import jupyterhub from ..utils import AnyTimeoutError
from .test_api import add_user from .test_api import add_user
from .utils import async_requests from .utils import async_requests
@@ -35,7 +32,7 @@ async def wait_for_spawner(spawner, timeout=10):
assert status is None assert status is None
try: try:
await wait() await wait()
except TimeoutError: except AnyTimeoutError:
continue continue
else: else:
break break

View File

@@ -56,8 +56,8 @@ async def test_root_redirect(app):
r = await get_page(url, app, cookies=cookies) r = await get_page(url, app, cookies=cookies)
path = urlparse(r.url).path path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name) assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
# serve "server not running" page, which has status 503 # serve "server not running" page, which has status 424
assert r.status_code == 503 assert r.status_code == 424
async def test_root_default_url_noauth(app): async def test_root_default_url_noauth(app):
@@ -172,7 +172,7 @@ async def test_spawn_redirect(app):
r = await get_page('user/' + name, app, hub=False, cookies=cookies) r = await get_page('user/' + name, app, hub=False, cookies=cookies)
path = urlparse(r.url).path path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/' % name) 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): async def test_spawn_handler_access(app):
@@ -507,13 +507,13 @@ async def test_user_redirect_deprecated(app, username):
print(urlparse(r.url)) print(urlparse(r.url))
path = urlparse(r.url).path path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/' % name) assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
assert r.status_code == 503 assert r.status_code == 424
r = await get_page('/user/baduser/test.ipynb', app, cookies=cookies, hub=False) r = await get_page('/user/baduser/test.ipynb', app, cookies=cookies, hub=False)
print(urlparse(r.url)) print(urlparse(r.url))
path = urlparse(r.url).path path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name) assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
assert r.status_code == 503 assert r.status_code == 424
r = await get_page('/user/baduser/test.ipynb', app, hub=False) r = await get_page('/user/baduser/test.ipynb', app, hub=False)
r.raise_for_status() r.raise_for_status()
@@ -578,6 +578,41 @@ async def test_login_page(app, url, params, redirected_url, form_action):
assert action.endswith(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): async def test_login_fail(app):
name = 'wash' name = 'wash'
base_url = public_url(app) base_url = public_url(app)
@@ -1061,13 +1096,20 @@ async def test_token_page(app):
async def test_server_not_running_api_request(app): async def test_server_not_running_api_request(app):
cookies = await app.login_user("bees") cookies = await app.login_user("bees")
r = await get_page("user/bees/api/status", app, hub=False, cookies=cookies) 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" assert r.headers["content-type"] == "application/json"
message = r.json()['message'] message = r.json()['message']
assert ujoin(app.base_url, "hub/spawn/bees") in message assert ujoin(app.base_url, "hub/spawn/bees") in message
assert " /user/bees" in message assert " /user/bees" in message
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_metrics_no_auth(app): async def test_metrics_no_auth(app):
r = await get_page("metrics", app) r = await get_page("metrics", app)
assert r.status_code == 403 assert r.status_code == 403
@@ -1087,7 +1129,7 @@ async def test_health_check_request(app):
async def test_pre_spawn_start_exc_no_form(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 # throw exception from pre_spawn_start
async def mock_pre_spawn_start(user, spawner): async def mock_pre_spawn_start(user, spawner):

View File

@@ -28,7 +28,7 @@ def test_orm_roles(db):
user_role = orm.Role(name='user', scopes=['self']) user_role = orm.Role(name='user', scopes=['self'])
db.add(user_role) db.add(user_role)
if not token_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) db.add(token_role)
if not service_role: if not service_role:
service_role = orm.Role(name='service', scopes=[]) service_role = orm.Role(name='service', scopes=[])
@@ -369,7 +369,7 @@ async def test_creating_roles(app, role, role_def, response_type, response):
'info', 'info',
app_log.info('Role user scopes attribute has been changed'), 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), ('default', 'user', 'error', ValueError),
], ],
) )
@@ -410,9 +410,9 @@ async def test_delete_roles(db, role_type, rolename, response_type, response):
}, },
'existing', 'existing',
), ),
({'name': 'test-scopes-2', 'scopes': ['uses']}, NameError), ({'name': 'test-scopes-2', 'scopes': ['uses']}, KeyError),
({'name': 'test-scopes-3', 'scopes': ['users:activities']}, NameError), ({'name': 'test-scopes-3', 'scopes': ['users:activities']}, KeyError),
({'name': 'test-scopes-4', 'scopes': ['groups!goup=class-A']}, NameError), ({'name': 'test-scopes-4', 'scopes': ['groups!goup=class-A']}, KeyError),
], ],
) )
async def test_scope_existence(tmpdir, request, role, response): async def test_scope_existence(tmpdir, request, role, response):
@@ -431,7 +431,7 @@ async def test_scope_existence(tmpdir, request, role, response):
assert added_role is not None assert added_role is not None
assert added_role.scopes == role['scopes'] assert added_role.scopes == role['scopes']
elif response == NameError: elif response == KeyError:
with pytest.raises(response): with pytest.raises(response):
roles.create_role(db, role) roles.create_role(db, role)
added_role = orm.Role.find(db, role['name']) added_role = orm.Role.find(db, role['name'])
@@ -443,7 +443,14 @@ async def test_scope_existence(tmpdir, request, role, response):
@mark.role @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""" """Test loading predefined roles for users in app.py"""
roles_to_load = [ roles_to_load = [
{ {
@@ -461,6 +468,7 @@ async def test_load_roles_users(tmpdir, request):
hub.init_db() hub.init_db()
db = hub.db db = hub.db
hub.authenticator.admin_users = ['admin'] hub.authenticator.admin_users = ['admin']
if explicit_allowed_users:
hub.authenticator.allowed_users = ['cyclops', 'gandalf', 'bilbo', 'gargamel'] hub.authenticator.allowed_users = ['cyclops', 'gandalf', 'bilbo', 'gargamel']
await hub.init_role_creation() await hub.init_role_creation()
await hub.init_users() await hub.init_users()
@@ -578,7 +586,7 @@ async def test_load_roles_groups(tmpdir, request):
'name': 'head', 'name': 'head',
'description': 'Whole user access', 'description': 'Whole user access',
'scopes': ['users', 'admin:users'], 'scopes': ['users', 'admin:users'],
'groups': ['group3'], 'groups': ['group3', "group4"],
}, },
] ]
kwargs = {'load_groups': groups_to_load, 'load_roles': roles_to_load} kwargs = {'load_groups': groups_to_load, 'load_roles': roles_to_load}
@@ -598,11 +606,13 @@ async def test_load_roles_groups(tmpdir, request):
group1 = orm.Group.find(db, name='group1') group1 = orm.Group.find(db, name='group1')
group2 = orm.Group.find(db, name='group2') group2 = orm.Group.find(db, name='group2')
group3 = orm.Group.find(db, name='group3') group3 = orm.Group.find(db, name='group3')
group4 = orm.Group.find(db, name='group4')
# test group roles # test group roles
assert group1.roles == [] assert group1.roles == []
assert group2 in assist_role.groups assert group2 in assist_role.groups
assert group3 in head_role.groups assert group3 in head_role.groups
assert group4 in head_role.groups
# delete the test roles # delete the test roles
for role in roles_to_load: for role in roles_to_load:
@@ -661,11 +671,15 @@ async def test_load_roles_user_tokens(tmpdir, request):
"headers, rolename, scopes, status", "headers, rolename, scopes, status",
[ [
# no role requested - gets default 'token' role # no role requested - gets default 'token' role
({}, None, None, 200), ({}, None, None, 201),
# role scopes within the user's default 'user' role # 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 # 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 role request
({}, 'non-existing', [], 404), ({}, 'non-existing', [], 404),
# role scopes outside of both user's role and group's role scopes # role scopes outside of both user's role and group's role scopes
@@ -1181,14 +1195,47 @@ async def test_no_admin_role_change():
await hub.init_role_creation() await hub.init_role_creation()
async def test_user_config_respects_memberships(): @pytest.mark.parametrize(
role_spec = [ "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', 'name': 'user',
'scopes': ['self', 'shutdown'], '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': 'new-role',
'scopes': ['read:users'],
'users': ['not-yet-created-user'],
}
] ]
user_names = ['eddy', 'carol'] user_names = []
hub = MockHub(load_roles=role_spec) hub = MockHub(load_roles=role_spec)
hub.init_db() hub.init_db()
hub.authenticator.allowed_users = user_names hub.authenticator.allowed_users = user_names
@@ -1196,9 +1243,9 @@ async def test_user_config_respects_memberships():
await hub.init_users() await hub.init_users()
await hub.init_role_assignment() await hub.init_role_assignment()
user_role = orm.Role.find(hub.db, 'user') user_role = orm.Role.find(hub.db, 'user')
for user_name in user_names: new_role = orm.Role.find(hub.db, 'new-role')
user = orm.User.find(hub.db, user_name) assert orm.User.find(hub.db, 'not-yet-created-user') in new_role.users
assert user in user_role.users assert orm.User.find(hub.db, 'not-yet-created-user') in user_role.users
async def test_admin_role_respects_config(): async def test_admin_role_respects_config():
@@ -1220,16 +1267,45 @@ async def test_admin_role_respects_config():
assert user in admin_role.users assert user in admin_role.users
async def test_empty_admin_spec(): @pytest.mark.parametrize(
role_spec = [{'name': 'admin', 'users': []}] "in_db, role_users, admin_users, expected_members",
hub = MockHub(load_roles=role_spec) [
# 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.init_db()
hub.authenticator.admin_users = []
await hub.init_role_creation() 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_users()
await hub.init_role_assignment() await hub.init_role_assignment()
admin_role = orm.Role.find(hub.db, 'admin') admin_role = orm.Role.find(db, 'admin')
assert not admin_role.users role_members = sorted(user.name for user in admin_role.users)
assert role_members == expected_members
async def test_no_default_service_role(): async def test_no_default_service_role():
@@ -1330,3 +1406,20 @@ async def test_token_keep_roles_on_restart():
for token in user.api_tokens: for token in user.api_tokens:
hub.db.delete(token) hub.db.delete(token)
hub.db.commit() 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"]

View File

@@ -477,7 +477,7 @@ async def test_metascope_all_expansion(app, create_user_with_scopes):
user = create_user_with_scopes('self') user = create_user_with_scopes('self')
user.new_api_token() user.new_api_token()
token = user.api_tokens[0] token = user.api_tokens[0]
# Check 'all' expansion # Check 'inherit' expansion
token_scope_set = get_scopes_for(token) token_scope_set = get_scopes_for(token)
user_scope_set = get_scopes_for(user) user_scope_set = get_scopes_for(user)
assert user_scope_set == token_scope_set assert user_scope_set == token_scope_set
@@ -677,9 +677,14 @@ async def test_resolve_token_permissions(
intersection_scopes, intersection_scopes,
): ):
orm_user = create_user_with_scopes(*user_scopes).orm_user 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') create_temp_role(token_scopes, 'active-posting')
api_token = orm_user.new_api_token(roles=['active-posting']) api_token = orm_user.new_api_token(roles=['active-posting'])
orm_api_token = orm.APIToken.find(app.db, token=api_token) 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 # get expanded !user filter scopes for check
user_scopes = roles.expand_roles_to_scopes(orm_user) user_scopes = roles.expand_roles_to_scopes(orm_user)

View File

@@ -385,8 +385,8 @@ async def test_oauth_page_hit(
hits_page, hits_page,
): ):
test_roles = { test_roles = {
'reader': create_temp_role(['read:users'], role_name='reader'), 'reader': create_temp_role(['read:users!user'], role_name='reader'),
'writer': create_temp_role(['users:activity'], role_name='writer'), 'writer': create_temp_role(['users:activity!user'], role_name='writer'),
} }
service = mockservice_url service = mockservice_url
user = create_user_with_scopes("access:services", "self") user = create_user_with_scopes("access:services", "self")

View File

@@ -21,6 +21,7 @@ from ..objects import Server
from ..spawner import LocalProcessSpawner from ..spawner import LocalProcessSpawner
from ..spawner import Spawner from ..spawner import Spawner
from ..user import User from ..user import User
from ..utils import AnyTimeoutError
from ..utils import new_token from ..utils import new_token
from ..utils import url_path_join from ..utils import url_path_join
from .mocking import public_url from .mocking import public_url
@@ -95,7 +96,7 @@ async def wait_for_spawner(spawner, timeout=10):
assert status is None assert status is None
try: try:
await wait() await wait()
except TimeoutError: except AnyTimeoutError:
continue continue
else: else:
break break
@@ -427,7 +428,22 @@ async def test_hub_connect_url(db):
) )
async def test_spawner_oauth_roles(app): async def test_spawner_oauth_roles(app, user):
allowed_roles = ['lotsa', 'roles'] allowed_roles = ["admin", "user"]
spawner = new_spawner(app.db, oauth_roles=allowed_roles) spawner = user.spawners['']
assert spawner.oauth_roles == allowed_roles 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()

View File

@@ -26,11 +26,41 @@ from .metrics import RUNNING_SERVERS
from .metrics import TOTAL_USERS from .metrics import TOTAL_USERS
from .objects import Server from .objects import Server
from .spawner import LocalProcessSpawner from .spawner import LocalProcessSpawner
from .utils import AnyTimeoutError
from .utils import make_ssl_context from .utils import make_ssl_context
from .utils import maybe_future from .utils import maybe_future
from .utils import url_path_join 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): class UserDict(dict):
"""Like defaultdict, but for users """Like defaultdict, but for users
@@ -592,6 +622,19 @@ class User:
if callable(allowed_roles): if callable(allowed_roles):
allowed_roles = allowed_roles(spawner) 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( oauth_client = oauth_provider.add_client(
client_id, client_id,
api_token, api_token,
@@ -707,11 +750,11 @@ class User:
db.commit() db.commit()
except Exception as e: except Exception as e:
if isinstance(e, gen.TimeoutError): if isinstance(e, AnyTimeoutError):
self.log.warning( self.log.warning(
"{user}'s server failed to start in {s} seconds, giving up".format( f"{self.name}'s server failed to start"
user=self.name, s=spawner.start_timeout f" in {spawner.start_timeout} seconds, giving up."
) f"\n{start_timeout_message}"
) )
e.reason = 'timeout' e.reason = 'timeout'
self.settings['statsd'].incr('spawner.failure.timeout') self.settings['statsd'].incr('spawner.failure.timeout')
@@ -764,14 +807,11 @@ class User:
http=True, timeout=spawner.http_timeout, ssl_context=ssl_context http=True, timeout=spawner.http_timeout, ssl_context=ssl_context
) )
except Exception as e: except Exception as e:
if isinstance(e, TimeoutError): if isinstance(e, AnyTimeoutError):
self.log.warning( self.log.warning(
"{user}'s server never showed up at {url} " f"{self.name}'s server never showed up at {server.url}"
"after {http_timeout} seconds. Giving up".format( f" after {spawner.http_timeout} seconds. Giving up."
user=self.name, f"\n{http_timeout_message}"
url=server.url,
http_timeout=spawner.http_timeout,
)
) )
e.reason = 'timeout' e.reason = 'timeout'
self.settings['statsd'].incr('spawner.failure.http_timeout') self.settings['statsd'].incr('spawner.failure.http_timeout')

View File

@@ -23,12 +23,12 @@ from operator import itemgetter
from async_generator import aclosing from async_generator import aclosing
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from tornado import gen
from tornado import ioloop from tornado import ioloop
from tornado import web from tornado import web
from tornado.httpclient import AsyncHTTPClient from tornado.httpclient import AsyncHTTPClient
from tornado.httpclient import HTTPError from tornado.httpclient import HTTPError
from tornado.log import app_log from tornado.log import app_log
from tornado.platform.asyncio import to_asyncio_future
# For compatibility with python versions 3.6 or earlier. # 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. # 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 return ssl_context
# AnyTimeoutError catches TimeoutErrors coming from asyncio, tornado, stdlib
AnyTimeoutError = (gen.TimeoutError, asyncio.TimeoutError, TimeoutError)
async def exponential_backoff( async def exponential_backoff(
pass_func, pass_func,
fail_message, fail_message,
@@ -182,7 +186,7 @@ async def exponential_backoff(
if dt < max_wait: if dt < max_wait:
scale *= scale_factor scale *= scale_factor
await asyncio.sleep(dt) await asyncio.sleep(dt)
raise TimeoutError(fail_message) raise asyncio.TimeoutError(fail_message)
async def wait_for_server(ip, port, timeout=10): async def wait_for_server(ip, port, timeout=10):
@@ -288,6 +292,31 @@ def authenticated_403(self):
raise web.HTTPError(403) 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 @auth_decorator
def metrics_authentication(self): def metrics_authentication(self):
"""Decorator for restricting access to metrics""" """Decorator for restricting access to metrics"""

View File

@@ -5,3 +5,41 @@ target_version = [
"py37", "py37",
"py38", "py38",
] ]
[tool.tbump]
# Uncomment this if your project is hosted on GitHub:
github_url = "https://github.com/jupyterhub/jupyterhub"
[tool.tbump.version]
current = "2.0.1"
# 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}"

View File

@@ -46,10 +46,9 @@ def get_data_files():
"""Get data files in share/jupyter""" """Get data files in share/jupyter"""
data_files = [] data_files = []
ntrim = len(here + os.path.sep)
for (d, dirs, filenames) in os.walk(share_jupyterhub): 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 return data_files

File diff suppressed because one or more lines are too long

View File

@@ -19,6 +19,7 @@
<div id="react-admin-hook"> <div id="react-admin-hook">
<script id="jupyterhub-admin-config"> <script id="jupyterhub-admin-config">
window.api_page_limit = parseInt("{{ api_page_limit|safe }}") window.api_page_limit = parseInt("{{ api_page_limit|safe }}")
window.base_url = "{{ base_url|safe }}"
</script> </script>
<script src="static/js/admin-react.js"></script> <script src="static/js/admin-react.js"></script>
</div> </div>

View File

@@ -20,7 +20,7 @@
</a> </a>
</div> </div>
{% else %} {% else %}
<form action="{{login_url}}?next={{next}}" method="post" role="form"> <form action="{{authenticator_login_url}}" method="post" role="form">
<div class="auth-form-header"> <div class="auth-form-header">
Sign in Sign in
</div> </div>

View File

@@ -6,7 +6,6 @@ FROM $BASE_IMAGE
MAINTAINER Project Jupyter <jupyter@googlegroups.com> MAINTAINER Project Jupyter <jupyter@googlegroups.com>
ADD install_jupyterhub /tmp/install_jupyterhub ADD install_jupyterhub /tmp/install_jupyterhub
ARG JUPYTERHUB_VERSION=main ARG JUPYTERHUB_VERSION=git:HEAD
# install pinned jupyterhub and ensure jupyterlab is installed # install pinned jupyterhub
RUN python3 /tmp/install_jupyterhub && \ RUN python3 /tmp/install_jupyterhub
python3 -m pip install jupyterlab

View File

@@ -1,4 +0,0 @@
#!/bin/bash
set -ex
docker build --build-arg JUPYTERHUB_VERSION=$DOCKER_TAG -t $DOCKER_REPO:$DOCKER_TAG .

View File

@@ -1,21 +0,0 @@
#!/bin/bash
set -ex
function get_hub_version() {
rm -f hub_version
V=$1
docker run --rm -v $PWD:/version -u $(id -u) -i $DOCKER_REPO:$DOCKER_TAG sh -c 'jupyterhub --version > /version/hub_version'
hub_xyz=$(cat hub_version)
split=( ${hub_xyz//./ } )
hub_xy="${split[0]}.${split[1]}"
# add .dev on hub_xy so it's 1.0.dev
if [[ ! -z "${split[3]:-}" ]]; then
hub_xy="${hub_xy}.${split[3]}"
fi
}
# tag e.g. 0.9 with main
get_hub_version
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:$hub_xy
docker push $DOCKER_REPO:$hub_xy
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:$hub_xyz
docker push $DOCKER_REPO:$hub_xyz

View File

@@ -3,19 +3,22 @@ import os
from subprocess import check_call from subprocess import check_call
import sys import sys
V = os.environ['JUPYTERHUB_VERSION'] version = os.environ['JUPYTERHUB_VERSION']
pip_install = [ pip_install = [
sys.executable, '-m', 'pip', 'install', '--no-cache', '--upgrade', sys.executable,
'--upgrade-strategy', 'only-if-needed', '-m',
'pip',
'install',
'--no-cache',
'--upgrade',
'--upgrade-strategy',
'only-if-needed',
] ]
if V in {'main', 'HEAD'}: if version.startswith("git:"):
req = 'https://github.com/jupyterhub/jupyterhub/archive/HEAD.tar.gz' ref = version.partition(":")[-1]
req = f"https://github.com/jupyterhub/jupyterhub/archive/{ref}.tar.gz"
else: else:
version_info = [ int(part) for part in V.split('.') ] req = f"jupyterhub=={version}"
version_info[-1] += 1
upper_bound = '.'.join(map(str, version_info))
vs = '>=%s,<%s' % (V, upper_bound)
req = 'jupyterhub%s' % vs
check_call(pip_install + [req]) check_call(pip_install + [req])