Compare commits

...

209 Commits

Author SHA1 Message Date
Min RK
39da98f133 Bump to 2.1.1 2022-01-25 14:36:02 +01:00
Erik Sundell
29e69aa880 Merge pull request #3779 from minrk/changelog-211
changelog for 2.1.1
2022-01-25 12:18:37 +01:00
Min RK
0c315f31b7 specify nodejs, python versions in readthedocs
rather than use ancient system node

does v2 require the new .readthedocs.yaml filename?
Docs suggest it does.
2022-01-25 10:43:50 +01:00
Min RK
508842a68c changelog for 2.1.1 2022-01-25 09:37:58 +01:00
Min RK
4b31615a05 Merge pull request #3778 from minrk/missing-metrics
add missing read:metrics scope to admin role
2022-01-24 16:12:09 +01:00
Min RK
17b64280e8 add missing metrics scope to admin role
new scope defined, but not added to admin

In the future, the admin list should probably be derived automatically
2022-01-24 15:35:57 +01:00
Simon Li
4ca2344af7 Merge pull request #3777 from jupyterhub/dependabot/npm_and_yarn/jsx/nanoid-3.2.0
Bump nanoid from 3.1.23 to 3.2.0 in /jsx
2022-01-22 08:50:45 +00:00
dependabot[bot]
4c050cf165 Bump nanoid from 3.1.23 to 3.2.0 in /jsx
Bumps [nanoid](https://github.com/ai/nanoid) from 3.1.23 to 3.2.0.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.1.23...3.2.0)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-22 04:38:13 +00:00
Min RK
5e2ccb81fa Bump to 2.2.0.dev 2022-01-21 11:36:55 +01:00
Min RK
b8dc3befab Bump to 2.1.0 2022-01-21 11:35:49 +01:00
Erik Sundell
2f29848757 Merge pull request #3776 from minrk/cl21
Changelog for 2.1.0
2022-01-21 10:54:09 +01:00
Min RK
4f3d6cdd0c changelog for 2.1.0 2022-01-21 10:42:25 +01:00
Min RK
67733ef928 Merge pull request #3773 from IgorBerman/issue-3772-user_options-returns-empty-jupyterhub-restart
Using orm_spawner in server model user_options
2022-01-21 09:38:37 +01:00
Erik Sundell
e657754e7f Merge pull request #3775 from minrk/on_rtd_edit
DOCS: Add github metadata for edit button
2022-01-20 19:39:35 +01:00
Igor Berman
2d6087959c issue-3772: populating user_options from orm_spawner; adding test 2022-01-20 20:07:43 +02:00
Min RK
08a913707f define html_context needed for edit_page_button 2022-01-20 18:56:41 +01:00
Igor Berman
9c8a4f287a issue-3772: populating user_options from orm_spawner, cleanup 2022-01-20 18:04:35 +02:00
Igor Berman
64d6f0222c issue-3772: populating user_options from orm_spawner 2022-01-20 18:01:57 +02:00
Erik Sundell
538abdf084 Merge pull request #3763 from minrk/page-scopes
apply scope checks to some admin-or-self situations
2022-01-20 16:21:51 +01:00
Min RK
6e5c307edb apply scope checks to some admin-or-self pages
Some non-api spawn and redirect checks still had `self or admin`,
when they should have checked directly for the appropriate permissions

This removes the long-deprecated redirect from `/user/other` -> `/user/self` _if_ the other server is not running.
The result is a more consistent behavior whether the requested server is running or not,
and whether the user has _access_ to the running server or not.
2022-01-20 13:27:43 +01:00
Igor Berman
67ebe0b0cf Update base.py 2022-01-19 21:45:45 +02:00
Min RK
dcf21d53fd Merge pull request #3765 from twalcari/patch-2
Improve documentation about spawner exception handling
2022-01-19 10:01:51 +01:00
Erik Sundell
f5bb0a2622 Merge pull request #3770 from minrk/metrics-scope
Add `read:metrics` scope for metrics endpoint
2022-01-18 17:51:50 +01:00
Min RK
704712cc81 Add read:metrics scope for metrics endpoint
and ensure token auth is accepted
2022-01-18 15:02:24 +01:00
Erik Sundell
f86d53a234 Merge pull request #3764 from minrk/progress-error-message
relay custom messages in exception.jupyterhub_message in progress API
2022-01-18 13:18:29 +01:00
Thijs Walcarius
5466224988 Improve documentation about spawner error messages 2022-01-18 09:18:01 +01:00
Min RK
f9fa21bfd7 relay custom messages in exception.jupyterhub_message in progress API
matches the message shown on the HTML spawn-failed page

For consistency, also support `jupyterhub_html_message` to populate the `html_message` field
2022-01-18 09:15:58 +01:00
Simon Li
e4855c30f5 Merge pull request #3768 from jupyterhub/dependabot/npm_and_yarn/jsx/follow-redirects-1.14.7
Bump follow-redirects from 1.13.0 to 1.14.7 in /jsx
2022-01-15 13:56:47 +00:00
dependabot[bot]
f1c4fdd5a2 Bump follow-redirects from 1.13.0 to 1.14.7 in /jsx
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.13.0 to 1.14.7.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.13.0...v1.14.7)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-15 08:58:31 +00:00
Min RK
e58cf06706 Merge pull request #3762 from DougTrajano/main
Add the capability to inform a connection to Alembic Migration Script
2022-01-12 14:02:09 +01:00
pre-commit-ci[bot]
91f4918cff [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-01-11 11:55:37 +00:00
Douglas Trajano
b15ccfa4ae Add connection parameter 2022-01-11 08:50:20 -03:00
Min RK
5102fde2f0 Bump to 2.1.0.dev 2022-01-10 13:54:49 +01:00
Min RK
f5dc005a70 Bump to 2.0.2 2022-01-10 13:54:24 +01:00
Min RK
5fd8f0f596 Merge pull request #3759 from minrk/cl-202
changelog for 2.0.2
2022-01-10 13:53:34 +01:00
Min RK
26ceafa8a3 changelog for 2.0.2 2022-01-10 13:30:14 +01:00
Min RK
2e2ed8a4ff Merge pull request #3760 from minrk/admin-th-macro
remove unused macro with missing references
2022-01-10 13:28:10 +01:00
Min RK
6cc734f884 Merge pull request #3750 from consideRatio/pr/ci-refactor-docs-workflows
ci: refactor to avoid triggering all tests on changes to docs
2022-01-10 13:27:57 +01:00
Erik Sundell
4f7f07d3b7 Fix missing docs requirements 2022-01-10 11:18:22 +01:00
Min RK
d436c97e3d remove unused macro with missing references
The th macro is unused and doesn't work
because it references `sort` template variable,
which has been removed
2022-01-10 11:09:34 +01:00
Erik Sundell
807c5b8ff9 Make the generate-scope-table script autoformat its output 2022-01-10 10:48:01 +01:00
Erik Sundell
8da06d1259 Fix git CLI flag ordering 2022-01-10 10:33:23 +01:00
Erik Sundell
1c1be8a24b Generate yaml formatted to match prettier better 2022-01-10 10:31:30 +01:00
Min RK
897606b00c Merge pull request #3754 from jupyterhub/doc-theme-config
DOCS: Update theme configuration
2022-01-10 09:34:51 +01:00
Simon Li
615af5eb33 Merge pull request #3757 from minrk/get-browser-proto
use outermost proxied entry when looking up browser protocol
2022-01-09 22:44:07 +00:00
Erik Sundell
85f94c12fc Merge pull request #3748 from jupyterhub/DOC-allowed-users
DOC: Add note about allowed_users not being set
2022-01-08 18:59:24 +01:00
Min RK
ccfee4d235 use outermost proxied entry when checking for browser protocol
wee care about what the browser sees, so trust the outermost entry instead of the innermost

This is not secure _in general_, in that these values can be spoofed by malicious proxies,
but for CORS and cookie purposes, we only care about what the browser sees,
however many hops there may be.

A malicious proxy in the chain here isn't a concern because what matters is the immediate
hop from the _browser_, not the immediate hop from the _server_.
2022-01-07 14:03:11 +01:00
Min RK
a2ba55756d Merge pull request #3746 from manics/more-cors-tests
Extra test_cors_check tests
2022-01-07 12:37:37 +01:00
pre-commit-ci[bot]
1b3e94db6c [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-01-04 22:23:45 +00:00
Chris Holdgraf
614d9d89d0 DOCS: Update theme configuration 2022-01-04 14:22:45 -08:00
Chris Holdgraf
05a3f5aa9a Update docs/source/getting-started/authenticators-users-basics.md
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2022-01-04 13:32:39 -08:00
Erik Sundell
4f47153123 ci: cleanup comments for readability 2022-01-04 00:53:33 +01:00
Erik Sundell
a14d9ecaa1 ci: refactor to avoid triggering all tests on changes to docs 2022-01-04 00:53:33 +01:00
Erik Sundell
6815f30d36 Merge pull request #3749 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-01-03 22:33:13 +01:00
pre-commit-ci[bot]
13172e6856 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-01-03 21:06:46 +00:00
pre-commit-ci[bot]
ebc9fd7758 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.29.1 → v2.31.0](https://github.com/asottile/pyupgrade/compare/v2.29.1...v2.31.0)
2022-01-03 21:06:11 +00:00
Chris Holdgraf
0761a5db02 DOC: Add note about allowed_users not being set 2022-01-03 10:27:10 -08:00
Erik Sundell
46e7a231fe Merge pull request #3747 from minrk/https-typo
localhost URL is http, not https
2022-01-03 15:54:14 +01:00
Min RK
ffa5a20e2f localhost URL is not https 2022-01-03 15:41:54 +01:00
Simon Li
2088a57ffe Extra test_cors_check tests 2022-01-03 13:55:04 +00:00
Erik Sundell
345805781f Merge pull request #3740 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-12-27 22:53:25 +01:00
pre-commit-ci[bot]
9eb52ea788 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.0.1 → v4.1.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.0.1...v4.1.0)
2021-12-27 21:10:45 +00:00
Min RK
fb1405ecd8 Bump to 2.1.0.dev 2021-12-22 14:16:34 +01:00
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
93 changed files with 5299 additions and 2489 deletions

View File

@@ -1,15 +1,32 @@
# Build releases and (on tags) publish to PyPI
# This is a GitHub workflow defining a set of jobs with a set of steps.
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
#
# Test build release artifacts (PyPI package, Docker images) and publish them on
# pushed git tags.
#
name: Release
# always build releases (to make sure wheel-building works)
# but only publish to PyPI on tags
on:
push:
branches:
- "!dependabot/**"
tags:
- "*"
pull_request:
paths-ignore:
- "docs/**"
- "**.md"
- "**.rst"
- ".github/workflows/*"
- "!.github/workflows/release.yml"
push:
paths-ignore:
- "docs/**"
- "**.md"
- "**.rst"
- ".github/workflows/*"
- "!.github/workflows/release.yml"
branches-ignore:
- "dependabot/**"
- "pre-commit-ci-update-config"
tags:
- "**"
workflow_dispatch:
jobs:
build-release:
@@ -96,7 +113,6 @@ jobs:
# Setup docker to build for multiple platforms, see:
# https://github.com/docker/build-push-action/tree/v2.4.0#usage
# https://github.com/docker/build-push-action/blob/v2.4.0/docs/advanced/multi-platform.md
- name: Set up QEMU (for docker buildx)
uses: docker/setup-qemu-action@25f0500ff22e406f7191a2a8ba8cda16901ca018 # associated tag: v1.0.2
@@ -120,6 +136,8 @@ jobs:
run: |
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}"
# image: jupyterhub/jupyterhub
#
# https://github.com/jupyterhub/action-major-minor-tag-calculator
# If this is a tagged build this will return additional parent tags.
# E.g. 1.2.3 is expanded to Docker tags
@@ -129,7 +147,7 @@ jobs:
# If GITHUB_TOKEN isn't available (e.g. in PRs) returns no tags [].
- name: Get list of jupyterhub tags
id: jupyterhubtags
uses: jupyterhub/action-major-minor-tag-calculator@v1
uses: jupyterhub/action-major-minor-tag-calculator@v2
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub:"
@@ -137,7 +155,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -146,11 +164,11 @@ jobs:
# array into a comma separated list of tags
tags: ${{ join(fromJson(steps.jupyterhubtags.outputs.tags)) }}
# jupyterhub-onbuild
# image: jupyterhub/jupyterhub-onbuild
#
- name: Get list of jupyterhub-onbuild tags
id: onbuildtags
uses: jupyterhub/action-major-minor-tag-calculator@v1
uses: jupyterhub/action-major-minor-tag-calculator@v2
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:"
@@ -158,7 +176,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub-onbuild
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
with:
build-args: |
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
@@ -167,11 +185,11 @@ jobs:
push: true
tags: ${{ join(fromJson(steps.onbuildtags.outputs.tags)) }}
# jupyterhub-demo
# image: jupyterhub/jupyterhub-demo
#
- name: Get list of jupyterhub-demo tags
id: demotags
uses: jupyterhub/action-major-minor-tag-calculator@v1
uses: jupyterhub/action-major-minor-tag-calculator@v2
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:"
@@ -179,7 +197,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub-demo
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
with:
build-args: |
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
@@ -190,3 +208,24 @@ jobs:
platforms: linux/amd64
push: true
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}
# image: jupyterhub/singleuser
#
- name: Get list of jupyterhub/singleuser tags
id: singleusertags
uses: jupyterhub/action-major-minor-tag-calculator@v2
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/singleuser:"
defaultTag: "${{ env.REGISTRY }}jupyterhub/singleuser:noref"
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub/singleuser
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
with:
build-args: |
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
context: singleuser
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ join(fromJson(steps.singleusertags.outputs.tags)) }}

31
.github/workflows/support-bot.yml vendored Normal file
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"

64
.github/workflows/test-docs.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
# This is a GitHub workflow defining a set of jobs with a set of steps.
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
#
# This workflow validates the REST API definition and runs the pytest tests in
# the docs/ folder. This workflow does not build the documentation. That is
# instead tested via ReadTheDocs (https://readthedocs.org/projects/jupyterhub/).
#
name: Test docs
# The tests defined in docs/ are currently influenced by changes to _version.py
# and scopes.py.
on:
pull_request:
paths:
- "docs/**"
- "jupyterhub/_version.py"
- "jupyterhub/scopes.py"
- ".github/workflows/*"
- "!.github/workflows/test-docs.yml"
push:
paths:
- "docs/**"
- "jupyterhub/_version.py"
- "jupyterhub/scopes.py"
- ".github/workflows/*"
- "!.github/workflows/test-docs.yml"
branches-ignore:
- "dependabot/**"
- "pre-commit-ci-update-config"
tags:
- "**"
workflow_dispatch:
env:
# UTF-8 content may be interpreted as ascii and causes errors without this.
LANG: C.UTF-8
PYTEST_ADDOPTS: "--verbose --color=yes"
jobs:
validate-rest-api-definition:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Validate REST API definition
uses: char0n/swagger-editor-validate@182d1a5d26ff5c2f4f452c43bd55e2c7d8064003
with:
definition-file: docs/source/_static/rest-api.yml
test-docs:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: "3.9"
- name: Install requirements
run: |
pip install -r docs/requirements.txt pytest -e .
- name: pytest docs/
run: |
pytest docs/

View File

@@ -1,25 +1,67 @@
# This is a GitHub workflow defining a set of jobs with a set of steps.
# ref: https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
#
name: Test
# Trigger the workflow's on all PRs but only on pushed tags or commits to
# main/master branch to avoid PRs developed in a GitHub fork's dedicated branch
# to trigger.
on:
pull_request:
paths-ignore:
- "docs/**"
- "**.md"
- "**.rst"
- ".github/workflows/*"
- "!.github/workflows/test.yml"
push:
paths-ignore:
- "docs/**"
- "**.md"
- "**.rst"
- ".github/workflows/*"
- "!.github/workflows/test.yml"
branches-ignore:
- "dependabot/**"
- "pre-commit-ci-update-config"
tags:
- "**"
workflow_dispatch:
env:
# UTF-8 content may be interpreted as ascii and causes errors without this.
LANG: C.UTF-8
PYTEST_ADDOPTS: "--verbose --color=yes"
jobs:
jstest:
# Run javascript tests
runs-on: ubuntu-20.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v2
# NOTE: actions/setup-node@v1 make use of a cache within the GitHub base
# environment and setup in a fraction of a second.
- name: Install Node
uses: actions/setup-node@v1
with:
node-version: "14"
- name: Install Node dependencies
run: |
npm install -g yarn
- name: Run yarn
run: |
cd jsx
yarn
- name: yarn test
run: |
cd jsx
yarn test
# Run "pytest jupyterhub/tests" in various configurations
pytest:
runs-on: ubuntu-20.04
timeout-minutes: 10
timeout-minutes: 15
strategy:
# Keep running even if one variation of the job fail
@@ -106,7 +148,6 @@ jobs:
run: |
npm install
npm install -g configurable-http-proxy
npm install -g yarn
npm list
# NOTE: actions/setup-python@v2 make use of a cache within the GitHub base
@@ -169,26 +210,25 @@ jobs:
if: ${{ matrix.db }}
run: |
if [ "${{ matrix.db }}" == "mysql" ]; then
if [[ -z "$(which mysql)" ]]; then
sudo apt-get update
sudo apt-get install -y mysql-client
fi
DB=mysql bash ci/docker-db.sh
DB=mysql bash ci/init-db.sh
fi
if [ "${{ matrix.db }}" == "postgres" ]; then
if [[ -z "$(which psql)" ]]; then
sudo apt-get update
sudo apt-get install -y postgresql-client
fi
DB=postgres bash ci/docker-db.sh
DB=postgres bash ci/init-db.sh
fi
- name: Run pytest
# FIXME: --color=yes explicitly set because:
# https://github.com/actions/runner/issues/241
run: |
pytest -v --maxfail=2 --color=yes --cov=jupyterhub jupyterhub/tests
- name: Run yarn jest test
run: |
cd jsx && yarn && yarn test
pytest --maxfail=2 --cov=jupyterhub jupyterhub/tests
- name: Submit codecov report
run: |
codecov

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/asottile/pyupgrade
rev: v2.29.0
rev: v2.31.0
hooks:
- id: pyupgrade
args:
@@ -10,11 +10,11 @@ repos:
hooks:
- id: reorder-python-imports
- repo: https://github.com/psf/black
rev: 21.9b0
rev: 21.12b0
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.4.1
rev: v2.5.1
hooks:
- id: prettier
- repo: https://github.com/PyCQA/flake8
@@ -22,7 +22,7 @@ repos:
hooks:
- id: flake8
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
rev: v4.1.0
hooks:
- id: end-of-file-fixer
- id: check-case-conflict

View File

@@ -4,10 +4,12 @@ sphinx:
configuration: docs/source/conf.py
build:
image: latest
os: ubuntu-20.04
tools:
nodejs: "16"
python: "3.9"
python:
version: 3.7
install:
- method: pip
path: .

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.
JupyterHub also provides a
[REST API](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/HEAD/docs/rest-api.yml#/default)
[REST API][]
for administration of the Hub and its users.
[rest api]: https://juptyerhub.readthedocs.io/en/latest/reference/rest-api.html
## Installation
### Check prerequisites
@@ -115,8 +117,7 @@ To start the Hub server, run the command:
jupyterhub
Visit `https://localhost:8000` in your browser, and sign in with your unix
PAM credentials.
Visit `http://localhost:8000` in your browser, and sign in with your system username and password.
_Note_: To allow multiple users to sign in to the server, you will need to
run the `jupyterhub` command as a _privileged user_, such as root.
@@ -239,7 +240,7 @@ You can also talk with us on our JupyterHub [Gitter](https://gitter.im/jupyterhu
- [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
- [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial)
- [Documentation for JupyterHub](https://jupyterhub.readthedocs.io/en/latest/) | [PDF (latest)](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf) | [PDF (stable)](https://media.readthedocs.org/pdf/jupyterhub/stable/jupyterhub.pdf)
- [Documentation for JupyterHub's REST API](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/HEAD/docs/rest-api.yml#/default)
- [Documentation for JupyterHub's REST API][rest api]
- [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)
- [Project Jupyter website](https://jupyter.org)
- [Project Jupyter community](https://jupyter.org/community)

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

View File

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

View File

@@ -53,14 +53,6 @@ help:
clean:
rm -rf $(BUILDDIR)/*
node_modules: package.json
npm install && touch node_modules
rest-api: source/_static/rest-api/index.html
source/_static/rest-api/index.html: rest-api.yml node_modules
npm run rest-api
metrics: source/reference/metrics.rst
source/reference/metrics.rst: generate-metrics.py
@@ -71,7 +63,7 @@ scopes: source/rbac/scope-table.md
source/rbac/scope-table.md: source/rbac/generate-scope-table.py
python3 source/rbac/generate-scope-table.py
html: rest-api metrics scopes
html: metrics scopes
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."

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,12 +1,12 @@
-r ../requirements.txt
alabaster_jupyterhub
# Temporary fix of #3021. Revert back to released autodoc-traits when
# 0.1.0 released.
https://github.com/jupyterhub/autodoc-traits/archive/d22282c1c18c6865436e06d8b329c06fe12a07f8.zip
autodoc-traits
myst-parser
pre-commit
pydata-sphinx-theme
pytablewriter>=0.56
ruamel.yaml
sphinx>=1.7
sphinx-copybutton
sphinx-jsonschema

File diff suppressed because it is too large Load Diff

View File

@@ -2,3 +2,9 @@
.navbar-brand {
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
====================

View File

@@ -17,11 +17,6 @@ information on:
- making an API request programmatically using the requests library
- learning more about JupyterHub's API
The same JupyterHub API spec, as found here, is available in an interactive form
`here (on swagger's petstore) <https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#!/default>`__.
The `OpenAPI Initiative`_ (fka Swagger™) is a project used to describe
and document RESTful APIs.
JupyterHub API Reference:
.. toctree::

File diff suppressed because one or more lines are too long

View File

@@ -130,6 +130,30 @@ html_static_path = ['_static']
htmlhelp_basename = 'JupyterHubdoc'
html_theme_options = {
"icon_links": [
{
"name": "GitHub",
"url": "https://github.com/jupyterhub/jupyterhub",
"icon": "fab fa-github-square",
},
{
"name": "Discourse",
"url": "https://discourse.jupyter.org/c/jupyterhub/10",
"icon": "fab fa-discourse",
},
],
"use_edit_page_button": True,
"navbar_align": "left",
}
html_context = {
"github_user": "jupyterhub",
"github_repo": "jupyterhub",
"github_version": "main",
"doc_path": "docs",
}
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
@@ -205,7 +229,10 @@ epub_exclude_files = ['search.html']
# -- Intersphinx ----------------------------------------------------------
intersphinx_mapping = {'https://docs.python.org/3/': None}
intersphinx_mapping = {
'python': ('https://docs.python.org/3/', None),
'tornado': ('https://www.tornadoweb.org/en/stable/', None),
}
# -- Read The Docs --------------------------------------------------------
@@ -215,7 +242,7 @@ if on_rtd:
# build both metrics and rest-api, since RTD doesn't run make
from subprocess import check_call as sh
sh(['make', 'metrics', 'rest-api', 'scopes'], cwd=docs)
sh(['make', 'metrics', 'scopes'], cwd=docs)
# -- Spell checking -------------------------------------------------------

View File

@@ -16,6 +16,10 @@ c.Authenticator.allowed_users = {'mal', 'zoe', 'inara', 'kaylee'}
Users in the `allowed_users` set are added to the Hub database when the Hub is
started.
```{warning}
If this configuration value is not set, then **all authenticated users will be allowed into your hub**.
```
## Configure admins (`admin_users`)
```{note}

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
For convenient administration of the Hub, its users, and services,
JupyterHub also provides a `REST API`_.
JupyterHub also provides a :doc:`REST API <reference/rest-api>`.
The JupyterHub team and Project Jupyter value our community, and JupyterHub
follows the Jupyter `Community Guides <https://jupyter.readthedocs.io/en/latest/community/content-community.html>`_.
@@ -155,4 +155,3 @@ Questions? Suggestions?
.. _JupyterHub: https://github.com/jupyterhub/jupyterhub
.. _Jupyter notebook: https://jupyter-notebook.readthedocs.io/en/latest/
.. _REST API: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#!/default

View File

@@ -1,14 +1,33 @@
"""
This script updates two files with the RBAC scope descriptions found in
`scopes.py`.
The files are:
1. scope-table.md
This file is git ignored and referenced by the documentation.
2. rest-api.yml
This file is JupyterHub's REST API schema. Both a version and the RBAC
scopes descriptions are updated in it.
"""
import os
from collections import defaultdict
from pathlib import Path
from subprocess import run
from pytablewriter import MarkdownTableWriter
from ruamel.yaml import YAML
from jupyterhub import __version__
from jupyterhub.scopes import scope_definitions
HERE = os.path.abspath(os.path.dirname(__file__))
PARENT = Path(HERE).parent.parent.absolute()
DOCS = Path(HERE).parent.parent.absolute()
REST_API_YAML = DOCS.joinpath("source", "_static", "rest-api.yml")
SCOPE_TABLE_MD = Path(HERE).joinpath("scope-table.md")
class ScopeTableGenerator:
@@ -80,8 +99,9 @@ class ScopeTableGenerator:
return table_rows
def write_table(self):
"""Generates the scope table in markdown format and writes it into `scope-table.md`"""
filename = f"{HERE}/scope-table.md"
"""Generates the RBAC scopes reference documentation as a markdown table
and writes it to the .gitignored `scope-table.md`."""
filename = SCOPE_TABLE_MD
table_name = ""
headers = ["Scope", "Grants permission to:"]
values = self._parse_scopes()
@@ -97,23 +117,38 @@ class ScopeTableGenerator:
)
def write_api(self):
"""Generates the API description in markdown format and writes it into `rest-api.yml`"""
filename = f"{PARENT}/rest-api.yml"
yaml = YAML(typ='rt')
"""Loads `rest-api.yml` and writes it back with a dynamically set
JupyterHub version field and list of RBAC scopes descriptions from
`scopes.py`."""
filename = REST_API_YAML
yaml = YAML(typ="rt")
yaml.preserve_quotes = True
yaml.indent(mapping=2, offset=2, sequence=4)
scope_dict = {}
with open(filename, 'r+') as f:
with open(filename) as f:
content = yaml.load(f.read())
f.seek(0)
for scope in self.scopes:
description = self.scopes[scope]['description']
doc_description = self.scopes[scope].get('doc_description', '')
if doc_description:
description = doc_description
scope_dict[scope] = description
content['securityDefinitions']['oauth2']['scopes'] = scope_dict
content["info"]["version"] = __version__
for scope in self.scopes:
description = self.scopes[scope]['description']
doc_description = self.scopes[scope].get('doc_description', '')
if doc_description:
description = doc_description
scope_dict[scope] = description
content['components']['securitySchemes']['oauth2']['flows'][
'authorizationCode'
]['scopes'] = scope_dict
with open(filename, 'w') as f:
yaml.dump(content, f)
f.truncate()
run(
['pre-commit', 'run', 'prettier', '--files', filename],
cwd=HERE,
check=False,
)
def main():

View File

@@ -123,13 +123,13 @@ has,
define the `server` role.
To restore the JupyterHub 1.x behavior of servers being able to do anything their owners can do,
use the scope `all`:
use the scope `inherit` (for 'inheriting' the owner's permissions):
```python
c.JupyterHub.load_roles = [
{
'name': 'server',
'scopes': ['all'],
'scopes': ['inherit'],
}
]
```

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:
```bash
RewriteRule /jhub/(.*) ws://127.0.0.1:8000/jhub/$1 [NE.P,L]
RewriteRule /jhub/(.*) ws://127.0.0.1:8000/jhub/$1 [NE,P,L]
RewriteRule /jhub/(.*) http://127.0.0.1:8000/jhub/$1 [NE,P,L]
ProxyPass /jhub/ http://127.0.0.1:8000/jhub/

View File

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

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
This section will give you information on:
@@ -302,12 +304,8 @@ or kubernetes pods.
## Learn more about the API
You can see the full [JupyterHub REST API][] for details. This REST API Spec can
be viewed in a more [interactive style on swagger's petstore][].
Both resources contain the same information and differ only in its display.
Note: The Swagger specification is being renamed the [OpenAPI Initiative][].
You can see the full [JupyterHub REST API][] for details.
[interactive style on swagger's petstore]: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#!/default
[openapi initiative]: https://www.openapis.org/
[jupyterhub rest api]: ./rest-api
[jupyter notebook rest api]: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/notebook/HEAD/notebook/services/api/api.yaml

View File

@@ -1,17 +1,5 @@
# Services
With version 0.7, JupyterHub adds support for **Services**.
This section provides the following information about Services:
- [Definition of a Service](#definition-of-a-service)
- [Properties of a Service](#properties-of-a-service)
- [Hub-Managed Services](#hub-managed-services)
- [Launching a Hub-Managed Service](#launching-a-hub-managed-service)
- [Externally-Managed Services](#externally-managed-services)
- [Writing your own Services](#writing-your-own-services)
- [Hub Authentication and Services](#hub-authentication-and-services)
## Definition of a Service
When working with JupyterHub, a **Service** is defined as a process that interacts
@@ -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:
(service-env)=
```bash
JUPYTERHUB_SERVICE_NAME: The name of 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
JupyterHub 0.7 introduces some utilities for using the Hub's authentication
mechanism to govern access to your service. When a user logs into JupyterHub,
the Hub sets a **cookie (`jupyterhub-services`)**. The service can use this
cookie to authenticate requests.
JupyterHub provides some utilities for using the Hub's authentication
mechanism to govern access to your service.
JupyterHub ships with a reference implementation of Hub authentication that
Requests to all JupyterHub services are made with OAuth tokens.
These can either be requests with a token in the `Authorization` header,
or url parameter `?token=...`,
or browser requests which must complete the OAuth authorization code flow,
which results in a token that should be persisted for future requests
(persistence is up to the service,
but an encrypted cookie confined to the service path is appropriate,
and provided by default).
:::{versionchanged} 2.0
The shared `jupyterhub-services` cookie is removed.
OAuth must be used to authenticate browser requests with services.
:::
JupyterHub includes a reference implementation of Hub authentication that
can be used by services. You may go beyond this reference implementation and
create custom hub-authenticating clients and services. We describe the process
below.
The reference, or base, implementation is the [`HubAuth`][hubauth] class,
which implements the requests to the Hub.
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,
or via the `JUPYTERHUB_API_TOKEN` environment variable.
@@ -250,18 +260,17 @@ for more details.
### Authenticating tornado services with JupyterHub
Since most Jupyter services are written with tornado,
we include a mixin class, [`HubAuthenticated`][hubauthenticated],
we include a mixin class, [`HubOAuthenticated`][huboauthenticated],
for quickly authenticating your own tornado services with JupyterHub.
Tornado's `@web.authenticated` method calls a Handler's `.get_current_user`
method to identify the user. Mixing in `HubAuthenticated` defines
`get_current_user` to use HubAuth. If you want to configure the HubAuth
instance beyond the default, you'll want to define an `initialize` method,
Tornado's {py:func}`~.tornado.web.authenticated` decorator calls a Handler's {py:meth}`~.tornado.web.RequestHandler.get_current_user`
method to identify the user. Mixing in {class}`.HubAuthenticated` defines
{meth}`~.HubAuthenticated.get_current_user` to use HubAuth. If you want to configure the HubAuth
instance beyond the default, you'll want to define an {py:meth}`~.tornado.web.RequestHandler.initialize` method,
such as:
```python
class MyHandler(HubAuthenticated, web.RequestHandler):
hub_users = {'inara', 'mal'}
class MyHandler(HubOAuthenticated, web.RequestHandler):
def initialize(self, hub_auth):
self.hub_auth = hub_auth
@@ -271,14 +280,21 @@ class MyHandler(HubAuthenticated, web.RequestHandler):
...
```
The HubAuth will automatically load the desired configuration from the Service
environment variables.
The HubAuth class will automatically load the desired configuration from the Service
[environment variables](service-env).
If you want to limit user access, you can specify allowed users through either the
`.hub_users` attribute or `.hub_groups`. These are sets that check against the
username and user group list, respectively. If a user matches neither the user
list nor the group list, they will not be allowed access. If both are left
undefined, then any user will be allowed.
:::{versionchanged} 2.0
Access scopes are used to govern access to services.
Prior to 2.0,
sets of users and groups could be used to grant access
by defining `.hub_groups` or `.hub_users` on the authenticated handler.
These are ignored if the 2.0 `.hub_scopes` is defined.
:::
:::{seealso}
{meth}`.HubAuth.check_scopes`
:::
### Implementing your own Authentication with JupyterHub
@@ -328,7 +344,7 @@ and taking note of the following process:
```python
{
"name": "inara",
# groups may be omitted, depending on permissions
# groups may be omitted, depending on permissions
"groups": ["serenity", "guild"],
# scopes is new in JupyterHub 2.0
"scopes": [
@@ -354,9 +370,11 @@ section on securing the notebook viewer.
[requests]: http://docs.python-requests.org/en/master/
[services_auth]: ../api/services.auth.html
[hubauth]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth
[huboauth]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuth
[hubauth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
[hubauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
[huboauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuthenticated
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
[fastapi]: https://fastapi.tiangolo.com

View File

@@ -108,6 +108,16 @@ class MySpawner(Spawner):
return url
```
#### Exception handling
When `Spawner.start` raises an Exception, a message can be passed on to the user via the exception via a `.jupyterhub_html_message` or `.jupyterhub_message` attribute.
When the Exception has a `.jupyterhub_html_message` attribute, it will be rendered as HTML to the user.
Alternatively `.jupyterhub_message` is rendered as unformatted text.
If both attributes are not present, the Exception will be shown to the user as unformatted text.
### Spawner.poll
`Spawner.poll` should check if the spawner is still running.

46
docs/test_docs.py Normal file
View File

@@ -0,0 +1,46 @@
import sys
from pathlib import Path
from subprocess import run
from ruamel.yaml import YAML
yaml = YAML(typ="safe")
here = Path(__file__).absolute().parent
root = here.parent
def test_rest_api_version_is_updated():
"""Checks that the version in JupyterHub's REST API definition file
(rest-api.yml) is matching the JupyterHub version."""
version_py = root.joinpath("jupyterhub", "_version.py")
rest_api_yaml = root.joinpath("docs", "source", "_static", "rest-api.yml")
ns = {}
with version_py.open() as f:
exec(f.read(), {}, ns)
jupyterhub_version = ns["__version__"]
with rest_api_yaml.open() as f:
rest_api = yaml.load(f)
rest_api_version = rest_api["info"]["version"]
assert jupyterhub_version == rest_api_version
def test_rest_api_rbac_scope_descriptions_are_updated():
"""Checks that the RBAC scope descriptions in JupyterHub's REST API
definition file (rest-api.yml) as can be updated by generate-scope-table.py
matches what is committed."""
run([sys.executable, "source/rbac/generate-scope-table.py"], cwd=here, check=True)
run(
[
"git",
"--no-pager",
"diff",
"--color=always",
"--exit-code",
str(here.joinpath("source", "_static", "rest-api.yml")),
],
cwd=here,
check=True,
)

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
jupyterhub --ip=127.0.0.1
jupyterhub
2. Visit http://127.0.0.1:8000/services/whoami-oauth
After logging in with your local-system credentials, you should see a JSON dump of your user info:
After logging in with any username and password, you should see a JSON dump of your user info:
```json
{
"admin": false,
"last_activity": "2016-05-27T14:05:18.016372",
"groups": [],
"kind": "user",
"name": "queequeg",
"pending": null,
"server": "/user/queequeg"
"scopes": ["access:services!service=whoami-oauth"],
"session_id": "5a2164273a7346728873bcc2e3c26415"
}
```
What is contained in the model will depend on the permissions
requested in the `oauth_roles` configuration of the service `whoami-oauth` service.
The default is the minimum required for identification and access to the service,
which will provide the username and current scopes.
The `whoami-api` service powered by the base `HubAuthenticated` class only supports token-authenticated API requests,
not browser visits, because it does not implement OAuth. Visit it by requesting an api token from the tokens page,
not browser visits, because it does not implement OAuth. Visit it by requesting an api token from the tokens page (`/hub/token`),
and making a direct request:
```bash
$ curl -H "Authorization: token 8630bbd8ef064c48b22c7f122f0cd8ad" http://127.0.0.1:8000/services/whoami-api/ | jq .
token="d584cbc5bba2430fb153aadb305029b4"
curl -H "Authorization: token $token" http://127.0.0.1:8000/services/whoami-api/ | jq .
```
```json
{
"admin": false,
"created": "2021-05-21T09:47:41.299400Z",
"created": "2021-12-20T09:49:37.258427Z",
"groups": [],
"kind": "user",
"last_activity": "2021-05-21T09:49:08.290745Z",
"name": "test",
"last_activity": "2021-12-20T10:07:31.298056Z",
"name": "queequeg",
"pending": null,
"roles": [
"user"
],
"roles": ["user"],
"scopes": [
"access:servers!user=queequeg",
"access:services",
"access:servers!user=test",
"read:users!user=test",
"read:users:activity!user=test",
"read:users:groups!user=test",
"read:users:name!user=test",
"read:servers!user=test",
"read:tokens!user=test",
"users!user=test",
"users:activity!user=test",
"users:groups!user=test",
"users:name!user=test",
"servers!user=test",
"tokens!user=test"
"delete:servers!user=queequeg",
"read:servers!user=queequeg",
"read:tokens!user=queequeg",
"read:users!user=queequeg",
"read:users:activity!user=queequeg",
"read:users:groups!user=queequeg",
"read:users:name!user=queequeg",
"servers!user=queequeg",
"tokens!user=queequeg",
"users:activity!user=queequeg"
],
"server": null
"server": null,
"servers": {},
"session_id": null
}
```
The above is a more complete user model than the `whoami-oauth` example, because
the token was issued with the default `token` role,
which has the `inherit` metascope,
meaning the token has access to everything the tokens owner has access to.
This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
To govern access to the services, create **roles** with the scope `access:services!service=$service-name`,

View File

@@ -10,7 +10,15 @@ c.JupyterHub.services = [
'name': 'whoami-oauth',
'url': 'http://127.0.0.1:10102',
'command': [sys.executable, './whoami-oauth.py'],
'oauth_roles': ['user'],
# the default oauth roles is minimal,
# only requesting access to the service,
# and identification by name,
# nothing more.
# Specifying 'oauth_roles' as a list of role names
# allows requesting more information about users,
# or the ability to take actions on users' behalf, as required.
# The default 'token' role has the full permissions of its owner:
# 'oauth_roles': ['token'],
},
]

View File

@@ -31,6 +31,9 @@
"@babel/core": "^7.12.3",
"@babel/preset-env": "^7.12.11",
"@babel/preset-react": "^7.12.10",
"@testing-library/jest-dom": "^5.15.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"babel-loader": "^8.2.1",
"bootstrap": "^4.5.3",
"css-loader": "^5.0.1",
@@ -54,7 +57,7 @@
"webpack-dev-server": "^3.11.0"
},
"devDependencies": {
"@wojtekmaj/enzyme-adapter-react-17": "^0.4.1",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
"babel-jest": "^26.6.3",
"enzyme": "^3.11.0",
"eslint": "^7.18.0",

View File

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

View File

@@ -1,12 +1,15 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import AddUser from "./AddUser";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() });
import AddUser from "./AddUser";
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
@@ -14,64 +17,123 @@ jest.mock("react-redux", () => ({
useSelector: jest.fn(),
}));
describe("AddUser Component: ", () => {
var mockAsync = () =>
jest
.fn()
.mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
var mockAsync = (result) =>
jest.fn().mockImplementation(() => Promise.resolve(result));
var addUserJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<AddUser
addUsers={callbackSpy}
failRegexEvent={callbackSpy}
updateUsers={callbackSpy}
history={{ push: () => {} }}
/>
</HashRouter>
</Provider>
);
var mockAsyncRejection = () =>
jest.fn().mockImplementation(() => Promise.reject());
var mockAppState = () => ({
limit: 3,
var addUserJsx = (spy, spy2, spy3) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<AddUser
addUsers={spy}
failRegexEvent={spy2 || spy}
updateUsers={spy3 || spy2 || spy}
history={{ push: () => {} }}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({
limit: 3,
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => {};
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => {};
});
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
it("Renders", () => {
let component = mount(addUserJsx(mockAsync()));
expect(component.find(".container").length).toBe(1);
});
it("Removes users when they fail Regex", () => {
let callbackSpy = mockAsync(),
component = mount(addUserJsx(callbackSpy)),
textarea = component.find("textarea").first();
textarea.simulate("blur", { target: { value: "foo\nbar\n!!*&*" } });
let submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar"], false);
});
it("Correctly submits admin", () => {
let callbackSpy = mockAsync(),
component = mount(addUserJsx(callbackSpy)),
input = component.find("input").first();
input.simulate("change", { target: { checked: true } });
let submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenCalledWith([], true);
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
test("Renders", async () => {
await act(async () => {
render(addUserJsx());
});
expect(screen.getByTestId("container")).toBeVisible();
});
test("Removes users when they fail Regex", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(addUserJsx(callbackSpy));
});
let textarea = screen.getByTestId("user-textarea");
let submit = screen.getByTestId("submit");
fireEvent.blur(textarea, { target: { value: "foo\nbar\n!!*&*" } });
await act(async () => {
fireEvent.click(submit);
});
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar"], false);
});
test("Correctly submits admin", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(addUserJsx(callbackSpy));
});
let textarea = screen.getByTestId("user-textarea");
let submit = screen.getByTestId("submit");
let check = screen.getByTestId("check");
userEvent.click(check);
fireEvent.blur(textarea, { target: { value: "foo" } });
await act(async () => {
fireEvent.click(submit);
});
expect(callbackSpy).toHaveBeenCalledWith(["foo"], true);
});
test("Shows a UI error dialogue when user creation fails", async () => {
let callbackSpy = mockAsyncRejection();
await act(async () => {
render(addUserJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
await act(async () => {
fireEvent.click(submit);
});
let errorDialog = screen.getByText("Failed to create user.");
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});
test("Shows a more specific UI error dialogue when user creation returns an improper status code", async () => {
let callbackSpy = mockAsync({ status: 409 });
await act(async () => {
render(addUserJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
await act(async () => {
fireEvent.click(submit);
});
let errorDialog = screen.getByText(
"Failed to create user. User already exists."
);
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});

View File

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

View File

@@ -1,13 +1,14 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import CreateGroup from "./CreateGroup";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line
Enzyme.configure({ adapter: new Adapter() });
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
import CreateGroup from "./CreateGroup";
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
@@ -15,52 +16,100 @@ jest.mock("react-redux", () => ({
useSelector: jest.fn(),
}));
describe("CreateGroup Component: ", () => {
var mockAsync = (result) =>
jest.fn().mockImplementation(() => Promise.resolve(result));
var mockAsync = (result) =>
jest.fn().mockImplementation(() => Promise.resolve(result));
var createGroupJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<CreateGroup
createGroup={callbackSpy}
updateGroups={callbackSpy}
history={{ push: () => {} }}
/>
</HashRouter>
</Provider>
);
var mockAsyncRejection = () =>
jest.fn().mockImplementation(() => Promise.reject());
var mockAppState = () => ({
limit: 3,
var createGroupJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<CreateGroup
createGroup={callbackSpy}
updateGroups={callbackSpy}
history={{ push: () => {} }}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({
limit: 3,
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => () => {};
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => () => {};
});
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
it("Renders", () => {
let component = mount(createGroupJsx());
expect(component.find(".container").length).toBe(1);
});
it("Calls createGroup on submit", () => {
let callbackSpy = mockAsync({ status: 200 }),
component = mount(createGroupJsx(callbackSpy)),
input = component.find("input").first(),
submit = component.find("#submit").first();
input.simulate("change", { target: { value: "" } });
submit.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, "");
expect(component.find(".alert.alert-danger").length).toBe(0);
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
test("Renders", async () => {
await act(async () => {
render(createGroupJsx());
});
expect(screen.getByTestId("container")).toBeVisible();
});
test("Calls createGroup on submit", async () => {
let callbackSpy = mockAsync({ status: 200 });
await act(async () => {
render(createGroupJsx(callbackSpy));
});
let input = screen.getByTestId("group-input");
let submit = screen.getByTestId("submit");
userEvent.type(input, "groupname");
await act(async () => fireEvent.click(submit));
expect(callbackSpy).toHaveBeenNthCalledWith(1, "groupname");
});
test("Shows a UI error dialogue when group creation fails", async () => {
let callbackSpy = mockAsyncRejection();
await act(async () => {
render(createGroupJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
await act(async () => {
fireEvent.click(submit);
});
let errorDialog = screen.getByText("Failed to create group.");
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});
test("Shows a more specific UI error dialogue when user creation returns an improper status code", async () => {
let callbackSpy = mockAsync({ status: 409 });
await act(async () => {
render(createGroupJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
await act(async () => {
fireEvent.click(submit);
});
let errorDialog = screen.getByText(
"Failed to create group. Group already exists."
);
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});

View File

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

View File

@@ -1,12 +1,14 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import EditUser from "./EditUser";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils";
import { render, screen, fireEvent } from "@testing-library/react";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() });
import EditUser from "./EditUser";
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
@@ -14,67 +16,124 @@ jest.mock("react-redux", () => ({
useSelector: jest.fn(),
}));
describe("EditUser Component: ", () => {
var mockAsync = () =>
jest
.fn()
.mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
var mockSync = () => jest.fn();
var mockAsync = (data) =>
jest.fn().mockImplementation(() => Promise.resolve(data));
var editUserJsx = (callbackSpy, empty) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<EditUser
location={
empty ? {} : { state: { username: "foo", has_admin: false } }
}
deleteUser={callbackSpy}
editUser={callbackSpy}
updateUsers={callbackSpy}
history={{ push: () => {} }}
failRegexEvent={callbackSpy}
noChangeEvent={callbackSpy}
/>
</HashRouter>
</Provider>
);
var mockAsyncRejection = () =>
jest.fn().mockImplementation(() => Promise.reject());
var mockAppState = () => ({
limit: 3,
var editUserJsx = (callbackSpy, empty) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<EditUser
location={empty ? {} : { state: { username: "foo", has_admin: false } }}
deleteUser={callbackSpy}
editUser={callbackSpy}
updateUsers={callbackSpy}
history={{ push: () => {} }}
failRegexEvent={callbackSpy}
noChangeEvent={callbackSpy}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({
limit: 3,
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => {};
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => {};
});
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
it("Calls the delete user function when the button is pressed", () => {
let callbackSpy = mockAsync(),
component = mount(editUserJsx(callbackSpy)),
deleteUser = component.find("#delete-user");
deleteUser.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Submits the edits when the button is pressed", () => {
let callbackSpy = mockSync(),
component = mount(editUserJsx(callbackSpy)),
submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Doesn't render when no data is provided", () => {
let callbackSpy = mockSync(),
component = mount(editUserJsx(callbackSpy, true));
expect(component.find(".container").length).toBe(0);
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
test("Renders", async () => {
let callbackSpy = mockAsync({ key: "value", status: 200 });
await act(async () => {
render(editUserJsx(callbackSpy));
});
expect(screen.getByTestId("container")).toBeVisible();
});
test("Calls the delete user function when the button is pressed", async () => {
let callbackSpy = mockAsync({ key: "value", status: 200 });
await act(async () => {
render(editUserJsx(callbackSpy));
});
let deleteUser = screen.getByTestId("delete-user");
await act(async () => {
fireEvent.click(deleteUser);
});
expect(callbackSpy).toHaveBeenCalled();
});
test("Submits the edits when the button is pressed", async () => {
let callbackSpy = mockAsync({ key: "value", status: 200 });
await act(async () => {
render(editUserJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
await act(async () => {
fireEvent.click(submit);
});
expect(callbackSpy).toHaveBeenCalled();
});
test("Shows a UI error dialogue when user edit fails", async () => {
let callbackSpy = mockAsyncRejection();
await act(async () => {
render(editUserJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
let usernameInput = screen.getByTestId("edit-username-input");
fireEvent.blur(usernameInput, { target: { value: "whatever" } });
await act(async () => {
fireEvent.click(submit);
});
let errorDialog = screen.getByText("Failed to edit user.");
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});
test("Shows a UI error dialogue when user edit returns an improper status code", async () => {
let callbackSpy = mockAsync({ status: 409 });
await act(async () => {
render(editUserJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
let usernameInput = screen.getByTestId("edit-username-input");
fireEvent.blur(usernameInput, { target: { value: "whatever" } });
await act(async () => {
fireEvent.click(submit);
});
let errorDialog = screen.getByText("Failed to edit user.");
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});

View File

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

View File

@@ -1,100 +1,228 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import GroupEdit from "./GroupEdit";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
import { act } from "react-dom/test-utils";
import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() });
import GroupEdit from "./GroupEdit";
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useSelector: jest.fn(),
}));
describe("GroupEdit Component: ", () => {
var mockAsync = () => jest.fn().mockImplementation(() => Promise.resolve());
var mockAsync = (data) =>
jest.fn().mockImplementation(() => Promise.resolve(data));
var okPacket = new Promise((resolve) => resolve(true));
var mockAsyncRejection = () =>
jest.fn().mockImplementation(() => Promise.reject());
var groupEditJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<GroupEdit
location={{
state: {
group_data: { users: ["foo"], name: "group" },
callback: () => {},
},
}}
addToGroup={callbackSpy}
removeFromGroup={callbackSpy}
deleteGroup={callbackSpy}
history={{ push: () => callbackSpy }}
updateGroups={callbackSpy}
validateUser={jest.fn().mockImplementation(() => okPacket)}
/>
</HashRouter>
</Provider>
);
var okPacket = new Promise((resolve) => resolve(true));
var mockAppState = () => ({
limit: 3,
});
var groupEditJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<GroupEdit
location={{
state: {
group_data: { users: ["foo"], name: "group" },
callback: () => {},
},
}}
addToGroup={callbackSpy}
removeFromGroup={callbackSpy}
deleteGroup={callbackSpy}
history={{ push: () => callbackSpy }}
updateGroups={callbackSpy}
validateUser={jest.fn().mockImplementation(() => okPacket)}
/>
</HashRouter>
</Provider>
);
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
var mockAppState = () => ({
limit: 3,
});
afterEach(() => {
useSelector.mockClear();
});
it("Adds user from input to user selectables on button click", async () => {
let callbackSpy = mockAsync(),
component = mount(groupEditJsx(callbackSpy)),
input = component.find("#username-input"),
validateUser = component.find("#validate-user"),
submit = component.find("#submit");
input.simulate("change", { target: { value: "bar" } });
validateUser.simulate("click");
await act(() => okPacket);
submit.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["bar"], "group");
});
it("Removes a user recently added from input from the selectables list", () => {
let callbackSpy = mockAsync(),
component = mount(groupEditJsx(callbackSpy)),
unsubmittedUser = component.find(".item.selected").last();
unsubmittedUser.simulate("click");
expect(component.find(".item").length).toBe(1);
});
it("Grays out a user, already in the group, when unselected and calls deleteUser on submit", () => {
let callbackSpy = mockAsync(),
component = mount(groupEditJsx(callbackSpy)),
groupUser = component.find(".item.selected").first();
groupUser.simulate("click");
expect(component.find(".item.unselected").length).toBe(1);
expect(component.find(".item").length).toBe(1);
// test deleteUser call
let submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["foo"], "group");
});
it("Calls deleteGroup on button click", () => {
let callbackSpy = mockAsync(),
component = mount(groupEditJsx(callbackSpy)),
deleteGroup = component.find("#delete-group").first();
deleteGroup.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, "group");
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useSelector.mockClear();
});
test("Renders", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(groupEditJsx(callbackSpy));
});
expect(screen.getByTestId("container")).toBeVisible();
});
test("Adds user from input to user selectables on button click", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(groupEditJsx(callbackSpy));
});
let input = screen.getByTestId("username-input");
let validateUser = screen.getByTestId("validate-user");
let submit = screen.getByTestId("submit");
userEvent.type(input, "bar");
fireEvent.click(validateUser);
await act(async () => okPacket);
await act(async () => {
fireEvent.click(submit);
});
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["bar"], "group");
});
test("Removes a user recently added from input from the selectables list", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(groupEditJsx(callbackSpy));
});
let selectedUser = screen.getByText("foo");
fireEvent.click(selectedUser);
let unselectedUser = screen.getByText("foo");
expect(unselectedUser.className).toBe("item unselected");
});
test("Grays out a user, already in the group, when unselected and calls deleteUser on submit", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(groupEditJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
let groupUser = screen.getByText("foo");
fireEvent.click(groupUser);
let unselectedUser = screen.getByText("foo");
expect(unselectedUser.className).toBe("item unselected");
// test deleteUser call
await act(async () => {
fireEvent.click(submit);
});
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["foo"], "group");
});
test("Calls deleteGroup on button click", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(groupEditJsx(callbackSpy));
});
let deleteGroup = screen.getByTestId("delete-group");
await act(async () => {
fireEvent.click(deleteGroup);
});
expect(callbackSpy).toHaveBeenNthCalledWith(1, "group");
});
test("Shows a UI error dialogue when group edit fails", async () => {
let callbackSpy = mockAsyncRejection();
await act(async () => {
render(groupEditJsx(callbackSpy));
});
let groupUser = screen.getByText("foo");
fireEvent.click(groupUser);
let submit = screen.getByTestId("submit");
await act(async () => {
fireEvent.click(submit);
});
let errorDialog = screen.getByText("Failed to edit group.");
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});
test("Shows a UI error dialogue when group edit returns an improper status code", async () => {
let callbackSpy = mockAsync({ status: 403 });
await act(async () => {
render(groupEditJsx(callbackSpy));
});
let groupUser = screen.getByText("foo");
fireEvent.click(groupUser);
let submit = screen.getByTestId("submit");
await act(async () => {
fireEvent.click(submit);
});
let errorDialog = screen.getByText("Failed to edit group.");
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});
test("Shows a UI error dialogue when group delete fails", async () => {
let callbackSpy = mockAsyncRejection();
await act(async () => {
render(groupEditJsx(callbackSpy));
});
let deleteGroup = screen.getByTestId("delete-group");
await act(async () => {
fireEvent.click(deleteGroup);
});
let errorDialog = screen.getByText("Failed to delete group.");
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});
test("Shows a UI error dialogue when group delete returns an improper status code", async () => {
let callbackSpy = mockAsync({ status: 403 });
await act(async () => {
render(groupEditJsx(callbackSpy));
});
let deleteGroup = screen.getByTestId("delete-group");
await act(async () => {
fireEvent.click(deleteGroup);
});
let errorDialog = screen.getByText("Failed to delete group.");
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});

View File

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

View File

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

View File

@@ -1,12 +1,14 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import Groups from "./Groups";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils";
import { render, screen } from "@testing-library/react";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() });
import Groups from "./Groups";
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
@@ -14,52 +16,75 @@ jest.mock("react-redux", () => ({
useDispatch: jest.fn(),
}));
describe("Groups Component: ", () => {
var mockAsync = () =>
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
var mockAsync = () =>
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
var groupsJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Groups location={{ search: "0" }} updateGroups={callbackSpy} />
</HashRouter>
</Provider>
);
var groupsJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Groups location={{ search: "0" }} updateGroups={callbackSpy} />
</HashRouter>
</Provider>
);
var mockAppState = () => ({
user_data: JSON.parse(
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
),
groups_data: JSON.parse(
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
),
var mockAppState = () => ({
user_data: JSON.parse(
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
),
groups_data: JSON.parse(
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
),
limit: 10,
});
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
useDispatch.mockImplementation(() => {
return () => {};
});
});
afterEach(() => {
useSelector.mockClear();
});
it("Renders groups_data prop into links", () => {
let callbackSpy = mockAsync(),
component = mount(groupsJsx(callbackSpy)),
links = component.find("li");
expect(links.length).toBe(2);
});
it("Renders nothing if required data is not available", () => {
useSelector.mockImplementation((callback) => {
return callback({});
});
let component = mount(groupsJsx());
expect(component.html()).toBe("<div></div>");
useDispatch.mockImplementation(() => {
return () => {};
});
});
afterEach(() => {
useSelector.mockClear();
});
test("Renders", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(groupsJsx(callbackSpy));
});
expect(screen.getByTestId("container")).toBeVisible();
});
test("Renders groups_data prop into links", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(groupsJsx(callbackSpy));
});
let testgroup = screen.getByText("testgroup");
let testgroup2 = screen.getByText("testgroup2");
expect(testgroup).toBeVisible();
expect(testgroup2).toBeVisible();
});
test("Renders nothing if required data is not available", async () => {
useSelector.mockImplementation((callback) => {
return callback({});
});
let callbackSpy = mockAsync();
await act(async () => {
render(groupsJsx(callbackSpy));
});
let noShow = screen.getByTestId("no-show");
expect(noShow).toBeVisible();
});

View File

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

View File

@@ -1,161 +1,437 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import ServerDashboard from "./ServerDashboard";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils";
import { render, screen, fireEvent } from "@testing-library/react";
import { HashRouter, Switch } from "react-router-dom";
import { Provider, useSelector } from "react-redux";
import { createStore } from "redux";
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() });
import ServerDashboard from "./ServerDashboard";
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useSelector: jest.fn(),
}));
describe("ServerDashboard Component: ", () => {
var serverDashboardJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={callbackSpy}
shutdownHub={callbackSpy}
startServer={callbackSpy}
stopServer={callbackSpy}
startAll={callbackSpy}
stopAll={callbackSpy}
/>
</Switch>
</HashRouter>
</Provider>
);
var serverDashboardJsx = (spy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={spy}
shutdownHub={spy}
startServer={spy}
stopServer={spy}
startAll={spy}
stopAll={spy}
/>
</Switch>
</HashRouter>
</Provider>
);
var mockAsync = () =>
jest
.fn()
.mockImplementation(() =>
Promise.resolve({ json: () => Promise.resolve({ k: "v" }) })
);
var mockAsync = (data) =>
jest.fn().mockImplementation(() => Promise.resolve(data ? data : { k: "v" }));
var mockAppState = () => ({
user_data: JSON.parse(
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
),
});
var mockAsyncRejection = () =>
jest.fn().mockImplementation(() => Promise.reject());
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
var mockAppState = () => ({
user_data: JSON.parse(
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
),
});
afterEach(() => {
useSelector.mockClear();
});
it("Renders users from props.user_data into table", () => {
let component = mount(serverDashboardJsx(mockAsync())),
userRows = component.find(".user-row");
expect(userRows.length).toBe(2);
});
it("Renders correctly the status of a single-user server", () => {
let component = mount(serverDashboardJsx(mockAsync())),
userRows = component.find(".user-row");
// Renders .stop-button when server is started
// Should be 1 since user foo is started
expect(userRows.at(0).find(".stop-button").length).toBe(1);
// Renders .start-button when server is stopped
// Should be 1 since user bar is stopped
expect(userRows.at(1).find(".start-button").length).toBe(1);
});
it("Invokes the startServer event on button click", () => {
let callbackSpy = mockAsync(),
component = mount(serverDashboardJsx(callbackSpy)),
startBtn = component.find(".start-button");
startBtn.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Invokes the stopServer event on button click", () => {
let callbackSpy = mockAsync(),
component = mount(serverDashboardJsx(callbackSpy)),
stopBtn = component.find(".stop-button");
stopBtn.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Invokes the shutdownHub event on button click", () => {
let callbackSpy = mockAsync(),
component = mount(serverDashboardJsx(callbackSpy)),
shutdownBtn = component.find("#shutdown-button").first();
shutdownBtn.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Sorts according to username", () => {
let component = mount(serverDashboardJsx(mockAsync())).find(
"ServerDashboard"
),
handler = component.find("SortHandler").first();
handler.simulate("click");
let first = component.find(".user-row").first();
expect(first.html().includes("bar")).toBe(true);
handler.simulate("click");
first = component.find(".user-row").first();
expect(first.html().includes("foo")).toBe(true);
});
it("Sorts according to admin", () => {
let component = mount(serverDashboardJsx(mockAsync())).find(
"ServerDashboard"
),
handler = component.find("SortHandler").at(1);
handler.simulate("click");
let first = component.find(".user-row").first();
expect(first.html().includes("admin")).toBe(true);
handler.simulate("click");
first = component.find(".user-row").first();
expect(first.html().includes("admin")).toBe(false);
});
it("Sorts according to last activity", () => {
let component = mount(serverDashboardJsx(mockAsync())).find(
"ServerDashboard"
),
handler = component.find("SortHandler").at(2);
handler.simulate("click");
let first = component.find(".user-row").first();
// foo used most recently
expect(first.html().includes("foo")).toBe(true);
handler.simulate("click");
first = component.find(".user-row").first();
// invert sort - bar used least recently
expect(first.html().includes("bar")).toBe(true);
});
it("Sorts according to server status (running/not running)", () => {
let component = mount(serverDashboardJsx(mockAsync())).find(
"ServerDashboard"
),
handler = component.find("SortHandler").at(3);
handler.simulate("click");
let first = component.find(".user-row").first();
// foo running
expect(first.html().includes("foo")).toBe(true);
handler.simulate("click");
first = component.find(".user-row").first();
// invert sort - bar not running
expect(first.html().includes("bar")).toBe(true);
});
it("Renders nothing if required data is not available", () => {
useSelector.mockImplementation((callback) => {
return callback({});
});
let component = mount(serverDashboardJsx(jest.fn()));
expect(component.html()).toBe("<div></div>");
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useSelector.mockClear();
});
test("Renders", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
expect(screen.getByTestId("container")).toBeVisible();
});
test("Renders users from props.user_data into table", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let foo = screen.getByText("foo");
let bar = screen.getByText("bar");
expect(foo).toBeVisible();
expect(bar).toBeVisible();
});
test("Renders correctly the status of a single-user server", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let start = screen.getByText("Start Server");
let stop = screen.getByText("Stop Server");
expect(start).toBeVisible();
expect(stop).toBeVisible();
});
test("Invokes the startServer event on button click", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let start = screen.getByText("Start Server");
await act(async () => {
fireEvent.click(start);
});
expect(callbackSpy).toHaveBeenCalled();
});
test("Invokes the stopServer event on button click", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let stop = screen.getByText("Stop Server");
await act(async () => {
fireEvent.click(stop);
});
expect(callbackSpy).toHaveBeenCalled();
});
test("Invokes the shutdownHub event on button click", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let shutdown = screen.getByText("Shutdown Hub");
await act(async () => {
fireEvent.click(shutdown);
});
expect(callbackSpy).toHaveBeenCalled();
});
test("Sorts according to username", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let handler = screen.getByTestId("user-sort");
fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("bar");
fireEvent.click(handler);
first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("foo");
});
test("Sorts according to admin", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let handler = screen.getByTestId("admin-sort");
fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-admin")[0];
expect(first.textContent).toBe("admin");
fireEvent.click(handler);
first = screen.getAllByTestId("user-row-admin")[0];
expect(first.textContent).toBe("");
});
test("Sorts according to last activity", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let handler = screen.getByTestId("last-activity-sort");
fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("foo");
fireEvent.click(handler);
first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("bar");
});
test("Sorts according to server status (running/not running)", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let handler = screen.getByTestId("running-status-sort");
fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("foo");
fireEvent.click(handler);
first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("bar");
});
test("Renders nothing if required data is not available", async () => {
useSelector.mockImplementation((callback) => {
return callback({});
});
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let noShow = screen.getByTestId("no-show");
expect(noShow).toBeVisible();
});
test("Shows a UI error dialogue when start all servers fails", async () => {
let spy = mockAsync();
let rejectSpy = mockAsyncRejection;
await act(async () => {
render(
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={spy}
shutdownHub={spy}
startServer={spy}
stopServer={spy}
startAll={rejectSpy}
stopAll={spy}
/>
</Switch>
</HashRouter>
</Provider>
);
});
let startAll = screen.getByTestId("start-all");
await act(async () => {
fireEvent.click(startAll);
});
let errorDialog = screen.getByText("Failed to start servers.");
expect(errorDialog).toBeVisible();
});
test("Shows a UI error dialogue when stop all servers fails", async () => {
let spy = mockAsync();
let rejectSpy = mockAsyncRejection;
await act(async () => {
render(
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={spy}
shutdownHub={spy}
startServer={spy}
stopServer={spy}
startAll={spy}
stopAll={rejectSpy}
/>
</Switch>
</HashRouter>
</Provider>
);
});
let stopAll = screen.getByTestId("stop-all");
await act(async () => {
fireEvent.click(stopAll);
});
let errorDialog = screen.getByText("Failed to stop servers.");
expect(errorDialog).toBeVisible();
});
test("Shows a UI error dialogue when start user server fails", async () => {
let spy = mockAsync();
let rejectSpy = mockAsyncRejection();
await act(async () => {
render(
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={spy}
shutdownHub={spy}
startServer={rejectSpy}
stopServer={spy}
startAll={spy}
stopAll={spy}
/>
</Switch>
</HashRouter>
</Provider>
);
});
let start = screen.getByText("Start Server");
await act(async () => {
fireEvent.click(start);
});
let errorDialog = screen.getByText("Failed to start server.");
expect(errorDialog).toBeVisible();
});
test("Shows a UI error dialogue when start user server returns an improper status code", async () => {
let spy = mockAsync();
let rejectSpy = mockAsync({ status: 403 });
await act(async () => {
render(
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={spy}
shutdownHub={spy}
startServer={rejectSpy}
stopServer={spy}
startAll={spy}
stopAll={spy}
/>
</Switch>
</HashRouter>
</Provider>
);
});
let start = screen.getByText("Start Server");
await act(async () => {
fireEvent.click(start);
});
let errorDialog = screen.getByText("Failed to start server.");
expect(errorDialog).toBeVisible();
});
test("Shows a UI error dialogue when stop user servers fails", async () => {
let spy = mockAsync();
let rejectSpy = mockAsyncRejection();
await act(async () => {
render(
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={spy}
shutdownHub={spy}
startServer={spy}
stopServer={rejectSpy}
startAll={spy}
stopAll={spy}
/>
</Switch>
</HashRouter>
</Provider>
);
});
let stop = screen.getByText("Stop Server");
await act(async () => {
fireEvent.click(stop);
});
let errorDialog = screen.getByText("Failed to stop server.");
expect(errorDialog).toBeVisible();
});
test("Shows a UI error dialogue when stop user server returns an improper status code", async () => {
let spy = mockAsync();
let rejectSpy = mockAsync({ status: 403 });
await act(async () => {
render(
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={spy}
shutdownHub={spy}
startServer={spy}
stopServer={rejectSpy}
startAll={spy}
stopAll={spy}
/>
</Switch>
</HashRouter>
</Provider>
);
});
let stop = screen.getByText("Stop Server");
await act(async () => {
fireEvent.click(stop);
});
let errorDialog = screen.getByText("Failed to stop server.");
expect(errorDialog).toBeVisible();
});

View File

@@ -1,5 +1,7 @@
export const jhapiRequest = (endpoint, method, data) => {
return fetch("/hub/api" + endpoint, {
let base_url = window.base_url,
api_url = `${base_url}hub/api`;
return fetch(api_url + endpoint, {
method: method,
json: true,
headers: {

View File

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

View File

@@ -935,6 +935,14 @@
"@babel/plugin-transform-react-jsx-development" "^7.12.7"
"@babel/plugin-transform-react-pure-annotations" "^7.12.1"
"@babel/runtime-corejs3@^7.10.2":
version "7.16.3"
resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.16.3.tgz#1e25de4fa994c57c18e5fdda6cc810dac70f5590"
integrity sha512-IAdDC7T0+wEB4y2gbIL0uOXEYpiZEeuFUTVbdGq+UwCcF35T/tS8KrmMomEwEc5wBbyfH3PJVpTSUqrhPDXFcQ==
dependencies:
core-js-pure "^3.19.0"
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.4.2", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
version "7.12.5"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e"
@@ -942,6 +950,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.9.2":
version "7.16.3"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5"
integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.10.4":
version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278"
@@ -1224,6 +1239,17 @@
"@types/yargs" "^15.0.0"
chalk "^4.0.0"
"@jest/types@^27.4.2":
version "27.4.2"
resolved "https://registry.yarnpkg.com/@jest/types/-/types-27.4.2.tgz#96536ebd34da6392c2b7c7737d693885b5dd44a5"
integrity sha512-j35yw0PMTPpZsUoOBiuHzr1zTYoad1cVIE0ajEjcrJONxxrko/IRGKkXx3os0Nsi4Hu3+5VmDbVfq5WhG/pWAg==
dependencies:
"@types/istanbul-lib-coverage" "^2.0.0"
"@types/istanbul-reports" "^3.0.0"
"@types/node" "*"
"@types/yargs" "^16.0.0"
chalk "^4.0.0"
"@popperjs/core@^2.5.3":
version "2.5.4"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.5.4.tgz#de25b5da9f727985a3757fd59b5d028aba75841a"
@@ -1256,6 +1282,55 @@
dependencies:
"@sinonjs/commons" "^1.7.0"
"@testing-library/dom@^8.0.0":
version "8.11.1"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.11.1.tgz#03fa2684aa09ade589b460db46b4c7be9fc69753"
integrity sha512-3KQDyx9r0RKYailW2MiYrSSKEfH0GTkI51UGEvJenvcoDoeRYs0PZpi2SXqtnMClQvCqdtTTpOfFETDTVADpAg==
dependencies:
"@babel/code-frame" "^7.10.4"
"@babel/runtime" "^7.12.5"
"@types/aria-query" "^4.2.0"
aria-query "^5.0.0"
chalk "^4.1.0"
dom-accessibility-api "^0.5.9"
lz-string "^1.4.4"
pretty-format "^27.0.2"
"@testing-library/jest-dom@^5.15.1":
version "5.15.1"
resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.15.1.tgz#4c49ba4d244f235aec53f0a83498daeb4ee06c33"
integrity sha512-kmj8opVDRE1E4GXyLlESsQthCXK7An28dFWxhiMwD7ZUI7ZxA6sjdJRxLerD9Jd8cHX4BDc1jzXaaZKqzlUkvg==
dependencies:
"@babel/runtime" "^7.9.2"
"@types/testing-library__jest-dom" "^5.9.1"
aria-query "^4.2.2"
chalk "^3.0.0"
css "^3.0.0"
css.escape "^1.5.1"
dom-accessibility-api "^0.5.6"
lodash "^4.17.15"
redent "^3.0.0"
"@testing-library/react@^12.1.2":
version "12.1.2"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.2.tgz#f1bc9a45943461fa2a598bb4597df1ae044cfc76"
integrity sha512-ihQiEOklNyHIpo2Y8FREkyD1QAea054U0MVbwH1m8N9TxeFz+KoJ9LkqoKqJlzx2JDm56DVwaJ1r36JYxZM05g==
dependencies:
"@babel/runtime" "^7.12.5"
"@testing-library/dom" "^8.0.0"
"@testing-library/user-event@^13.5.0":
version "13.5.0"
resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-13.5.0.tgz#69d77007f1e124d55314a2b73fd204b333b13295"
integrity sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==
dependencies:
"@babel/runtime" "^7.12.5"
"@types/aria-query@^4.2.0":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.2.tgz#ed4e0ad92306a704f9fb132a0cfcf77486dbe2bc"
integrity sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
version "7.1.12"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.12.tgz#4d8e9e51eb265552a7e4f1ff2219ab6133bdfb2d"
@@ -1354,6 +1429,14 @@
dependencies:
"@types/istanbul-lib-report" "*"
"@types/jest@*":
version "27.0.3"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.0.3.tgz#0cf9dfe9009e467f70a342f0f94ead19842a783a"
integrity sha512-cmmwv9t7gBYt7hNKH5Spu7Kuu/DotGa+Ff+JGRKZ4db5eh8PnKS4LuebJ3YLUoyOyIHraTGyULn23YtEAm0VSg==
dependencies:
jest-diff "^27.0.0"
pretty-format "^27.0.0"
"@types/json-schema@*", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6":
version "7.0.6"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0"
@@ -1411,6 +1494,13 @@
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff"
integrity sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==
"@types/testing-library__jest-dom@^5.9.1":
version "5.14.2"
resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.2.tgz#564fb2b2dc827147e937a75b639a05d17ce18b44"
integrity sha512-vehbtyHUShPxIa9SioxDwCvgxukDMH//icJG90sXQBUm5lJOHLT5kNeU9tnivhnA/TkOFMzGIXN2cTc4hY8/kg==
dependencies:
"@types/jest" "*"
"@types/warning@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.0.tgz#0d2501268ad8f9962b740d387c4654f5f8e23e52"
@@ -1428,6 +1518,13 @@
dependencies:
"@types/yargs-parser" "*"
"@types/yargs@^16.0.0":
version "16.0.4"
resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-16.0.4.tgz#26aad98dd2c2a38e421086ea9ad42b9e51642977"
integrity sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==
dependencies:
"@types/yargs-parser" "*"
"@webassemblyjs/ast@1.9.0":
version "1.9.0"
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964"
@@ -1573,20 +1670,30 @@
"@webassemblyjs/wast-parser" "1.9.0"
"@xtuc/long" "4.2.2"
"@wojtekmaj/enzyme-adapter-react-17@^0.4.1":
version "0.4.1"
resolved "https://registry.yarnpkg.com/@wojtekmaj/enzyme-adapter-react-17/-/enzyme-adapter-react-17-0.4.1.tgz#a9d4a2873025c6de19e1142ca076661bac69f587"
integrity sha512-WZr8i4C6WVDV7Mb8sbm7GdlEPmk1f+xOMjUKThqrkWgwsfvu90zJyyX54wyAvsS91sjtKZ0JipGj2cJnEDaxPA==
"@wojtekmaj/enzyme-adapter-react-17@^0.6.5":
version "0.6.5"
resolved "https://registry.yarnpkg.com/@wojtekmaj/enzyme-adapter-react-17/-/enzyme-adapter-react-17-0.6.5.tgz#1925e17aaea7089e7ec66c7c35e5771e49b6bf7e"
integrity sha512-ChIObUiXXYUiqzXPqOai+p6KF5dlbItpDDYsftUOQiAiygbMDlLeJIjynC6ZrJIa2U2MpRp4YJmtR2GQyIHjgA==
dependencies:
enzyme-adapter-utils "^1.14.0"
enzyme-shallow-equal "^1.0.4"
has "^1.0.3"
"@wojtekmaj/enzyme-adapter-utils" "^0.1.1"
enzyme-shallow-equal "^1.0.0"
has "^1.0.0"
object.assign "^4.1.0"
object.values "^1.1.1"
prop-types "^15.7.2"
react-is "^17.0.0"
object.values "^1.1.0"
prop-types "^15.7.0"
react-is "^17.0.2"
react-test-renderer "^17.0.0"
semver "^5.7.0"
"@wojtekmaj/enzyme-adapter-utils@^0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@wojtekmaj/enzyme-adapter-utils/-/enzyme-adapter-utils-0.1.1.tgz#17773cf264570fbcfc0d33bb74e4002c17f2f1ec"
integrity sha512-bNPWtN/d8huKOkC6j1E3EkSamnRrHHT7YuR6f9JppAQqtoAm3v4/vERe4J14jQKmHLCyEBHXrlgb7H6l817hVg==
dependencies:
function.prototype.name "^1.1.0"
has "^1.0.0"
object.assign "^4.1.0"
object.fromentries "^2.0.0"
prop-types "^15.7.0"
"@xtuc/ieee754@^1.2.0":
version "1.2.0"
@@ -1639,21 +1746,6 @@ acorn@^8.0.4:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.0.4.tgz#7a3ae4191466a6984eee0fe3407a4f3aa9db8354"
integrity sha512-XNP0PqF1XD19ZlLKvB7cMmnZswW4C/03pRHgirB30uSJTaS3A3V1/P4sS3HPvFmjoriPCJQs+JDSbm4bL1TxGQ==
airbnb-prop-types@^2.16.0:
version "2.16.0"
resolved "https://registry.yarnpkg.com/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz#b96274cefa1abb14f623f804173ee97c13971dc2"
integrity sha512-7WHOFolP/6cS96PhKNrslCLMYAI8yB1Pp6u6XmxozQOiZbsI5ycglZr5cHhBFfuRcQQjzCMith5ZPZdYiJCxUg==
dependencies:
array.prototype.find "^2.1.1"
function.prototype.name "^1.1.2"
is-regex "^1.1.0"
object-is "^1.1.2"
object.assign "^4.1.0"
object.entries "^1.1.2"
prop-types "^15.7.2"
prop-types-exact "^1.2.0"
react-is "^16.13.1"
ajv-errors@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d"
@@ -1721,6 +1813,11 @@ ansi-regex@^5.0.0:
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75"
integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==
ansi-regex@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
ansi-styles@^3.2.0, ansi-styles@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
@@ -1735,6 +1832,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0:
dependencies:
color-convert "^2.0.1"
ansi-styles@^5.0.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b"
integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
anymatch@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb"
@@ -1758,6 +1860,19 @@ argparse@^1.0.7:
dependencies:
sprintf-js "~1.0.2"
aria-query@^4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b"
integrity sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==
dependencies:
"@babel/runtime" "^7.10.2"
"@babel/runtime-corejs3" "^7.10.2"
aria-query@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.0.0.tgz#210c21aaf469613ee8c9a62c7f86525e058db52c"
integrity sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==
arr-diff@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
@@ -1816,14 +1931,6 @@ array-unique@^0.3.2:
resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=
array.prototype.find@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/array.prototype.find/-/array.prototype.find-2.1.1.tgz#3baca26108ca7affb08db06bf0be6cb3115a969c"
integrity sha512-mi+MYNJYLTx2eNYy+Yh6raoQacCsNeeMUaspFPh9Y141lFSsWxxB8V9mM2ye+eqiRs917J6/pJ4M9ZPzenWckA==
dependencies:
define-properties "^1.1.3"
es-abstract "^1.17.4"
array.prototype.flat@^1.2.3:
version "1.2.4"
resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz#6ef638b43312bd401b4c6199fdec7e2dc9e9a123"
@@ -2217,6 +2324,14 @@ chalk@^2.0.0, chalk@^2.4.2:
escape-string-regexp "^1.0.5"
supports-color "^5.3.0"
chalk@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4"
integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==
dependencies:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
chalk@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a"
@@ -2225,6 +2340,14 @@ chalk@^4.0.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
chalk@^4.1.0:
version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
dependencies:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
change-emitter@^0.1.2:
version "0.1.6"
resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515"
@@ -2469,6 +2592,11 @@ core-js-compat@^3.8.0:
browserslist "^4.16.0"
semver "7.0.0"
core-js-pure@^3.19.0:
version "3.19.2"
resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.19.2.tgz#26b5bfb503178cff6e3e115bc2ba6c6419383680"
integrity sha512-5LkcgQEy8pFeVnd/zomkUBSwnmIxuF1C8E9KrMAbOc8f34IBT9RGvTYeNDdp1PnvMJrrVhvk1hg/yVV5h/znlg==
core-js@^1.0.0:
version "1.2.7"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
@@ -2533,6 +2661,20 @@ css-what@^4.0.0:
resolved "https://registry.yarnpkg.com/css-what/-/css-what-4.0.0.tgz#35e73761cab2eeb3d3661126b23d7aa0e8432233"
integrity sha512-teijzG7kwYfNVsUh2H/YN62xW3KK9YhXEgSlbxMlcyjPNvdKJqFx5lrwlJgoFP1ZHlB89iGDlo/JyshKeRhv5A==
css.escape@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb"
integrity sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=
css@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/css/-/css-3.0.0.tgz#4447a4d58fdd03367c516ca9f64ae365cee4aa5d"
integrity sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==
dependencies:
inherits "^2.0.4"
source-map "^0.6.1"
source-map-resolve "^0.6.0"
cssesc@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
@@ -2719,6 +2861,11 @@ diff-sequences@^26.6.2:
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1"
integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==
diff-sequences@^27.4.0:
version "27.4.0"
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.4.0.tgz#d783920ad8d06ec718a060d00196dfef25b132a5"
integrity sha512-YqiQzkrsmHMH5uuh8OdQFU9/ZpADnwzml8z0O5HvRNda+5UZsaX/xN+AAxfR2hWq1Y7HZnAzO9J5lJXOuDz2Ww==
discontinuous-range@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a"
@@ -2758,6 +2905,11 @@ doctrine@^3.0.0:
dependencies:
esutils "^2.0.2"
dom-accessibility-api@^0.5.6, dom-accessibility-api@^0.5.9:
version "0.5.10"
resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.10.tgz#caa6d08f60388d0bb4539dd75fe458a9a1d0014c"
integrity sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g==
dom-helpers@^5.0.1, dom-helpers@^5.1.2, dom-helpers@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.0.tgz#57fd054c5f8f34c52a3eeffdb7e7e93cd357d95b"
@@ -2889,20 +3041,7 @@ entities@^2.0.0, entities@~2.1.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5"
integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==
enzyme-adapter-utils@^1.14.0:
version "1.14.0"
resolved "https://registry.yarnpkg.com/enzyme-adapter-utils/-/enzyme-adapter-utils-1.14.0.tgz#afbb0485e8033aa50c744efb5f5711e64fbf1ad0"
integrity sha512-F/z/7SeLt+reKFcb7597IThpDp0bmzcH1E9Oabqv+o01cID2/YInlqHbFl7HzWBl4h3OdZYedtwNDOmSKkk0bg==
dependencies:
airbnb-prop-types "^2.16.0"
function.prototype.name "^1.1.3"
has "^1.0.3"
object.assign "^4.1.2"
object.fromentries "^2.0.3"
prop-types "^15.7.2"
semver "^5.7.1"
enzyme-shallow-equal@^1.0.1, enzyme-shallow-equal@^1.0.4:
enzyme-shallow-equal@^1.0.0, enzyme-shallow-equal@^1.0.1:
version "1.0.4"
resolved "https://registry.yarnpkg.com/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.4.tgz#b9256cb25a5f430f9bfe073a84808c1d74fced2e"
integrity sha512-MttIwB8kKxypwHvRynuC3ahyNc+cFbR8mjVIltnmzQ0uKGqmsfO4bfBuLxb0beLNPhjblUEYvEbsg+VSygvF1Q==
@@ -2952,7 +3091,7 @@ error-ex@^1.3.1:
dependencies:
is-arrayish "^0.2.1"
es-abstract@^1.17.0-next.1, es-abstract@^1.17.4:
es-abstract@^1.17.0-next.1:
version "1.17.7"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c"
integrity sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g==
@@ -2987,6 +3126,32 @@ es-abstract@^1.18.0-next.1:
string.prototype.trimend "^1.0.1"
string.prototype.trimstart "^1.0.1"
es-abstract@^1.19.0, es-abstract@^1.19.1:
version "1.19.1"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.19.1.tgz#d4885796876916959de78edaa0df456627115ec3"
integrity sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==
dependencies:
call-bind "^1.0.2"
es-to-primitive "^1.2.1"
function-bind "^1.1.1"
get-intrinsic "^1.1.1"
get-symbol-description "^1.0.0"
has "^1.0.3"
has-symbols "^1.0.2"
internal-slot "^1.0.3"
is-callable "^1.2.4"
is-negative-zero "^2.0.1"
is-regex "^1.1.4"
is-shared-array-buffer "^1.0.1"
is-string "^1.0.7"
is-weakref "^1.0.1"
object-inspect "^1.11.0"
object-keys "^1.1.1"
object.assign "^4.1.2"
string.prototype.trimend "^1.0.4"
string.prototype.trimstart "^1.0.4"
unbox-primitive "^1.0.1"
es-to-primitive@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a"
@@ -3499,9 +3664,9 @@ flatted@^3.1.0:
integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==
follow-redirects@^1.0.0:
version "1.13.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db"
integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==
version "1.14.7"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685"
integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==
for-in@^1.0.2:
version "1.0.2"
@@ -3562,7 +3727,17 @@ function-bind@^1.1.1:
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
function.prototype.name@^1.1.2, function.prototype.name@^1.1.3:
function.prototype.name@^1.1.0:
version "1.1.5"
resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621"
integrity sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==
dependencies:
call-bind "^1.0.2"
define-properties "^1.1.3"
es-abstract "^1.19.0"
functions-have-names "^1.2.2"
function.prototype.name@^1.1.2:
version "1.1.3"
resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.3.tgz#0bb034bb308e7682826f215eb6b2ae64918847fe"
integrity sha512-H51qkbNSp8mtkJt+nyW1gyStBiKZxfRqySNUR99ylq6BPXHKI4SEvIlTKp4odLfjRKJV04DFWMU3G/YRlQOsag==
@@ -3577,7 +3752,7 @@ functional-red-black-tree@^1.0.1:
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
functions-have-names@^1.2.1:
functions-have-names@^1.2.1, functions-have-names@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.2.tgz#98d93991c39da9361f8e50b337c4f6e41f120e21"
integrity sha512-bLgc3asbWdwPbx2mNk2S49kmJCuQeu0nfmaOgbs8WIyzzkw3r4htszdIi9Q9EMezDPTYuJx2wvjZ/EwgAthpnA==
@@ -3610,6 +3785,15 @@ get-intrinsic@^1.0.1, get-intrinsic@^1.0.2:
has "^1.0.3"
has-symbols "^1.0.1"
get-intrinsic@^1.1.0, get-intrinsic@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6"
integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==
dependencies:
function-bind "^1.1.1"
has "^1.0.3"
has-symbols "^1.0.1"
get-package-type@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a"
@@ -3629,6 +3813,14 @@ get-stream@^5.0.0:
dependencies:
pump "^3.0.0"
get-symbol-description@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6"
integrity sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==
dependencies:
call-bind "^1.0.2"
get-intrinsic "^1.1.1"
get-value@^2.0.3, get-value@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
@@ -3770,6 +3962,11 @@ harmony-reflect@^1.4.6:
resolved "https://registry.yarnpkg.com/harmony-reflect/-/harmony-reflect-1.6.1.tgz#c108d4f2bb451efef7a37861fdbdae72c9bdefa9"
integrity sha512-WJTeyp0JzGtHcuMsi7rw2VwtkvLa+JyfEKJCFyfcS0+CDkjQ5lHPu7zEhFZP+PDSRrEgXa5Ah0l1MbgbE41XjA==
has-bigints@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113"
integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==
has-flag@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
@@ -3785,6 +3982,18 @@ has-symbols@^1.0.1:
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
has-symbols@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423"
integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
has-tostringtag@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25"
integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==
dependencies:
has-symbols "^1.0.2"
has-value@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f"
@@ -3816,7 +4025,7 @@ has-values@^1.0.0:
is-number "^3.0.0"
kind-of "^4.0.0"
has@^1.0.3:
has@^1.0.0, has@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
@@ -4045,6 +4254,11 @@ imurmurhash@^0.1.4:
resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
indent-string@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251"
integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
indexes-of@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607"
@@ -4058,7 +4272,7 @@ inflight@^1.0.4:
once "^1.3.0"
wrappy "1"
inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3:
inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -4090,6 +4304,15 @@ internal-slot@^1.0.2:
has "^1.0.3"
side-channel "^1.0.2"
internal-slot@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c"
integrity sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==
dependencies:
get-intrinsic "^1.1.0"
has "^1.0.3"
side-channel "^1.0.4"
interpret@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
@@ -4146,6 +4369,13 @@ is-arrayish@^0.2.1:
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
is-bigint@^1.0.1:
version "1.0.4"
resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3"
integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==
dependencies:
has-bigints "^1.0.1"
is-binary-path@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898"
@@ -4160,6 +4390,14 @@ is-boolean-object@^1.0.1:
dependencies:
call-bind "^1.0.0"
is-boolean-object@^1.1.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719"
integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==
dependencies:
call-bind "^1.0.2"
has-tostringtag "^1.0.0"
is-buffer@^1.1.5:
version "1.1.6"
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
@@ -4170,6 +4408,11 @@ is-callable@^1.1.4, is-callable@^1.1.5, is-callable@^1.2.2:
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9"
integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA==
is-callable@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945"
integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==
is-ci@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c"
@@ -4277,6 +4520,11 @@ is-negative-zero@^2.0.0:
resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.0.tgz#9553b121b0fac28869da9ed459e20c7543788461"
integrity sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE=
is-negative-zero@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24"
integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==
is-number-object@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197"
@@ -4325,13 +4573,26 @@ is-potential-custom-element-name@^1.0.0:
resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397"
integrity sha1-DFLlS8yjkbssSUsh6GJtczbG45c=
is-regex@^1.0.4, is-regex@^1.0.5, is-regex@^1.1.0, is-regex@^1.1.1:
is-regex@^1.0.4, is-regex@^1.0.5, is-regex@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9"
integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==
dependencies:
has-symbols "^1.0.1"
is-regex@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958"
integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==
dependencies:
call-bind "^1.0.2"
has-tostringtag "^1.0.0"
is-shared-array-buffer@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz#97b0c85fbdacb59c9c446fe653b82cf2b5b7cfe6"
integrity sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==
is-stream@^1.0.1, is-stream@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
@@ -4347,6 +4608,13 @@ is-string@^1.0.5:
resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6"
integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==
is-string@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd"
integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==
dependencies:
has-tostringtag "^1.0.0"
is-subset@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/is-subset/-/is-subset-0.1.1.tgz#8a59117d932de1de00f245fcdd39ce43f1e939a6"
@@ -4359,11 +4627,25 @@ is-symbol@^1.0.2:
dependencies:
has-symbols "^1.0.1"
is-symbol@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c"
integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==
dependencies:
has-symbols "^1.0.2"
is-typedarray@^1.0.0, is-typedarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
is-weakref@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.1.tgz#842dba4ec17fa9ac9850df2d6efbc1737274f2a2"
integrity sha512-b2jKc2pQZjaeFYWEf7ScFj+Be1I+PXmlu572Q8coTXZ+LD/QQZ7ShPMst8h16riVgyXTQwUsFEl74mDvc/3MHQ==
dependencies:
call-bind "^1.0.0"
is-windows@^1.0.1, is-windows@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
@@ -4524,6 +4806,16 @@ jest-diff@^26.6.2:
jest-get-type "^26.3.0"
pretty-format "^26.6.2"
jest-diff@^27.0.0:
version "27.4.2"
resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.4.2.tgz#786b2a5211d854f848e2dcc1e324448e9481f36f"
integrity sha512-ujc9ToyUZDh9KcqvQDkk/gkbf6zSaeEg9AiBxtttXW59H/AcqEYp1ciXAtJp+jXWva5nAf/ePtSsgWwE5mqp4Q==
dependencies:
chalk "^4.0.0"
diff-sequences "^27.4.0"
jest-get-type "^27.4.0"
pretty-format "^27.4.2"
jest-docblock@^26.0.0:
version "26.0.0"
resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-26.0.0.tgz#3e2fa20899fc928cb13bd0ff68bd3711a36889b5"
@@ -4572,6 +4864,11 @@ jest-get-type@^26.3.0:
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0"
integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==
jest-get-type@^27.4.0:
version "27.4.0"
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.4.0.tgz#7503d2663fffa431638337b3998d39c5e928e9b5"
integrity sha512-tk9o+ld5TWq41DkK14L4wox4s2D9MtTpKaAVzXfr5CUKm5ZK2ExcaFE0qls2W71zE/6R2TxxrK9w2r6svAFDBQ==
jest-haste-map@^26.6.2:
version "26.6.2"
resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.6.2.tgz#dd7e60fe7dc0e9f911a23d79c5ff7fb5c2cafeaa"
@@ -5114,6 +5411,11 @@ lru-cache@^6.0.0:
dependencies:
yallist "^4.0.0"
lz-string@^1.4.4:
version "1.4.4"
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
integrity sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=
make-dir@^2.0.0, make-dir@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
@@ -5250,6 +5552,11 @@ mimic-fn@^2.1.0:
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
min-indent@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
mini-create-react-context@^0.4.0:
version "0.4.1"
resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz#072171561bfdc922da08a60c2197a497cc2d1d5e"
@@ -5329,9 +5636,9 @@ nan@^2.12.1:
integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==
nanoid@^3.1.23:
version "3.1.23"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81"
integrity sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==
version "3.2.0"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c"
integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==
nanomatch@^1.2.9:
version "1.2.13"
@@ -5487,6 +5794,11 @@ object-copy@^0.1.0:
define-property "^0.2.5"
kind-of "^3.0.3"
object-inspect@^1.11.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.11.0.tgz#9dceb146cedd4148a0d9e51ab88d34cf509922b1"
integrity sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==
object-inspect@^1.7.0, object-inspect@^1.9.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.9.0.tgz#c90521d74e1127b67266ded3394ad6116986533a"
@@ -5545,7 +5857,16 @@ object.entries@^1.1.1, object.entries@^1.1.2:
es-abstract "^1.18.0-next.1"
has "^1.0.3"
object.fromentries@^2.0.2, object.fromentries@^2.0.3:
object.fromentries@^2.0.0:
version "2.0.5"
resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.5.tgz#7b37b205109c21e741e605727fe8b0ad5fa08251"
integrity sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw==
dependencies:
call-bind "^1.0.2"
define-properties "^1.1.3"
es-abstract "^1.19.1"
object.fromentries@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.3.tgz#13cefcffa702dc67750314a3305e8cb3fad1d072"
integrity sha512-IDUSMXs6LOSJBWE++L0lzIbSqHl9KDCfff2x/JSEIDtEUavUnyMYC2ZGay/04Zq4UT8lvd4xNhU4/YHKibAOlw==
@@ -5562,6 +5883,15 @@ object.pick@^1.3.0:
dependencies:
isobject "^3.0.1"
object.values@^1.1.0:
version "1.1.5"
resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.5.tgz#959f63e3ce9ef108720333082131e4a459b716ac"
integrity sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==
dependencies:
call-bind "^1.0.2"
define-properties "^1.1.3"
es-abstract "^1.19.1"
object.values@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.2.tgz#7a2015e06fcb0f546bd652486ce8583a4731c731"
@@ -5948,6 +6278,16 @@ pretty-format@^26.6.2:
ansi-styles "^4.0.0"
react-is "^17.0.1"
pretty-format@^27.0.0, pretty-format@^27.0.2, pretty-format@^27.4.2:
version "27.4.2"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.4.2.tgz#e4ce92ad66c3888423d332b40477c87d1dac1fb8"
integrity sha512-p0wNtJ9oLuvgOQDEIZ9zQjZffK7KtyR6Si0jnXULIDwrlNF8Cuir3AZP0hHv0jmKuNN/edOnbMjnzd4uTcmWiw==
dependencies:
"@jest/types" "^27.4.2"
ansi-regex "^5.0.1"
ansi-styles "^5.0.0"
react-is "^17.0.1"
process-nextick-args@~2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
@@ -5973,15 +6313,6 @@ prompts@^2.0.1:
kleur "^3.0.3"
sisteransi "^1.0.5"
prop-types-exact@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/prop-types-exact/-/prop-types-exact-1.2.0.tgz#825d6be46094663848237e3925a98c6e944e9869"
integrity sha512-K+Tk3Kd9V0odiXFP9fwDHUYRyvK3Nun3GVyPapSIs5OBkITAm15W0CPFD/YKTkMUAbc0b9CUwRQp2ybiBIq+eA==
dependencies:
has "^1.0.3"
object.assign "^4.1.0"
reflect.ownkeys "^0.2.0"
prop-types-extra@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/prop-types-extra/-/prop-types-extra-1.1.1.tgz#58c3b74cbfbb95d304625975aa2f0848329a010b"
@@ -5990,7 +6321,7 @@ prop-types-extra@^1.1.0:
react-is "^16.3.2"
warning "^4.0.0"
prop-types@^15.6.2, prop-types@^15.7.2:
prop-types@^15.6.2, prop-types@^15.7.0, prop-types@^15.7.2:
version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@@ -6135,7 +6466,7 @@ react-icons@^4.1.0:
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.1.0.tgz#9ca9bcbf2e3aee8e86e378bb9d465842947bbfc3"
integrity sha512-FCXBg1JbbR0vWALXIxmFAfozHdVIJmmwCD81Jk0EKOt7Ax4AdBNcaRkWhR0NaKy9ugJgoY3fFvo0PHpte55pXg==
"react-is@^16.12.0 || ^17.0.0", react-is@^17.0.0, react-is@^17.0.1:
"react-is@^16.12.0 || ^17.0.0", react-is@^17.0.1:
version "17.0.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339"
integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==
@@ -6145,6 +6476,11 @@ react-is@^16.13.1, react-is@^16.3.2, react-is@^16.6.0, react-is@^16.7.0, react-i
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-is@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
@@ -6309,6 +6645,14 @@ recompose@^0.30.0:
react-lifecycles-compat "^3.0.2"
symbol-observable "^1.0.4"
redent@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f"
integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==
dependencies:
indent-string "^4.0.0"
strip-indent "^3.0.0"
redux@^4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f"
@@ -6317,11 +6661,6 @@ redux@^4.0.5:
loose-envify "^1.4.0"
symbol-observable "^1.2.0"
reflect.ownkeys@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460"
integrity sha1-dJrO7H8/34tj+SegSAnpDFwLNGA=
regenerate-unicode-properties@^8.2.0:
version "8.2.0"
resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec"
@@ -6659,7 +6998,7 @@ selfsigned@^1.10.7:
dependencies:
node-forge "^0.10.0"
"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1:
"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.6.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
@@ -6794,7 +7133,7 @@ shellwords@^0.1.1:
resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b"
integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==
side-channel@^1.0.2, side-channel@^1.0.3:
side-channel@^1.0.2, side-channel@^1.0.3, side-channel@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==
@@ -6899,6 +7238,14 @@ source-map-resolve@^0.5.0:
source-map-url "^0.4.0"
urix "^0.1.0"
source-map-resolve@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.6.0.tgz#3d9df87e236b53f16d01e58150fc7711138e5ed2"
integrity sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==
dependencies:
atob "^2.1.2"
decode-uri-component "^0.2.0"
source-map-support@^0.5.6, source-map-support@~0.5.19:
version "0.5.19"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
@@ -7084,6 +7431,14 @@ string.prototype.trimend@^1.0.1:
define-properties "^1.1.3"
es-abstract "^1.18.0-next.1"
string.prototype.trimend@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz#e75ae90c2942c63504686c18b287b4a0b1a45f80"
integrity sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==
dependencies:
call-bind "^1.0.2"
define-properties "^1.1.3"
string.prototype.trimstart@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.2.tgz#22d45da81015309cd0cdd79787e8919fc5c613e7"
@@ -7092,6 +7447,14 @@ string.prototype.trimstart@^1.0.1:
define-properties "^1.1.3"
es-abstract "^1.18.0-next.1"
string.prototype.trimstart@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz#b36399af4ab2999b4c9c648bd7a3fb2bb26feeed"
integrity sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==
dependencies:
call-bind "^1.0.2"
define-properties "^1.1.3"
string_decoder@^1.1.1:
version "1.3.0"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
@@ -7142,6 +7505,13 @@ strip-final-newline@^2.0.0:
resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
strip-indent@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001"
integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==
dependencies:
min-indent "^1.0.0"
strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
@@ -7419,6 +7789,16 @@ ua-parser-js@^0.7.18:
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31"
integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g==
unbox-primitive@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"
integrity sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==
dependencies:
function-bind "^1.1.1"
has-bigints "^1.0.1"
has-symbols "^1.0.2"
which-boxed-primitive "^1.0.2"
uncontrollable@^7.0.0:
version "7.1.1"
resolved "https://registry.yarnpkg.com/uncontrollable/-/uncontrollable-7.1.1.tgz#f67fed3ef93637126571809746323a9db815d556"
@@ -7792,6 +8172,17 @@ whatwg-url@^8.0.0:
tr46 "^2.0.2"
webidl-conversions "^6.1.0"
which-boxed-primitive@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"
integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==
dependencies:
is-bigint "^1.0.1"
is-boolean-object "^1.1.0"
is-number-object "^1.0.4"
is-string "^1.0.5"
is-symbol "^1.0.3"
which-module@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"

View File

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

View File

@@ -55,8 +55,15 @@ def run_migrations_offline():
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
connectable = config.attributes.get('connection', None)
if connectable is None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
else:
context.configure(
connection=connectable, target_metadata=target_metadata, literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
@@ -69,11 +76,14 @@ def run_migrations_online():
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
connectable = config.attributes.get('connection', None)
if connectable is None:
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)

View File

@@ -16,6 +16,7 @@ from tornado import web
from .. import orm
from .. import roles
from .. import scopes
from ..utils import get_browser_protocol
from ..utils import token_authenticated
from .base import APIHandler
from .base import BaseHandler
@@ -115,7 +116,10 @@ class OAuthHandler:
# make absolute local redirects full URLs
# to satisfy oauthlib's absolute URI requirement
redirect_uri = (
self.request.protocol + "://" + self.request.headers['Host'] + redirect_uri
get_browser_protocol(self.request)
+ "://"
+ self.request.host
+ redirect_uri
)
parsed_url = urlparse(uri)
query_list = parse_qsl(parsed_url.query, keep_blank_values=True)
@@ -308,12 +312,14 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
"filter": "",
}
]
elif 'all' in raw_scopes:
raw_scopes = ['all']
elif 'inherit' in raw_scopes:
raw_scopes = ['inherit']
scope_descriptions = [
{
"scope": "all",
"description": scopes.scope_definitions['all']['description'],
"scope": "inherit",
"description": scopes.scope_definitions['inherit'][
'description'
],
"filter": "",
}
]

View File

@@ -14,6 +14,7 @@ from tornado import web
from .. import orm
from ..handlers import BaseHandler
from ..utils import get_browser_protocol
from ..utils import isoformat
from ..utils import url_path_join
@@ -31,6 +32,9 @@ class APIHandler(BaseHandler):
- methods for REST API models
"""
# accept token-based authentication for API requests
_accept_token_auth = True
@property
def content_security_policy(self):
return '; '.join([super().content_security_policy, "default-src 'none'"])
@@ -55,7 +59,10 @@ class APIHandler(BaseHandler):
- allow unspecified host/referer (e.g. scripts)
"""
host = self.request.headers.get("Host")
host_header = self.app.forwarded_host_header or "Host"
host = self.request.headers.get(host_header)
if host and "," in host:
host = host.split(",", 1)[0].strip()
referer = self.request.headers.get("Referer")
# If no header is provided, assume it comes from a script/curl.
@@ -67,13 +74,25 @@ class APIHandler(BaseHandler):
self.log.warning("Blocking API request with no referer")
return False
host_path = url_path_join(host, self.hub.base_url)
referer_path = referer.split('://', 1)[-1]
if not (referer_path + '/').startswith(host_path):
proto = get_browser_protocol(self.request)
full_host = f"{proto}://{host}{self.hub.base_url}"
host_url = urlparse(full_host)
referer_url = urlparse(referer)
# resolve default ports for http[s]
referer_port = referer_url.port or (
443 if referer_url.scheme == 'https' else 80
)
host_port = host_url.port or (443 if host_url.scheme == 'https' else 80)
if (
referer_url.scheme != host_url.scheme
or referer_url.hostname != host_url.hostname
or referer_port != host_port
or not (referer_url.path + "/").startswith(host_url.path)
):
self.log.warning(
"Blocking Cross Origin API request. Referer: %s, Host: %s",
referer,
host_path,
f"Blocking Cross Origin API request. Referer: {referer},"
f" {host_header}: {host}, Host URL: {full_host}",
)
return False
return True
@@ -210,6 +229,7 @@ class APIHandler(BaseHandler):
'last_activity': isoformat(token.last_activity),
'expires_at': isoformat(token.expires_at),
'note': token.note,
'session_id': token.session_id,
'oauth_client': token.oauth_client.description
or token.oauth_client.identifier,
}

View File

@@ -58,6 +58,14 @@ class SelfAPIHandler(APIHandler):
model = get_model(user)
# add session_id associated with token
# added in 2.0
token = self.get_token()
if token:
model["session_id"] = token.session_id
else:
model["session_id"] = None
# add scopes to identify model,
# but not the scopes we added to ensure we could read our own model
model["scopes"] = sorted(self.expanded_scopes.difference(_added_scopes))
@@ -397,9 +405,11 @@ class UserTokenListAPIHandler(APIHandler):
token_roles = body.get('roles')
try:
api_token = user.new_api_token(
note=note, expires_in=body.get('expires_in', None), roles=token_roles
note=note,
expires_in=body.get('expires_in', None),
roles=token_roles,
)
except NameError:
except KeyError:
raise web.HTTPError(404, "Requested roles %r not found" % token_roles)
except ValueError:
raise web.HTTPError(
@@ -484,6 +494,11 @@ class UserServerAPIHandler(APIHandler):
@needs_scope('servers')
async def post(self, user_name, server_name=''):
user = self.find_user(user_name)
if user is None:
# this can be reached if a token has `servers`
# permission on *all* users
raise web.HTTPError(404)
if server_name:
if not self.allow_named_servers:
raise web.HTTPError(400, "Named servers are not enabled.")
@@ -699,7 +714,12 @@ class SpawnProgressAPIHandler(APIHandler):
# check if spawner has just failed
f = spawn_future
if f and f.done() and f.exception():
failed_event['message'] = "Spawn failed: %s" % f.exception()
exc = f.exception()
message = getattr(exc, "jupyterhub_message", str(exc))
failed_event['message'] = f"Spawn failed: {message}"
html_message = getattr(exc, "jupyterhub_html_message", "")
if html_message:
failed_event['html_message'] = html_message
await self.send_event(failed_event)
return
else:
@@ -732,7 +752,12 @@ class SpawnProgressAPIHandler(APIHandler):
# what happened? Maybe spawn failed?
f = spawn_future
if f and f.done() and f.exception():
failed_event['message'] = "Spawn failed: %s" % f.exception()
exc = f.exception()
message = getattr(exc, "jupyterhub_message", str(exc))
failed_event['message'] = f"Spawn failed: {message}"
html_message = getattr(exc, "jupyterhub_html_message", "")
if html_message:
failed_event['html_message'] = html_message
else:
self.log.warning(
"Server %s didn't start for unknown reason", spawner._log_name

View File

@@ -90,6 +90,7 @@ from .log import CoroutineLogFormatter, log_request
from .proxy import Proxy, ConfigurableHTTPProxy
from .traitlets import URLPrefix, Command, EntryPointType, Callable
from .utils import (
AnyTimeoutError,
catch_db_error,
maybe_future,
url_path_join,
@@ -790,6 +791,16 @@ class JupyterHub(Application):
self.proxy_api_ip or '127.0.0.1', self.proxy_api_port or self.port + 1
)
forwarded_host_header = Unicode(
'',
help="""Alternate header to use as the Host (e.g., X-Forwarded-Host)
when determining whether a request is cross-origin
This may be useful when JupyterHub is running behind a proxy that rewrites
the Host header.
""",
).tag(config=True)
hub_port = Integer(
8081,
help="""The internal port for the Hub process.
@@ -1892,6 +1903,7 @@ class JupyterHub(Application):
user = orm.User.find(db, name)
if user is None:
user = orm.User(name=name, admin=True)
roles.assign_default_roles(self.db, entity=user)
new_users.append(user)
db.add(user)
else:
@@ -1982,6 +1994,7 @@ class JupyterHub(Application):
self.log.info(f"Creating user {username}")
user = orm.User(name=username)
self.db.add(user)
roles.assign_default_roles(self.db, entity=user)
self.db.commit()
return user
@@ -2003,14 +2016,25 @@ class JupyterHub(Application):
async def init_role_creation(self):
"""Load default and predefined roles into the database"""
self.log.debug('Loading default roles to database')
self.log.debug('Loading roles into database')
default_roles = roles.get_default_roles()
config_role_names = [r['name'] for r in self.load_roles]
init_roles = default_roles
default_roles_dict = {role["name"]: role for role in default_roles}
init_roles = []
roles_with_new_permissions = []
for role_spec in self.load_roles:
role_name = role_spec['name']
if role_name in default_roles_dict:
self.log.debug(f"Overriding default role {role_name}")
# merge custom role spec with default role spec when overriding
# so the new role can be partially defined
default_role_spec = default_roles_dict.pop(role_name)
merged_role_spec = {}
merged_role_spec.update(default_role_spec)
merged_role_spec.update(role_spec)
role_spec = merged_role_spec
# Check for duplicates
if config_role_names.count(role_name) > 1:
raise ValueError(
@@ -2021,10 +2045,13 @@ class JupyterHub(Application):
old_role = orm.Role.find(self.db, name=role_name)
if old_role:
if not set(role_spec['scopes']).issubset(old_role.scopes):
app_log.warning(
self.log.warning(
"Role %s has obtained extra permissions" % role_name
)
roles_with_new_permissions.append(role_name)
# make sure we load any default roles not overridden
init_roles = list(default_roles_dict.values()) + init_roles
if roles_with_new_permissions:
unauthorized_oauth_tokens = (
self.db.query(orm.APIToken)
@@ -2036,7 +2063,7 @@ class JupyterHub(Application):
.filter(orm.APIToken.client_id != 'jupyterhub')
)
for token in unauthorized_oauth_tokens:
app_log.warning(
self.log.warning(
"Deleting OAuth token %s; one of its roles obtained new permissions that were not authorized by user"
% token
)
@@ -2044,14 +2071,19 @@ class JupyterHub(Application):
self.db.commit()
init_role_names = [r['name'] for r in init_roles]
if not orm.Role.find(self.db, name='admin'):
if (
self.db.query(orm.Role).first() is None
and self.db.query(orm.User).first() is not None
):
# apply rbac-upgrade default role assignment if there are users in the db,
# but not any roles
self._rbac_upgrade = True
else:
self._rbac_upgrade = False
for role in self.db.query(orm.Role).filter(
orm.Role.name.notin_(init_role_names)
):
app_log.info(f"Deleting role {role.name}")
self.log.warning(f"Deleting role {role.name}")
self.db.delete(role)
self.db.commit()
for role in init_roles:
@@ -2067,66 +2099,89 @@ class JupyterHub(Application):
if config_admin_users:
for role_spec in self.load_roles:
if role_spec['name'] == 'admin':
app_log.warning(
self.log.warning(
"Configuration specifies both admin_users and users in the admin role specification. "
"If admin role is present in config, c.authenticator.admin_users should not be used."
"If admin role is present in config, c.Authenticator.admin_users should not be used."
)
app_log.info(
self.log.info(
"Merging admin_users set with users list in admin role"
)
role_spec['users'] = set(role_spec.get('users', []))
role_spec['users'] |= config_admin_users
self.log.debug('Loading predefined roles from config file to database')
self.log.debug('Loading role assignments from config')
has_admin_role_spec = {role_bearer: False for role_bearer in admin_role_objects}
for predef_role in self.load_roles:
predef_role_obj = orm.Role.find(db, name=predef_role['name'])
if predef_role['name'] == 'admin':
for role_spec in self.load_roles:
role = orm.Role.find(db, name=role_spec['name'])
role_name = role_spec["name"]
if role_name == 'admin':
for kind in admin_role_objects:
has_admin_role_spec[kind] = kind in predef_role
has_admin_role_spec[kind] = kind in role_spec
if has_admin_role_spec[kind]:
app_log.info(f"Admin role specifies static {kind} list")
self.log.info(f"Admin role specifies static {kind} list")
else:
app_log.info(
self.log.info(
f"Admin role does not specify {kind}, preserving admin membership in database"
)
# add users, services, and/or groups,
# tokens need to be checked for permissions
for kind in kinds:
orm_role_bearers = []
if kind in predef_role.keys():
for bname in predef_role[kind]:
if kind in role_spec:
for name in role_spec[kind]:
if kind == 'users':
bname = self.authenticator.normalize_username(bname)
name = self.authenticator.normalize_username(name)
if not (
await maybe_future(
self.authenticator.check_allowed(bname, None)
self.authenticator.check_allowed(name, None)
)
):
raise ValueError(
"Username %r is not in Authenticator.allowed_users"
% bname
f"Username {name} is not in Authenticator.allowed_users"
)
Class = orm.get_class(kind)
orm_obj = Class.find(db, bname)
if orm_obj:
orm_obj = Class.find(db, name)
if orm_obj is not None:
orm_role_bearers.append(orm_obj)
else:
app_log.info(
f"Found unexisting {kind} {bname} in role definition {predef_role['name']}"
self.log.info(
f"Found unexisting {kind} {name} in role definition {role_name}"
)
if kind == 'users':
orm_obj = await self._get_or_create_user(bname)
orm_obj = await self._get_or_create_user(name)
orm_role_bearers.append(orm_obj)
elif kind == 'groups':
group = orm.Group(name=name)
db.add(group)
db.commit()
orm_role_bearers.append(group)
else:
raise ValueError(
f"{kind} {bname} defined in config role definition {predef_role['name']} but not present in database"
f"{kind} {name} defined in config role definition {role_name} but not present in database"
)
# Ensure all with admin role have admin flag
if predef_role['name'] == 'admin':
if role_name == 'admin':
orm_obj.admin = True
setattr(predef_role_obj, kind, orm_role_bearers)
# explicitly defined list
# ensure membership list is exact match (adds and revokes permissions)
setattr(role, kind, orm_role_bearers)
else:
# no defined members
# leaving 'users' undefined in overrides of the default 'user' role
# should not clear membership on startup
# since allowed users could be managed by the authenticator
if kind == "users" and role_name == "user":
# Default user lists can be managed by the Authenticator,
# if unspecified in role config
pass
else:
# otherwise, omitting a member category is equivalent to specifying an empty list
setattr(role, kind, [])
db.commit()
if self.authenticator.allowed_users:
self.log.debug(
f"Assigning {len(self.authenticator.allowed_users)} allowed_users to the user role"
)
allowed_users = db.query(orm.User).filter(
orm.User.name.in_(self.authenticator.allowed_users)
)
@@ -2143,8 +2198,8 @@ class JupyterHub(Application):
db.commit()
# make sure that on hub upgrade, all users, services and tokens have at least one role (update with default)
if getattr(self, '_rbac_upgrade', False):
app_log.warning(
"No admin role found; assuming hub upgrade. Initializing default roles for all entities"
self.log.warning(
"No roles found; assuming hub upgrade. Initializing default roles for all entities"
)
for kind in kinds:
roles.check_for_default_roles(db, kind)
@@ -2350,7 +2405,7 @@ class JupyterHub(Application):
continue
try:
await Server.from_orm(service.orm.server).wait_up(timeout=1, http=True)
except TimeoutError:
except AnyTimeoutError:
self.log.warning(
"Cannot connect to %s service %s at %s",
service.kind,
@@ -2428,7 +2483,7 @@ class JupyterHub(Application):
)
try:
await user._wait_up(spawner)
except TimeoutError:
except AnyTimeoutError:
self.log.error(
"%s does not appear to be running at %s, shutting it down.",
spawner._log_name,
@@ -2792,7 +2847,7 @@ class JupyterHub(Application):
await gen.with_timeout(
timedelta(seconds=max(init_spawners_timeout, 1)), init_spawners_future
)
except gen.TimeoutError:
except AnyTimeoutError:
self.log.warning(
"init_spawners did not complete within %i seconds. "
"Allowing to complete in the background.",
@@ -3055,7 +3110,7 @@ class JupyterHub(Application):
await Server.from_orm(service.orm.server).wait_up(
http=True, timeout=1, ssl_context=ssl_context
)
except TimeoutError:
except AnyTimeoutError:
if service.managed:
status = await service.spawner.poll()
if status is not None:

View File

@@ -45,9 +45,12 @@ from ..metrics import ServerSpawnStatus
from ..metrics import ServerStopStatus
from ..metrics import TOTAL_USERS
from ..objects import Server
from ..scopes import needs_scope
from ..spawner import LocalProcessSpawner
from ..user import User
from ..utils import AnyTimeoutError
from ..utils import get_accepted_mimetype
from ..utils import get_browser_protocol
from ..utils import maybe_future
from ..utils import url_path_join
@@ -70,6 +73,12 @@ SESSION_COOKIE_NAME = 'jupyterhub-session-id'
class BaseHandler(RequestHandler):
"""Base Handler class with access to common methods and properties."""
# by default, only accept cookie-based authentication
# The APIHandler base class enables token auth
# versionadded: 2.0
_accept_cookie_auth = True
_accept_token_auth = False
async def prepare(self):
"""Identify the user during the prepare stage of each request
@@ -339,6 +348,7 @@ class BaseHandler(RequestHandler):
auth_info['auth_state'] = await user.get_auth_state()
return await self.auth_to_user(auth_info, user)
@functools.lru_cache()
def get_token(self):
"""get token from authorization header"""
token = self.get_auth_token()
@@ -409,9 +419,11 @@ class BaseHandler(RequestHandler):
async def get_current_user(self):
"""get current username"""
if not hasattr(self, '_jupyterhub_user'):
user = None
try:
user = self.get_current_user_token()
if user is None:
if self._accept_token_auth:
user = self.get_current_user_token()
if user is None and self._accept_cookie_auth:
user = self.get_current_user_cookie()
if user and isinstance(user, User):
user = await self.refresh_auth(user)
@@ -622,12 +634,10 @@ class BaseHandler(RequestHandler):
next_url = self.get_argument('next', default='')
# protect against some browsers' buggy handling of backslash as slash
next_url = next_url.replace('\\', '%5C')
if (next_url + '/').startswith(
(
f'{self.request.protocol}://{self.request.host}/',
f'//{self.request.host}/',
)
) or (
proto = get_browser_protocol(self.request)
host = self.request.host
if (next_url + '/').startswith((f'{proto}://{host}/', f'//{host}/',)) or (
self.subdomain_host
and urlparse(next_url).netloc
and ("." + urlparse(next_url).netloc).endswith(
@@ -761,8 +771,9 @@ class BaseHandler(RequestHandler):
# Only set `admin` if the authenticator returned an explicit value.
if admin is not None and admin != user.admin:
user.admin = admin
roles.assign_default_roles(self.db, entity=user)
self.db.commit()
# always ensure default roles ('user', 'admin' if admin) are assigned
# after a successful login
roles.assign_default_roles(self.db, entity=user)
# always set auth_state and commit,
# because there could be key-rotation or clearing of previous values
# going on.
@@ -1021,7 +1032,7 @@ class BaseHandler(RequestHandler):
await gen.with_timeout(
timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future
)
except gen.TimeoutError:
except AnyTimeoutError:
# waiting_for_response indicates server process has started,
# but is yet to become responsive.
if spawner._spawn_pending and not spawner._waiting_for_response:
@@ -1168,7 +1179,7 @@ class BaseHandler(RequestHandler):
try:
await gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), future)
except gen.TimeoutError:
except AnyTimeoutError:
# hit timeout, but stop is still pending
self.log.warning(
"User %s:%s server is slow to stop (timeout=%s)",
@@ -1371,6 +1382,9 @@ class UserUrlHandler(BaseHandler):
Note that this only occurs if bob's server is not already running.
"""
# accept token auth for API requests that are probably to non-running servers
_accept_token_auth = True
def _fail_api_request(self, user_name='', server_name=''):
"""Fail an API request to a not-running server"""
self.log.warning(
@@ -1435,54 +1449,24 @@ class UserUrlHandler(BaseHandler):
delete = non_get
@web.authenticated
@needs_scope("access:servers")
async def get(self, user_name, user_path):
if not user_path:
user_path = '/'
current_user = self.current_user
if (
current_user
and current_user.name != user_name
and current_user.admin
and self.settings.get('admin_access', False)
):
# allow admins to spawn on behalf of users
if user_name != current_user.name:
user = self.find_user(user_name)
if user is None:
# no such user
raise web.HTTPError(404, "No such user %s" % user_name)
raise web.HTTPError(404, f"No such user {user_name}")
self.log.info(
"Admin %s requesting spawn on behalf of %s",
current_user.name,
user.name,
f"User {current_user.name} requesting spawn on behalf of {user.name}"
)
admin_spawn = True
should_spawn = True
redirect_to_self = False
else:
user = current_user
admin_spawn = False
# For non-admins, spawn if the user requested is the current user
# otherwise redirect users to their own server
should_spawn = current_user and current_user.name == user_name
redirect_to_self = not should_spawn
if redirect_to_self:
# logged in as a different non-admin user, redirect to user's own server
# this is only a stop-gap for a common mistake,
# because the same request will be a 403
# if the requested server is running
self.statsd.incr('redirects.user_to_user', 1)
self.log.warning(
"User %s requested server for %s, which they don't own",
current_user.name,
user_name,
)
target = url_path_join(current_user.url, user_path or '')
if self.request.query:
target = url_concat(target, parse_qsl(self.request.query))
self.redirect(target)
return
# If people visit /user/:user_name directly on the Hub,
# the redirects will just loop, because the proxy is bypassed.

View File

@@ -12,6 +12,8 @@ class MetricsHandler(BaseHandler):
Handler to serve Prometheus metrics
"""
_accept_token_auth = True
@metrics_authentication
async def get(self):
self.set_header('Content-Type', CONTENT_TYPE_LATEST)

View File

@@ -106,22 +106,27 @@ class SpawnHandler(BaseHandler):
)
@web.authenticated
async def get(self, for_user=None, server_name=''):
def get(self, user_name=None, server_name=''):
"""GET renders form for spawning with user-specified options
or triggers spawn via redirect if there is no form.
"""
# two-stage to get the right signature for @require_scopes filter on user_name
if user_name is None:
user_name = self.current_user.name
if server_name is None:
server_name = ""
return self._get(user_name=user_name, server_name=server_name)
@needs_scope("servers")
async def _get(self, user_name, server_name):
for_user = user_name
user = current_user = self.current_user
if for_user is not None and for_user != user.name:
if not user.admin:
raise web.HTTPError(
403, "Only admins can spawn on behalf of other users"
)
if for_user != user.name:
user = self.find_user(for_user)
if user is None:
raise web.HTTPError(404, "No such user: %s" % for_user)
raise web.HTTPError(404, f"No such user: {for_user}")
if server_name:
if not self.allow_named_servers:
@@ -141,14 +146,11 @@ class SpawnHandler(BaseHandler):
)
if not self.allow_named_servers and user.running:
url = self.get_next_url(user, default=user.server_url(server_name))
url = self.get_next_url(user, default=user.server_url(""))
self.log.info("User is running: %s", user.name)
self.redirect(url)
return
if server_name is None:
server_name = ''
spawner = user.spawners[server_name]
pending_url = self._get_pending_url(user, server_name)
@@ -189,7 +191,6 @@ class SpawnHandler(BaseHandler):
spawner._log_name,
)
options = await maybe_future(spawner.options_from_query(query_options))
pending_url = self._get_pending_url(user, server_name)
return await self._wrap_spawn_single_user(
user, server_name, spawner, pending_url, options
)
@@ -219,14 +220,19 @@ class SpawnHandler(BaseHandler):
)
@web.authenticated
async def post(self, for_user=None, server_name=''):
def post(self, user_name=None, server_name=''):
"""POST spawns with user-specified options"""
if user_name is None:
user_name = self.current_user.name
if server_name is None:
server_name = ""
return self._post(user_name=user_name, server_name=server_name)
@needs_scope("servers")
async def _post(self, user_name, server_name):
for_user = user_name
user = current_user = self.current_user
if for_user is not None and for_user != user.name:
if not user.admin:
raise web.HTTPError(
403, "Only admins can spawn on behalf of other users"
)
if for_user != user.name:
user = self.find_user(for_user)
if user is None:
raise web.HTTPError(404, "No such user: %s" % for_user)
@@ -308,10 +314,13 @@ class SpawnHandler(BaseHandler):
# otherwise it may cause a redirect loop
if f.done() and f.exception():
exc = f.exception()
self.log.exception(f"Error starting server {spawner._log_name}: {exc}")
if isinstance(exc, web.HTTPError):
# allow custom HTTPErrors to pass through
raise exc
raise web.HTTPError(
500,
"Error in Authenticator.pre_spawn_start: %s %s"
% (type(exc).__name__, str(exc)),
f"Unhandled error starting server {spawner._log_name}",
)
return self.redirect(pending_url)
@@ -334,13 +343,11 @@ class SpawnPendingHandler(BaseHandler):
"""
@web.authenticated
async def get(self, for_user, server_name=''):
@needs_scope("servers")
async def get(self, user_name, server_name=''):
for_user = user_name
user = current_user = self.current_user
if for_user is not None and for_user != current_user.name:
if not current_user.admin:
raise web.HTTPError(
403, "Only admins can spawn on behalf of other users"
)
if for_user != current_user.name:
user = self.find_user(for_user)
if user is None:
raise web.HTTPError(404, "No such user: %s" % for_user)
@@ -384,6 +391,7 @@ class SpawnPendingHandler(BaseHandler):
server_name=server_name,
spawn_url=spawn_url,
failed=True,
failed_html_message=getattr(exc, 'jupyterhub_html_message', ''),
failed_message=getattr(exc, 'jupyterhub_message', ''),
exception=exc,
)
@@ -465,6 +473,7 @@ class AdminHandler(BaseHandler):
named_server_limit_per_user=self.named_server_limit_per_user,
server_version=f'{__version__} {self.version_hash}',
api_page_limit=self.settings["api_page_default_limit"],
base_url=self.settings["base_url"],
)
self.finish(html)

View File

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

View File

@@ -2,6 +2,7 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import re
from functools import wraps
from itertools import chain
from sqlalchemy import func
@@ -44,6 +45,7 @@ def get_default_roles():
'access:services',
'access:servers',
'read:roles',
'read:metrics',
],
},
{
@@ -57,7 +59,7 @@ def get_default_roles():
{
'name': 'token',
'description': 'Token with same permissions as its owner',
'scopes': ['all'],
'scopes': ['inherit'],
},
]
return default_roles
@@ -214,7 +216,7 @@ def _check_scopes(*args, rolename=None):
or
scopes (list): list of scopes to check
Raises NameError if scope does not exist
Raises KeyError if scope does not exist
"""
allowed_scopes = set(scopes.scope_definitions.keys())
@@ -228,35 +230,17 @@ def _check_scopes(*args, rolename=None):
for scope in args:
scopename, _, filter_ = scope.partition('!')
if scopename not in allowed_scopes:
raise NameError(f"Scope '{scope}' {log_role} does not exist")
if scopename == "all":
raise KeyError("Draft scope 'all' is now called 'inherit'")
raise KeyError(f"Scope '{scope}' {log_role} does not exist")
if filter_:
full_filter = f"!{filter_}"
if not any(f in scope for f in allowed_filters):
raise NameError(
raise KeyError(
f"Scope filter '{full_filter}' in scope '{scope}' {log_role} does not exist"
)
def _overwrite_role(role, role_dict):
"""Overwrites role's description and/or scopes with role_dict if role not 'admin'"""
for attr in role_dict.keys():
if attr == 'description' or attr == 'scopes':
if role.name == 'admin':
admin_role_spec = [
r for r in get_default_roles() if r['name'] == 'admin'
][0]
if role_dict[attr] != admin_role_spec[attr]:
raise ValueError(
'admin role description or scopes cannot be overwritten'
)
else:
if role_dict[attr] != getattr(role, attr):
setattr(role, attr, role_dict[attr])
app_log.info(
'Role %r %r attribute has been changed', role.name, attr
)
_role_name_pattern = re.compile(r'^[a-z][a-z0-9\-_~\.]{1,253}[a-z0-9]$')
@@ -291,6 +275,17 @@ def create_role(db, role_dict):
description = role_dict.get('description')
scopes = role_dict.get('scopes')
if name == "admin":
for _role in get_default_roles():
if _role["name"] == "admin":
admin_spec = _role
break
for key in ["description", "scopes"]:
if key in role_dict and role_dict[key] != admin_spec[key]:
raise ValueError(
f"Cannot override admin role admin.{key} = {role_dict[key]}"
)
# check if the provided scopes exist
if scopes:
_check_scopes(*scopes, rolename=role_dict['name'])
@@ -304,8 +299,22 @@ def create_role(db, role_dict):
if role_dict not in default_roles:
app_log.info('Role %s added to database', name)
else:
_overwrite_role(role, role_dict)
for attr in ["description", "scopes"]:
try:
new_value = role_dict[attr]
except KeyError:
continue
old_value = getattr(role, attr)
if new_value != old_value:
setattr(role, attr, new_value)
app_log.info(
f'Role attribute {role.name}.{attr} has been changed',
)
app_log.debug(
f'Role attribute {role.name}.{attr} changed from %r to %r',
old_value,
new_value,
)
db.commit()
@@ -322,81 +331,64 @@ def delete_role(db, rolename):
db.commit()
app_log.info('Role %s has been deleted', rolename)
else:
raise NameError('Cannot remove role %r that does not exist', rolename)
raise KeyError('Cannot remove role %r that does not exist', rolename)
def existing_only(func):
"""Decorator for checking if objects and roles exist"""
def _existing_only(func):
"""Decorator for checking if roles exist"""
def _check_existence(db, entity, rolename):
role = orm.Role.find(db, rolename)
if entity is None:
raise ValueError(
f"{entity!r} of kind {type(entity).__name__!r} does not exist"
)
elif role is None:
raise ValueError("Role %r does not exist" % rolename)
else:
func(db, entity, role)
@wraps(func)
def _check_existence(db, entity, role=None, *, rolename=None):
if isinstance(role, str):
rolename = role
if rolename is not None:
# if given as a str, lookup role by name
role = orm.Role.find(db, rolename)
if role is None:
raise ValueError(f"Role {rolename} does not exist")
return func(db, entity, role)
return _check_existence
@existing_only
def grant_role(db, entity, rolename):
@_existing_only
def grant_role(db, entity, role):
"""Adds a role for users, services, groups or tokens"""
if isinstance(entity, orm.APIToken):
entity_repr = entity
else:
entity_repr = entity.name
if rolename not in entity.roles:
entity.roles.append(rolename)
if role not in entity.roles:
entity.roles.append(role)
db.commit()
app_log.info(
'Adding role %s for %s: %s',
rolename.name,
role.name,
type(entity).__name__,
entity_repr,
)
@existing_only
def strip_role(db, entity, rolename):
@_existing_only
def strip_role(db, entity, role):
"""Removes a role for users, services, groups or tokens"""
if isinstance(entity, orm.APIToken):
entity_repr = entity
else:
entity_repr = entity.name
if rolename in entity.roles:
entity.roles.remove(rolename)
if role in entity.roles:
entity.roles.remove(role)
db.commit()
app_log.info(
'Removing role %s for %s: %s',
rolename.name,
role.name,
type(entity).__name__,
entity_repr,
)
def _switch_default_role(db, obj, admin):
"""Switch between default user/service and admin roles for users/services"""
user_role = orm.Role.find(db, 'user')
admin_role = orm.Role.find(db, 'admin')
def add_and_remove(db, obj, current_role, new_role):
if current_role in obj.roles:
strip_role(db, entity=obj, rolename=current_role.name)
# only add new default role if the user has no other roles
if len(obj.roles) < 1:
grant_role(db, entity=obj, rolename=new_role.name)
if admin:
add_and_remove(db, obj, user_role, admin_role)
else:
add_and_remove(db, obj, admin_role, user_role)
def _token_allowed_role(db, token, role):
"""Checks if requested role for token does not grant the token
higher permissions than the token's owner has
@@ -413,56 +405,67 @@ def _token_allowed_role(db, token, role):
expanded_scopes = _get_subscopes(role, owner=owner)
implicit_permissions = {'all', 'read:all'}
implicit_permissions = {'inherit', 'read:inherit'}
explicit_scopes = expanded_scopes - implicit_permissions
# ignore horizontal filters
no_filter_scopes = {
scope.split('!', 1)[0] if '!' in scope else scope for scope in explicit_scopes
}
# find the owner's scopes
expanded_owner_scopes = expand_roles_to_scopes(owner)
# ignore horizontal filters
no_filter_owner_scopes = {
scope.split('!', 1)[0] if '!' in scope else scope
for scope in expanded_owner_scopes
}
disallowed_scopes = no_filter_scopes.difference(no_filter_owner_scopes)
allowed_scopes = scopes._intersect_expanded_scopes(
explicit_scopes, expanded_owner_scopes, db
)
disallowed_scopes = explicit_scopes.difference(allowed_scopes)
if not disallowed_scopes:
# no scopes requested outside owner's own scopes
return True
else:
app_log.warning(
f"Token requesting scopes exceeding owner {owner.name}: {disallowed_scopes}"
f"Token requesting role {role.name} with scopes not held by owner {owner.name}: {disallowed_scopes}"
)
return False
def assign_default_roles(db, entity):
"""Assigns default role to an entity:
users and services get 'user' role, or admin role if they have admin flag
"""Assigns default role(s) to an entity:
tokens get 'token' role
users and services get 'admin' role if they are admin (removed if they are not)
users always get 'user' role
"""
if isinstance(entity, orm.Group):
pass
elif isinstance(entity, orm.APIToken):
app_log.debug('Assigning default roles to tokens')
return
if isinstance(entity, orm.APIToken):
app_log.debug('Assigning default role to token')
default_token_role = orm.Role.find(db, 'token')
if not entity.roles and (entity.user or entity.service) is not None:
default_token_role.tokens.append(entity)
app_log.info('Added role %s to token %s', default_token_role.name, entity)
db.commit()
# users and services can have 'user' or 'admin' roles as default
db.commit()
# users and services all have 'user' role by default
# and optionally 'admin' as well
else:
kind = type(entity).__name__
app_log.debug(f'Assigning default roles to {kind} {entity.name}')
_switch_default_role(db, entity, entity.admin)
app_log.debug(f'Assigning default role to {kind} {entity.name}')
if entity.admin:
grant_role(db, entity=entity, rolename="admin")
else:
admin_role = orm.Role.find(db, 'admin')
if admin_role in entity.roles:
strip_role(db, entity=entity, rolename="admin")
if kind == "User":
grant_role(db, entity=entity, rolename="user")
def update_roles(db, entity, roles):
"""Updates object's roles checking for requested permissions
if object is orm.APIToken
"""Add roles to an entity (token, user, etc.)
If it is an API token, check role permissions against token owner
prior to assignment to avoid permission expansion.
Otherwise, it just calls `grant_role` for each role.
"""
standard_permissions = {'all', 'read:all'}
for rolename in roles:
if isinstance(entity, orm.APIToken):
role = orm.Role.find(db, rolename)
@@ -475,12 +478,11 @@ def update_roles(db, entity, roles):
app_log.info('Adding role %s to token: %s', role.name, entity)
else:
raise ValueError(
f'Requested token role {rolename} of {entity} has more permissions than the token owner'
f'Requested token role {rolename} for {entity} has more permissions than the token owner'
)
else:
raise NameError('Role %r does not exist' % rolename)
raise KeyError(f'Role {rolename} does not exist')
else:
app_log.debug('Assigning default roles to %s', type(entity).__name__)
grant_role(db, entity=entity, rolename=rolename)

View File

@@ -30,7 +30,7 @@ scope_definitions = {
'description': 'Your own resources',
'doc_description': 'The users own resources _(metascope for users, resolves to (no_scope) for services)_',
},
'all': {
'inherit': {
'description': 'Anything you have access to',
'doc_description': 'Everything that the token-owning entity can access _(metascope for tokens)_',
},
@@ -131,6 +131,9 @@ scope_definitions = {
'description': 'Read information about the proxys routing table, sync the Hub with the proxy and notify the Hub about a new proxy.'
},
'shutdown': {'description': 'Shutdown the hub.'},
'read:metrics': {
'description': "Read prometheus metrics.",
},
}
@@ -295,7 +298,7 @@ def get_scopes_for(orm_object):
)
if isinstance(orm_object, orm.APIToken):
app_log.warning(f"Authenticated with token {orm_object}")
app_log.debug(f"Authenticated with token {orm_object}")
owner = orm_object.user or orm_object.service
token_scopes = roles.expand_roles_to_scopes(orm_object)
if orm_object.client_id != "jupyterhub":
@@ -317,13 +320,13 @@ def get_scopes_for(orm_object):
owner_scopes = roles.expand_roles_to_scopes(owner)
if token_scopes == {'all'}:
# token_scopes is only 'all', return owner scopes as-is
if token_scopes == {'inherit'}:
# token_scopes is only 'inherit', return scopes inherited from owner as-is
# short-circuit common case where we don't need to compute an intersection
return owner_scopes
if 'all' in token_scopes:
token_scopes.remove('all')
if 'inherit' in token_scopes:
token_scopes.remove('inherit')
token_scopes |= owner_scopes
intersection = _intersect_expanded_scopes(

View File

@@ -3,10 +3,24 @@
Tokens are sent to the Hub for verification.
The Hub replies with a JSON model describing the authenticated user.
``HubAuth`` can be used in any application, even outside tornado.
This contains two levels of authentication:
``HubAuthenticated`` is a mixin class for tornado handlers that should
authenticate with the Hub.
- :class:`HubOAuth` - Use OAuth 2 to authenticate browsers with the Hub.
This should be used for any service that should respond to browser requests
(i.e. most services).
- :class:`HubAuth` - token-only authentication, for a service that only need to handle token-authenticated API requests
The ``Auth`` classes (:class:`HubAuth`, :class:`HubOAuth`)
can be used in any application, even outside tornado.
They contain reference implementations of talking to the Hub API
to resolve a token to a user.
The ``Authenticated`` classes (:class:`HubAuthenticated`, :class:`HubOAuthenticated`)
are mixins for tornado handlers that should authenticate with the Hub.
If you are using OAuth, you will also need to register an oauth callback handler to complete the oauth process.
A tornado implementation is provided in :class:`HubOAuthCallbackHandler`.
"""
import base64
@@ -39,6 +53,7 @@ from traitlets import validate
from traitlets.config import SingletonConfigurable
from ..scopes import _intersect_expanded_scopes
from ..utils import get_browser_protocol
from ..utils import url_path_join
@@ -212,6 +227,7 @@ class HubAuth(SingletonConfigurable):
help="""The base API URL of the Hub.
Typically `http://hub-ip:hub-port/hub/api`
Default: $JUPYTERHUB_API_URL
""",
).tag(config=True)
@@ -227,7 +243,10 @@ class HubAuth(SingletonConfigurable):
os.getenv('JUPYTERHUB_API_TOKEN', ''),
help="""API key for accessing Hub API.
Generate with `jupyterhub token [username]` or add to JupyterHub.services config.
Default: $JUPYTERHUB_API_TOKEN
Loaded from services configuration in jupyterhub_config.
Will be auto-generated for hub-managed services.
""",
).tag(config=True)
@@ -236,6 +255,7 @@ class HubAuth(SingletonConfigurable):
help="""The URL prefix for the Hub itself.
Typically /hub/
Default: $JUPYTERHUB_BASE_URL
""",
).tag(config=True)
@@ -753,7 +773,7 @@ class HubOAuth(HubAuth):
# OAuth that doesn't complete shouldn't linger too long.
'max_age': 600,
}
if handler.request.protocol == 'https':
if get_browser_protocol(handler.request) == 'https':
kwargs['secure'] = True
# load user cookie overrides
kwargs.update(self.cookie_options)
@@ -793,7 +813,7 @@ class HubOAuth(HubAuth):
def set_cookie(self, handler, access_token):
"""Set a cookie recording OAuth result"""
kwargs = {'path': self.base_url, 'httponly': True}
if handler.request.protocol == 'https':
if get_browser_protocol(handler.request) == 'https':
kwargs['secure'] = True
# load user cookie overrides
kwargs.update(self.cookie_options)
@@ -854,8 +874,6 @@ class HubAuthenticated:
Examples::
class MyHandler(HubAuthenticated, web.RequestHandler):
hub_users = {'inara', 'mal'}
def initialize(self, hub_auth):
self.hub_auth = hub_auth
@@ -865,6 +883,7 @@ class HubAuthenticated:
"""
# deprecated, pre-2.0 allow sets
hub_services = None # set of allowed services
hub_users = None # set of allowed users
hub_groups = None # set of allowed groups
@@ -960,6 +979,10 @@ class HubAuthenticated:
raise UserNotAllowed(model)
# proceed with the pre-2.0 way if hub_scopes is not set
warnings.warn(
"hub_scopes ($JUPYTERHUB not set, proceeding with pre-2.0 authentication",
DeprecationWarning,
)
if self.allow_admin and model.get('admin', False):
app_log.debug("Allowing Hub admin %s", name)
@@ -1023,8 +1046,8 @@ class HubAuthenticated:
self._hub_auth_user_cache = None
raise
# store tokens passed via url or header in a cookie for future requests
url_token = self.hub_auth.get_token(self)
# store ?token=... tokens passed via url in a cookie for future requests
url_token = self.get_argument('token', '')
if (
user_model
and url_token

View File

@@ -18,6 +18,7 @@ import sys
import warnings
from datetime import datetime
from datetime import timezone
from importlib import import_module
from textwrap import dedent
from urllib.parse import urlparse
@@ -606,10 +607,34 @@ class SingleUserNotebookAppMixin(Configurable):
t = self.hub_activity_interval * (1 + 0.2 * (random.random() - 0.5))
await asyncio.sleep(t)
def _log_app_versions(self):
"""Log application versions at startup
Logs versions of jupyterhub and singleuser-server base versions (jupyterlab, jupyter_server, notebook)
"""
self.log.info(f"Starting jupyterhub single-user server version {__version__}")
# don't log these package versions
seen = {"jupyterhub", "traitlets", "jupyter_core", "builtins"}
for cls in self.__class__.mro():
module_name = cls.__module__.partition(".")[0]
if module_name not in seen:
seen.add(module_name)
try:
mod = import_module(module_name)
mod_version = getattr(mod, "__version__")
except Exception:
mod_version = ""
self.log.info(
f"Extending {cls.__module__}.{cls.__name__} from {module_name} {mod_version}"
)
def initialize(self, argv=None):
# disable trash by default
# this can be re-enabled by config
self.config.FileContentsManager.delete_to_trash = False
self._log_app_versions()
return super().initialize(argv)
def start(self):
@@ -715,6 +740,18 @@ class SingleUserNotebookAppMixin(Configurable):
orig_loader = env.loader
env.loader = ChoiceLoader([FunctionLoader(get_page), orig_loader])
def load_server_extensions(self):
# Loading LabApp sets $JUPYTERHUB_API_TOKEN on load, which is incorrect
r = super().load_server_extensions()
# clear the token in PageConfig at this step
# so that cookie auth is used
# FIXME: in the future,
# it would probably make sense to set page_config.token to the token
# from the current request.
if 'page_config_data' in self.web_app.settings:
self.web_app.settings['page_config_data']['token'] = ''
return r
def detect_base_package(App):
"""Detect the base package for an App class

View File

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

View File

@@ -9,6 +9,7 @@ from datetime import timedelta
from unittest import mock
from urllib.parse import quote
from urllib.parse import urlparse
from urllib.parse import urlunparse
from pytest import fixture
from pytest import mark
@@ -65,7 +66,15 @@ async def test_auth_api(app):
assert r.status_code == 403
async def test_cors_checks(app):
@mark.parametrize(
"content_type, status",
[
("text/plain", 403),
# accepted, but invalid
("application/json; charset=UTF-8", 400),
],
)
async def test_post_content_type(app, content_type, status):
url = ujoin(public_host(app), app.hub.base_url)
host = urlparse(url).netloc
# add admin user
@@ -74,42 +83,6 @@ async def test_cors_checks(app):
user = add_user(app.db, name='admin', admin=True)
cookies = await app.login_user('admin')
r = await api_request(
app, 'users', headers={'Authorization': '', 'Referer': 'null'}, cookies=cookies
)
assert r.status_code == 403
r = await api_request(
app,
'users',
headers={
'Authorization': '',
'Referer': 'http://attack.com/csrf/vulnerability',
},
cookies=cookies,
)
assert r.status_code == 403
r = await api_request(
app,
'users',
headers={'Authorization': '', 'Referer': url, 'Host': host},
cookies=cookies,
)
assert r.status_code == 200
r = await api_request(
app,
'users',
headers={
'Authorization': '',
'Referer': ujoin(url, 'foo/bar/baz/bat'),
'Host': host,
},
cookies=cookies,
)
assert r.status_code == 200
r = await api_request(
app,
'users',
@@ -117,24 +90,115 @@ async def test_cors_checks(app):
data='{}',
headers={
"Authorization": "",
"Content-Type": "text/plain",
"Content-Type": content_type,
},
cookies=cookies,
)
assert r.status_code == 403
assert r.status_code == status
@mark.parametrize(
"host, referer, extraheaders, status",
[
('$host', '$url', {}, 200),
(None, None, {}, 200),
(None, 'null', {}, 403),
(None, 'http://attack.com/csrf/vulnerability', {}, 403),
('$host', {"path": "/user/someuser"}, {}, 403),
('$host', {"path": "{path}/foo/bar/subpath"}, {}, 200),
# mismatch host
("mismatch.com", "$url", {}, 403),
# explicit host, matches
("fake.example", {"netloc": "fake.example"}, {}, 200),
# explicit port, matches implicit port
("fake.example:80", {"netloc": "fake.example"}, {}, 200),
# explicit port, mismatch
("fake.example:81", {"netloc": "fake.example"}, {}, 403),
# implicit ports, mismatch proto
("fake.example", {"netloc": "fake.example", "scheme": "https"}, {}, 403),
# explicit ports, match
("fake.example:81", {"netloc": "fake.example:81"}, {}, 200),
# Test proxy protocol defined headers taken into account by utils.get_browser_protocol
(
"fake.example",
{"netloc": "fake.example", "scheme": "https"},
{'X-Scheme': 'https'},
200,
),
(
"fake.example",
{"netloc": "fake.example", "scheme": "https"},
{'X-Forwarded-Proto': 'https'},
200,
),
(
"fake.example",
{"netloc": "fake.example", "scheme": "https"},
{
'Forwarded': 'host=fake.example;proto=https,for=1.2.34;proto=http',
'X-Scheme': 'http',
},
200,
),
(
"fake.example",
{"netloc": "fake.example", "scheme": "https"},
{
'Forwarded': 'host=fake.example;proto=http,for=1.2.34;proto=http',
'X-Scheme': 'https',
},
403,
),
("fake.example", {"netloc": "fake.example"}, {'X-Scheme': 'https'}, 403),
("fake.example", {"netloc": "fake.example"}, {'X-Scheme': 'https, http'}, 403),
],
)
async def test_cors_check(request, app, host, referer, extraheaders, status):
url = ujoin(public_host(app), app.hub.base_url)
real_host = urlparse(url).netloc
if host == "$host":
host = real_host
if referer == '$url':
referer = url
elif isinstance(referer, dict):
parsed_url = urlparse(url)
# apply {}
url_ns = {key: getattr(parsed_url, key) for key in parsed_url._fields}
for key, value in referer.items():
referer[key] = value.format(**url_ns)
referer = urlunparse(parsed_url._replace(**referer))
# disable default auth header, cors is for cookie auth
headers = {"Authorization": ""}
if host is not None:
headers['X-Forwarded-Host'] = host
if referer is not None:
headers['Referer'] = referer
headers.update(extraheaders)
# add admin user
user = find_user(app.db, 'admin')
if user is None:
user = add_user(app.db, name='admin', admin=True)
cookies = await app.login_user('admin')
# test custom forwarded_host_header behavior
app.forwarded_host_header = 'X-Forwarded-Host'
# reset the config after the test to avoid leaking state
def reset_header():
app.forwarded_host_header = ""
request.addfinalizer(reset_header)
r = await api_request(
app,
'users',
method='post',
data='{}',
headers={
"Authorization": "",
"Content-Type": "application/json; charset=UTF-8",
},
headers=headers,
cookies=cookies,
)
assert r.status_code == 400 # accepted, but invalid
assert r.status_code == status
# --------------
@@ -160,6 +224,8 @@ def normalize_user(user):
"""
for key in ('created', 'last_activity'):
user[key] = normalize_timestamp(user[key])
if 'roles' in user:
user['roles'] = sorted(user['roles'])
if 'servers' in user:
for server in user['servers'].values():
for key in ('started', 'last_activity'):
@@ -212,7 +278,12 @@ async def test_get_users(app):
}
assert users == [
fill_user(
{'name': 'admin', 'admin': True, 'roles': ['admin'], 'auth_state': None}
{
'name': 'admin',
'admin': True,
'roles': ['admin', 'user'],
'auth_state': None,
}
),
fill_user(user_model),
]
@@ -597,7 +668,7 @@ async def test_add_multi_user_admin(app):
assert user is not None
assert user.name == name
assert user.admin
assert orm.Role.find(db, 'user') not in user.roles
assert orm.Role.find(db, 'user') in user.roles
assert orm.Role.find(db, 'admin') in user.roles
@@ -637,7 +708,7 @@ async def test_add_admin(app):
assert user.name == name
assert user.admin
# assert newadmin has default 'admin' role
assert orm.Role.find(db, 'user') not in user.roles
assert orm.Role.find(db, 'user') in user.roles
assert orm.Role.find(db, 'admin') in user.roles
@@ -672,7 +743,7 @@ async def test_make_admin(app):
assert user is not None
assert user.name == name
assert user.admin
assert orm.Role.find(db, 'user') not in user.roles
assert orm.Role.find(db, 'user') in user.roles
assert orm.Role.find(db, 'admin') in user.roles
@@ -972,6 +1043,11 @@ async def test_bad_spawn(app, bad_spawn):
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):
db = app.db
name = 'zaphod'

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
import json
from unittest import mock
import pytest
from .utils import add_user
from .utils import api_request
from .utils import get_page
from jupyterhub import metrics
from jupyterhub import orm
from jupyterhub import roles
async def test_total_users(app):
@@ -32,3 +36,42 @@ async def test_total_users(app):
sample = metrics.TOTAL_USERS.collect()[0].samples[0]
assert sample.value == num_users
@pytest.mark.parametrize(
"authenticate_prometheus, authenticated, authorized, success",
[
(True, True, True, True),
(True, True, False, False),
(True, False, False, False),
(False, True, True, True),
(False, False, False, True),
],
)
async def test_metrics_auth(
app,
authenticate_prometheus,
authenticated,
authorized,
success,
create_temp_role,
user,
):
if authorized:
role = create_temp_role(["read:metrics"])
roles.grant_role(app.db, user, role)
headers = {}
if authenticated:
token = user.new_api_token()
headers["Authorization"] = f"token {token}"
with mock.patch.dict(
app.tornado_settings, {"authenticate_prometheus": authenticate_prometheus}
):
r = await get_page("metrics", app, headers=headers)
if success:
assert r.status_code == 200
else:
assert r.status_code == 403
assert 'read:metrics' in r.text

View File

@@ -12,6 +12,7 @@ from tornado.escape import url_escape
from tornado.httputil import url_concat
from .. import orm
from .. import roles
from .. import scopes
from ..auth import Authenticator
from ..handlers import BaseHandler
@@ -20,7 +21,6 @@ from ..utils import url_path_join as ujoin
from .mocking import FalsyCallableFormSpawner
from .mocking import FormSpawner
from .test_api import next_event
from .utils import add_user
from .utils import api_request
from .utils import async_requests
from .utils import AsyncSession
@@ -48,16 +48,16 @@ async def test_root_auth(app):
# if spawning was quick, there will be one more entry that's public_url(user)
async def test_root_redirect(app):
async def test_root_redirect(app, user):
name = 'wash'
cookies = await app.login_user(name)
next_url = ujoin(app.base_url, 'user/other/test.ipynb')
next_url = ujoin(app.base_url, f'user/{user.name}/test.ipynb')
url = '/?' + urlencode({'next': next_url})
r = await get_page(url, app, cookies=cookies)
path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
# serve "server not running" page, which has status 424
assert r.status_code == 424
assert path == ujoin(app.base_url, f'hub/user/{user.name}/test.ipynb')
# preserves choice to requested user, which 404s as unavailable without access
assert r.status_code == 404
async def test_root_default_url_noauth(app):
@@ -203,13 +203,34 @@ async def test_spawn_handler_access(app):
r.raise_for_status()
async def test_spawn_admin_access(app, admin_access):
"""GET /user/:name as admin with admin-access spawns user's server"""
cookies = await app.login_user('admin')
name = 'mariel'
user = add_user(app.db, app=app, name=name)
app.db.commit()
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
async def test_spawn_other_user(
app, user, username, group, create_temp_role, has_access
):
"""GET /user/:name as another user with access to spawns user's server"""
cookies = await app.login_user(username)
requester = app.users[username]
name = user.name
if has_access:
if has_access == "group":
group.users.append(user)
app.db.commit()
scopes = [
f"access:servers!group={group.name}",
f"servers!group={group.name}",
]
elif has_access == "all":
scopes = ["access:servers", "servers"]
elif has_access == "user":
scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"]
role = create_temp_role(scopes)
roles.grant_role(app.db, requester, role)
r = await get_page('spawn/' + name, app, cookies=cookies)
if not has_access:
assert r.status_code == 404
return
r.raise_for_status()
while '/spawn-pending/' in r.url:
@@ -248,14 +269,36 @@ async def test_spawn_page_falsy_callable(app):
assert history[1] == ujoin(public_url(app), "hub/spawn-pending/erik")
async def test_spawn_page_admin(app, admin_access):
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
async def test_spawn_page_access(
app, has_access, group, username, user, create_temp_role
):
cookies = await app.login_user(username)
requester = app.users[username]
if has_access:
if has_access == "group":
group.users.append(user)
app.db.commit()
scopes = [
f"access:servers!group={group.name}",
f"servers!group={group.name}",
]
elif has_access == "all":
scopes = ["access:servers", "servers"]
elif has_access == "user":
scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"]
role = create_temp_role(scopes)
roles.grant_role(app.db, requester, role)
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
cookies = await app.login_user('admin')
u = add_user(app.db, app=app, name='melanie')
r = await get_page('spawn/' + u.name, app, cookies=cookies)
assert r.url.endswith('/spawn/' + u.name)
r = await get_page('spawn/' + user.name, app, cookies=cookies)
if not has_access:
assert r.status_code == 404
return
assert r.status_code == 200
assert r.url.endswith('/spawn/' + user.name)
assert FormSpawner.options_form in r.text
assert f"Spawning server for {u.name}" in r.text
assert f"Spawning server for {user.name}" in r.text
async def test_spawn_with_query_arguments(app):
@@ -322,18 +365,39 @@ async def test_spawn_form(app):
}
async def test_spawn_form_admin_access(app, admin_access):
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
async def test_spawn_form_other_user(
app, username, user, group, create_temp_role, has_access
):
cookies = await app.login_user(username)
requester = app.users[username]
if has_access:
if has_access == "group":
group.users.append(user)
app.db.commit()
scopes = [
f"access:servers!group={group.name}",
f"servers!group={group.name}",
]
elif has_access == "all":
scopes = ["access:servers", "servers"]
elif has_access == "user":
scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"]
role = create_temp_role(scopes)
roles.grant_role(app.db, requester, role)
with mock.patch.dict(app.tornado_settings, {'spawner_class': FormSpawner}):
base_url = ujoin(public_host(app), app.hub.base_url)
cookies = await app.login_user('admin')
u = add_user(app.db, app=app, name='martha')
next_url = ujoin(app.base_url, 'user', u.name, 'tree')
next_url = ujoin(app.base_url, 'user', user.name, 'tree')
r = await async_requests.post(
url_concat(ujoin(base_url, 'spawn', u.name), {'next': next_url}),
url_concat(ujoin(base_url, 'spawn', user.name), {'next': next_url}),
cookies=cookies,
data={'bounds': ['-3', '3'], 'energy': '938MeV'},
)
if not has_access:
assert r.status_code == 404
return
r.raise_for_status()
while '/spawn-pending/' in r.url:
@@ -342,8 +406,8 @@ async def test_spawn_form_admin_access(app, admin_access):
r.raise_for_status()
assert r.history
assert r.url.startswith(public_url(app, u))
assert u.spawner.user_options == {
assert r.url.startswith(public_url(app, user))
assert user.spawner.user_options == {
'energy': '938MeV',
'bounds': [-3, 3],
'notspecified': 5,
@@ -498,31 +562,54 @@ async def test_user_redirect_hook(app, username):
assert redirected_url.path == ujoin(app.base_url, 'user', username, 'terminals/1')
async def test_user_redirect_deprecated(app, username):
"""redirecting from /user/someonelse/ URLs (deprecated)"""
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
async def test_other_user_url(app, username, user, group, create_temp_role, has_access):
"""Test accessing /user/someonelse/ URLs when the server is not running
Used to redirect to your own server,
which produced inconsistent behavior depending on whether the server was running.
"""
name = username
cookies = await app.login_user(name)
other_user = user
requester = app.users[name]
other_user_url = f"/user/{other_user.name}"
if has_access:
if has_access == "group":
group.users.append(other_user)
app.db.commit()
scopes = [f"access:servers!group={group.name}"]
elif has_access == "all":
scopes = ["access:servers"]
elif has_access == "user":
scopes = [f"access:servers!user={other_user.name}"]
role = create_temp_role(scopes)
roles.grant_role(app.db, requester, role)
status = 424
else:
# 404 - access denied without revealing if the user exists
status = 404
r = await get_page('/user/baduser', app, cookies=cookies, hub=False)
r = await get_page(other_user_url, app, cookies=cookies, hub=False)
print(urlparse(r.url))
path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
assert r.status_code == 424
assert path == ujoin(app.base_url, f'hub/user/{other_user.name}/')
assert r.status_code == status
r = await get_page('/user/baduser/test.ipynb', app, cookies=cookies, hub=False)
r = await get_page(f'{other_user_url}/test.ipynb', app, cookies=cookies, hub=False)
print(urlparse(r.url))
path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
assert r.status_code == 424
assert path == ujoin(app.base_url, f'hub/user/{other_user.name}/test.ipynb')
assert r.status_code == status
r = await get_page('/user/baduser/test.ipynb', app, hub=False)
r = await get_page(f'{other_user_url}/test.ipynb', app, hub=False)
r.raise_for_status()
print(urlparse(r.url))
path = urlparse(r.url).path
assert path == ujoin(app.base_url, '/hub/login')
query = urlparse(r.url).query
assert query == urlencode(
{'next': ujoin(app.base_url, '/hub/user/baduser/test.ipynb')}
{'next': ujoin(app.base_url, f'/hub/user/{other_user.name}/test.ipynb')}
)
@@ -578,6 +665,41 @@ async def test_login_page(app, url, params, redirected_url, form_action):
assert action.endswith(form_action)
@pytest.mark.parametrize(
"url, token_in",
[
("/home", "url"),
("/home", "header"),
("/login", "url"),
("/login", "header"),
],
)
async def test_page_with_token(app, user, url, token_in):
cookies = await app.login_user(user.name)
token = user.new_api_token()
if token_in == "url":
url = url_concat(url, {"token": token})
headers = None
elif token_in == "header":
headers = {
"Authorization": f"token {token}",
}
# request a page with ?token= in URL shouldn't be allowed
r = await get_page(
url,
app,
headers=headers,
allow_redirects=False,
)
if "/hub/login" in r.url:
assert r.status_code == 200
else:
assert r.status_code == 302
assert r.headers["location"].partition("?")[0].endswith("/hub/login")
assert not r.cookies
async def test_login_fail(app):
name = 'wash'
base_url = public_url(app)
@@ -1075,26 +1197,13 @@ async def test_server_not_running_api_request_legacy_status(app):
assert r.status_code == 503
async def test_metrics_no_auth(app):
r = await get_page("metrics", app)
assert r.status_code == 403
async def test_metrics_auth(app):
cookies = await app.login_user('river')
metrics_url = ujoin(public_host(app), app.hub.base_url, 'metrics')
r = await get_page("metrics", app, cookies=cookies)
assert r.status_code == 200
assert r.url == metrics_url
async def test_health_check_request(app):
r = await get_page('health', app)
assert r.status_code == 200
async def test_pre_spawn_start_exc_no_form(app):
exc = "pre_spawn_start error"
exc = "Unhandled error starting server"
# throw exception from pre_spawn_start
async def mock_pre_spawn_start(user, spawner):

View File

@@ -28,7 +28,7 @@ def test_orm_roles(db):
user_role = orm.Role(name='user', scopes=['self'])
db.add(user_role)
if not token_role:
token_role = orm.Role(name='token', scopes=['all'])
token_role = orm.Role(name='token', scopes=['inherit'])
db.add(token_role)
if not service_role:
service_role = orm.Role(name='service', scopes=[])
@@ -369,7 +369,7 @@ async def test_creating_roles(app, role, role_def, response_type, response):
'info',
app_log.info('Role user scopes attribute has been changed'),
),
('non-existing', 'test-role2', 'error', NameError),
('non-existing', 'test-role2', 'error', KeyError),
('default', 'user', 'error', ValueError),
],
)
@@ -410,9 +410,9 @@ async def test_delete_roles(db, role_type, rolename, response_type, response):
},
'existing',
),
({'name': 'test-scopes-2', 'scopes': ['uses']}, NameError),
({'name': 'test-scopes-3', 'scopes': ['users:activities']}, NameError),
({'name': 'test-scopes-4', 'scopes': ['groups!goup=class-A']}, NameError),
({'name': 'test-scopes-2', 'scopes': ['uses']}, KeyError),
({'name': 'test-scopes-3', 'scopes': ['users:activities']}, KeyError),
({'name': 'test-scopes-4', 'scopes': ['groups!goup=class-A']}, KeyError),
],
)
async def test_scope_existence(tmpdir, request, role, response):
@@ -431,7 +431,7 @@ async def test_scope_existence(tmpdir, request, role, response):
assert added_role is not None
assert added_role.scopes == role['scopes']
elif response == NameError:
elif response == KeyError:
with pytest.raises(response):
roles.create_role(db, role)
added_role = orm.Role.find(db, role['name'])
@@ -443,7 +443,14 @@ async def test_scope_existence(tmpdir, request, role, response):
@mark.role
async def test_load_roles_users(tmpdir, request):
@mark.parametrize(
"explicit_allowed_users",
[
(True,),
(False,),
],
)
async def test_load_roles_users(tmpdir, request, explicit_allowed_users):
"""Test loading predefined roles for users in app.py"""
roles_to_load = [
{
@@ -461,7 +468,8 @@ async def test_load_roles_users(tmpdir, request):
hub.init_db()
db = hub.db
hub.authenticator.admin_users = ['admin']
hub.authenticator.allowed_users = ['cyclops', 'gandalf', 'bilbo', 'gargamel']
if explicit_allowed_users:
hub.authenticator.allowed_users = ['cyclops', 'gandalf', 'bilbo', 'gargamel']
await hub.init_role_creation()
await hub.init_users()
await hub.init_role_assignment()
@@ -578,7 +586,7 @@ async def test_load_roles_groups(tmpdir, request):
'name': 'head',
'description': 'Whole user access',
'scopes': ['users', 'admin:users'],
'groups': ['group3'],
'groups': ['group3', "group4"],
},
]
kwargs = {'load_groups': groups_to_load, 'load_roles': roles_to_load}
@@ -598,11 +606,13 @@ async def test_load_roles_groups(tmpdir, request):
group1 = orm.Group.find(db, name='group1')
group2 = orm.Group.find(db, name='group2')
group3 = orm.Group.find(db, name='group3')
group4 = orm.Group.find(db, name='group4')
# test group roles
assert group1.roles == []
assert group2 in assist_role.groups
assert group3 in head_role.groups
assert group4 in head_role.groups
# delete the test roles
for role in roles_to_load:
@@ -663,7 +673,11 @@ async def test_load_roles_user_tokens(tmpdir, request):
# no role requested - gets default 'token' role
({}, None, None, 201),
# role scopes within the user's default 'user' role
({}, 'self-reader', ['read:users'], 201),
({}, 'self-reader', ['read:users!user'], 201),
# role scopes within the user's default 'user' role, but with disjoint filter
({}, 'other-reader', ['read:users!user=other'], 403),
# role scopes within the user's default 'user' role, without filter
({}, 'other-reader', ['read:users'], 403),
# role scopes outside of the user's role but within the group's role scopes of which the user is a member
({}, 'groups-reader', ['read:groups'], 201),
# non-existing role request
@@ -1181,14 +1195,47 @@ async def test_no_admin_role_change():
await hub.init_role_creation()
async def test_user_config_respects_memberships():
@pytest.mark.parametrize(
"in_db, role_users, allowed_users, expected_members",
[
# users in the db, not specified in custom user role
# no change to membership
(["alpha", "beta"], None, None, ["alpha", "beta"]),
# allowed_users is additive, not strict
(["alpha", "beta"], None, {"gamma"}, ["alpha", "beta", "gamma"]),
# explicit empty revokes all assignments
(["alpha", "beta"], [], None, []),
# explicit value is respected exactly
(["alpha", "beta"], ["alpha", "gamma"], None, ["alpha", "gamma"]),
],
)
async def test_user_role_from_config(
in_db, role_users, allowed_users, expected_members
):
role_spec = {
'name': 'user',
'scopes': ['self', 'shutdown'],
}
if role_users is not None:
role_spec['users'] = role_users
hub = MockHub(load_roles=[role_spec])
hub.init_db()
db = hub.db
hub.authenticator.admin_users = set()
if allowed_users:
hub.authenticator.allowed_users = allowed_users
await hub.init_role_creation()
async def test_user_config_creates_default_role():
role_spec = [
{
'name': 'user',
'scopes': ['self', 'shutdown'],
'name': 'new-role',
'scopes': ['read:users'],
'users': ['not-yet-created-user'],
}
]
user_names = ['eddy', 'carol']
user_names = []
hub = MockHub(load_roles=role_spec)
hub.init_db()
hub.authenticator.allowed_users = user_names
@@ -1196,9 +1243,9 @@ async def test_user_config_respects_memberships():
await hub.init_users()
await hub.init_role_assignment()
user_role = orm.Role.find(hub.db, 'user')
for user_name in user_names:
user = orm.User.find(hub.db, user_name)
assert user in user_role.users
new_role = orm.Role.find(hub.db, 'new-role')
assert orm.User.find(hub.db, 'not-yet-created-user') in new_role.users
assert orm.User.find(hub.db, 'not-yet-created-user') in user_role.users
async def test_admin_role_respects_config():
@@ -1220,16 +1267,45 @@ async def test_admin_role_respects_config():
assert user in admin_role.users
async def test_empty_admin_spec():
role_spec = [{'name': 'admin', 'users': []}]
hub = MockHub(load_roles=role_spec)
@pytest.mark.parametrize(
"in_db, role_users, admin_users, expected_members",
[
# users in the db, not specified in custom user role
# no change to membership
(["alpha", "beta"], None, None, ["alpha", "beta"]),
# admin_users is additive, not strict
(["alpha", "beta"], None, {"gamma"}, ["alpha", "beta", "gamma"]),
# explicit empty revokes all assignments
(["alpha", "beta"], [], None, []),
# explicit value is respected exactly
(["alpha", "beta"], ["alpha", "gamma"], None, ["alpha", "gamma"]),
],
)
async def test_admin_role_membership(in_db, role_users, admin_users, expected_members):
load_roles = []
if role_users is not None:
load_roles.append({"name": "admin", "users": role_users})
if not admin_users:
admin_users = set()
hub = MockHub(load_roles=load_roles, db_url="sqlite:///:memory:")
hub.init_db()
hub.authenticator.admin_users = []
await hub.init_role_creation()
db = hub.db
hub.authenticator.admin_users = admin_users
# add in_db users to the database
# this is the 'before' state of who had the role before startup
for username in in_db or []:
user = orm.User(name=username)
db.add(user)
db.commit()
roles.grant_role(db, user, "admin")
db.commit()
await hub.init_users()
await hub.init_role_assignment()
admin_role = orm.Role.find(hub.db, 'admin')
assert not admin_role.users
admin_role = orm.Role.find(db, 'admin')
role_members = sorted(user.name for user in admin_role.users)
assert role_members == expected_members
async def test_no_default_service_role():
@@ -1330,3 +1406,20 @@ async def test_token_keep_roles_on_restart():
for token in user.api_tokens:
hub.db.delete(token)
hub.db.commit()
async def test_login_default_role(app, username):
cookies = await app.login_user(username)
user = app.users[username]
# assert login new user gets 'user' role
assert [role.name for role in user.roles] == ["user"]
# clear roles, keep user
user.roles = []
app.db.commit()
# login *again*; user exists,
# login should always trigger "user" role assignment
cookies = await app.login_user(username)
user = app.users[username]
assert [role.name for role in user.roles] == ["user"]

View File

@@ -477,7 +477,7 @@ async def test_metascope_all_expansion(app, create_user_with_scopes):
user = create_user_with_scopes('self')
user.new_api_token()
token = user.api_tokens[0]
# Check 'all' expansion
# Check 'inherit' expansion
token_scope_set = get_scopes_for(token)
user_scope_set = get_scopes_for(user)
assert user_scope_set == token_scope_set
@@ -677,9 +677,14 @@ async def test_resolve_token_permissions(
intersection_scopes,
):
orm_user = create_user_with_scopes(*user_scopes).orm_user
# ensure user has full permissions when token is created
# to create tokens with permissions exceeding their owner
roles.grant_role(app.db, orm_user, "admin")
create_temp_role(token_scopes, 'active-posting')
api_token = orm_user.new_api_token(roles=['active-posting'])
orm_api_token = orm.APIToken.find(app.db, token=api_token)
# drop admin so that filtering can be applied
roles.strip_role(app.db, orm_user, "admin")
# get expanded !user filter scopes for check
user_scopes = roles.expand_roles_to_scopes(orm_user)

View File

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

View File

@@ -21,6 +21,7 @@ from ..objects import Server
from ..spawner import LocalProcessSpawner
from ..spawner import Spawner
from ..user import User
from ..utils import AnyTimeoutError
from ..utils import new_token
from ..utils import url_path_join
from .mocking import public_url
@@ -80,6 +81,18 @@ async def test_spawner(db, request):
assert isinstance(status, int)
def test_spawner_from_db(app, user):
spawner = user.spawners['name']
user_options = {"test": "value"}
spawner.orm_spawner.user_options = user_options
app.db.commit()
# delete and recreate the spawner from the db
user.spawners.pop('name')
new_spawner = user.spawners['name']
assert new_spawner.orm_spawner.user_options == user_options
assert new_spawner.user_options == user_options
async def wait_for_spawner(spawner, timeout=10):
"""Wait for an http server to show up
@@ -95,7 +108,7 @@ async def wait_for_spawner(spawner, timeout=10):
assert status is None
try:
await wait()
except TimeoutError:
except AnyTimeoutError:
continue
else:
break
@@ -427,7 +440,22 @@ async def test_hub_connect_url(db):
)
async def test_spawner_oauth_roles(app):
allowed_roles = ['lotsa', 'roles']
spawner = new_spawner(app.db, oauth_roles=allowed_roles)
assert spawner.oauth_roles == allowed_roles
async def test_spawner_oauth_roles(app, user):
allowed_roles = ["admin", "user"]
spawner = user.spawners['']
spawner.oauth_roles = allowed_roles
# exercise start/stop which assign roles to oauth client
await spawner.user.spawn()
oauth_client = spawner.orm_spawner.oauth_client
assert sorted(role.name for role in oauth_client.allowed_roles) == allowed_roles
await spawner.user.stop()
async def test_spawner_oauth_roles_bad(app, user):
allowed_roles = ["user", "nosuchrole"]
spawner = user.spawners['']
spawner.oauth_roles = allowed_roles
# exercise start/stop which assign roles
# raises ValueError if we try to assign a role that doesn't exist
with pytest.raises(ValueError):
await spawner.user.spawn()

View File

@@ -2,12 +2,16 @@
import asyncio
import time
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import Mock
import pytest
from async_generator import aclosing
from tornado import gen
from tornado.concurrent import run_on_executor
from tornado.httpserver import HTTPRequest
from tornado.httputil import HTTPHeaders
from .. import utils
from ..utils import iterate_until
@@ -88,3 +92,33 @@ async def test_tornado_coroutines():
# verify that tornado gen and executor methods return awaitables
assert (await t.on_executor()) == "executor"
assert (await t.tornado_coroutine()) == "gen.coroutine"
@pytest.mark.parametrize(
"forwarded, x_scheme, x_forwarded_proto, expected",
[
("", "", "", "_attr_"),
("for=1.2.3.4", "", "", "_attr_"),
("for=1.2.3.4,proto=https", "", "", "_attr_"),
("", "https", "http", "https"),
("", "https, http", "", "https"),
("", "https, http", "http", "https"),
("proto=http ; for=1.2.3.4, proto=https", "https, http", "", "http"),
("proto=invalid;for=1.2.3.4,proto=http", "https, http", "", "https"),
("for=1.2.3.4,proto=http", "https, http", "", "https"),
("", "invalid, http", "", "_attr_"),
],
)
def test_browser_protocol(x_scheme, x_forwarded_proto, forwarded, expected):
request = Mock(spec=HTTPRequest)
request.protocol = "_attr_"
request.headers = HTTPHeaders()
if x_scheme:
request.headers["X-Scheme"] = x_scheme
if x_forwarded_proto:
request.headers["X-Forwarded-Proto"] = x_forwarded_proto
if forwarded:
request.headers["Forwarded"] = forwarded
proto = utils.get_browser_protocol(request)
assert proto == expected

View File

@@ -26,11 +26,41 @@ from .metrics import RUNNING_SERVERS
from .metrics import TOTAL_USERS
from .objects import Server
from .spawner import LocalProcessSpawner
from .utils import AnyTimeoutError
from .utils import make_ssl_context
from .utils import maybe_future
from .utils import url_path_join
# detailed messages about the most common failure-to-start errors,
# which manifest timeouts during start
start_timeout_message = """
Common causes of this timeout, and debugging tips:
1. Everything is working, but it took too long.
To fix: increase `Spawner.start_timeout` configuration
to a number of seconds that is enough for spawners to finish starting.
2. The server didn't finish starting,
or it crashed due to a configuration issue.
Check the single-user server's logs for hints at what needs fixing.
"""
http_timeout_message = """
Common causes of this timeout, and debugging tips:
1. The server didn't finish starting,
or it crashed due to a configuration issue.
Check the single-user server's logs for hints at what needs fixing.
2. The server started, but is not accessible at the specified URL.
This may be a configuration issue specific to your chosen Spawner.
Check the single-user server logs and resource to make sure the URL
is correct and accessible from the Hub.
3. (unlikely) Everything is working, but the server took too long to respond.
To fix: increase `Spawner.http_timeout` configuration
to a number of seconds that is enough for servers to become responsive.
"""
class UserDict(dict):
"""Like defaultdict, but for users
@@ -346,6 +376,7 @@ class User:
oauth_client_id=client_id,
cookie_options=self.settings.get('cookie_options', {}),
trusted_alt_names=trusted_alt_names,
user_options=orm_spawner.user_options or {},
)
if self.settings.get('internal_ssl'):
@@ -592,6 +623,19 @@ class User:
if callable(allowed_roles):
allowed_roles = allowed_roles(spawner)
# allowed_roles config is a list of strings
# oauth provider.allowed_roles is a list of orm.Roles
if allowed_roles:
allowed_role_names = allowed_roles
allowed_roles = list(
self.db.query(orm.Role).filter(orm.Role.name.in_(allowed_roles))
)
if len(allowed_roles) != len(allowed_role_names):
missing_roles = set(allowed_role_names).difference(
{role.name for role in allowed_roles}
)
raise ValueError(f"No such role(s): {', '.join(missing_roles)}")
oauth_client = oauth_provider.add_client(
client_id,
api_token,
@@ -707,11 +751,11 @@ class User:
db.commit()
except Exception as e:
if isinstance(e, gen.TimeoutError):
if isinstance(e, AnyTimeoutError):
self.log.warning(
"{user}'s server failed to start in {s} seconds, giving up".format(
user=self.name, s=spawner.start_timeout
)
f"{self.name}'s server failed to start"
f" in {spawner.start_timeout} seconds, giving up."
f"\n{start_timeout_message}"
)
e.reason = 'timeout'
self.settings['statsd'].incr('spawner.failure.timeout')
@@ -764,14 +808,11 @@ class User:
http=True, timeout=spawner.http_timeout, ssl_context=ssl_context
)
except Exception as e:
if isinstance(e, TimeoutError):
if isinstance(e, AnyTimeoutError):
self.log.warning(
"{user}'s server never showed up at {url} "
"after {http_timeout} seconds. Giving up".format(
user=self.name,
url=server.url,
http_timeout=spawner.http_timeout,
)
f"{self.name}'s server never showed up at {server.url}"
f" after {spawner.http_timeout} seconds. Giving up."
f"\n{http_timeout_message}"
)
e.reason = 'timeout'
self.settings['statsd'].incr('spawner.failure.http_timeout')

View File

@@ -23,12 +23,12 @@ from operator import itemgetter
from async_generator import aclosing
from sqlalchemy.exc import SQLAlchemyError
from tornado import gen
from tornado import ioloop
from tornado import web
from tornado.httpclient import AsyncHTTPClient
from tornado.httpclient import HTTPError
from tornado.log import app_log
from tornado.platform.asyncio import to_asyncio_future
# For compatibility with python versions 3.6 or earlier.
# asyncio.Task.all_tasks() is fully moved to asyncio.all_tasks() starting with 3.9. Also applies to current_task.
@@ -97,6 +97,10 @@ def make_ssl_context(keyfile, certfile, cafile=None, verify=True, check_hostname
return ssl_context
# AnyTimeoutError catches TimeoutErrors coming from asyncio, tornado, stdlib
AnyTimeoutError = (gen.TimeoutError, asyncio.TimeoutError, TimeoutError)
async def exponential_backoff(
pass_func,
fail_message,
@@ -182,7 +186,7 @@ async def exponential_backoff(
if dt < max_wait:
scale *= scale_factor
await asyncio.sleep(dt)
raise TimeoutError(fail_message)
raise asyncio.TimeoutError(fail_message)
async def wait_for_server(ip, port, timeout=10):
@@ -288,12 +292,39 @@ def authenticated_403(self):
raise web.HTTPError(403)
def admin_only(f):
"""Deprecated!"""
# write it this way to trigger deprecation warning at decoration time,
# not on the method call
warnings.warn(
"""@jupyterhub.utils.admin_only is deprecated in JupyterHub 2.0.
Use the new `@jupyterhub.scopes.needs_scope` decorator to resolve permissions,
or check against `self.current_user.parsed_scopes`.
""",
DeprecationWarning,
stacklevel=2,
)
# the original decorator
@auth_decorator
def admin_only(self):
"""Decorator for restricting access to admin users"""
user = self.current_user
if user is None or not user.admin:
raise web.HTTPError(403)
return admin_only(f)
@auth_decorator
def metrics_authentication(self):
"""Decorator for restricting access to metrics"""
user = self.current_user
if user is None and self.authenticate_prometheus:
raise web.HTTPError(403)
if not self.authenticate_prometheus:
return
scope = 'read:metrics'
if scope not in self.parsed_scopes:
raise web.HTTPError(403, f"Access to metrics requires scope '{scope}'")
# Token utilities
@@ -326,7 +357,7 @@ def hash_token(token, salt=8, rounds=16384, algorithm='sha512'):
h.update(btoken)
digest = h.hexdigest()
return "{algorithm}:{rounds}:{salt}:{digest}".format(**locals())
return f"{algorithm}:{rounds}:{salt}:{digest}"
def compare_token(compare, token):
@@ -654,3 +685,44 @@ def catch_db_error(f):
return r
return catching
def get_browser_protocol(request):
"""Get the _protocol_ seen by the browser
Like tornado's _apply_xheaders,
but in the case of multiple proxy hops,
use the outermost value (what the browser likely sees)
instead of the innermost value,
which is the most trustworthy.
We care about what the browser sees,
not where the request actually came from,
so trusting possible spoofs is the right thing to do.
"""
headers = request.headers
# first choice: Forwarded header
forwarded_header = headers.get("Forwarded")
if forwarded_header:
first_forwarded = forwarded_header.split(",", 1)[0].strip()
fields = {}
forwarded_dict = {}
for field in first_forwarded.split(";"):
key, _, value = field.partition("=")
fields[key.strip().lower()] = value.strip()
if "proto" in fields and fields["proto"].lower() in {"http", "https"}:
return fields["proto"].lower()
else:
app_log.warning(
f"Forwarded header present without protocol: {forwarded_header}"
)
# second choice: X-Scheme or X-Forwarded-Proto
proto_header = headers.get("X-Scheme", headers.get("X-Forwarded-Proto", None))
if proto_header:
proto_header = proto_header.split(",")[0].strip().lower()
if proto_header in {"http", "https"}:
return proto_header
# no forwarded headers
return request.protocol

View File

@@ -5,3 +5,41 @@ target_version = [
"py37",
"py38",
]
[tool.tbump]
# Uncomment this if your project is hosted on GitHub:
github_url = "https://github.com/jupyterhub/jupyterhub"
[tool.tbump.version]
current = "2.1.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"""
data_files = []
ntrim = len(here + os.path.sep)
for (d, dirs, filenames) in os.walk(share_jupyterhub):
data_files.append((d[ntrim:], [pjoin(d, f) for f in filenames]))
rel_d = os.path.relpath(d, here)
data_files.append((rel_d, [os.path.join(rel_d, f) for f in filenames]))
return data_files

File diff suppressed because one or more lines are too long

View File

@@ -1,24 +1,10 @@
{% extends "page.html" %}
{% macro th(label, key='', colspan=1) %}
<th data-sort="{{key}}" colspan="{{colspan}}">{{label}}
{% if key %}
<a href="#"><i class="fa {% if sort.get(key) == 'asc' -%}
fa-sort-asc
{%- elif sort.get(key) == 'desc' -%}
fa-sort-desc
{%- else -%}
fa-sort
{%- endif %} sort-icon">
</i></a>
{% endif %}
</th>
{% endmacro %}
{% block main %}
<div id="react-admin-hook">
<script id="jupyterhub-admin-config">
window.api_page_limit = parseInt("{{ api_page_limit|safe }}")
window.base_url = "{{ base_url|safe }}"
</script>
<script src="static/js/admin-react.js"></script>
</div>

View File

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

View File

@@ -18,8 +18,10 @@
<p>
{% if failed %}
The latest attempt to start your server {{ server_name }} has failed.
{% if failed_message %}
{{ failed_message }}
{% if failed_html_message %}
</p><p>{{ failed_html_message | safe }}</p><p>
{% elif failed_message %}
</p><p>{{ failed_message }}</p><p>
{% endif %}
Would you like to retry starting it?
{% else %}

View File

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

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