Compare commits

..

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

I *think* 404 is a more appropriate response, as the resource
(API) being requested is no longer present.
2021-10-05 03:00:16 +05:30
Erik Sundell
68db740998 Merge pull request #3635 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-10-04 22:38:05 +02:00
pre-commit-ci[bot]
9c0c6f25b7 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.28.0 → v2.29.0](https://github.com/asottile/pyupgrade/compare/v2.28.0...v2.29.0)
2021-10-04 19:48:13 +00:00
Min RK
5f0077cb5b Merge pull request #3445 from rpwagner/patch-1
Initial SECURITY.md
2021-09-29 09:42:59 +02:00
Rick Wagner
3610454a12 adding initial security policy 2021-06-01 09:51:20 -07:00
Rick Wagner
abc4bbebe4 Initial SECURITY.md
Proposing a basic security policy, similar to the README or contributors guide, based on the [GitHub documentation](https://docs.github.com/en/code-security/security-advisories/adding-a-security-policy-to-your-repository) and current Project Jupyter recommendations. This may be better as a [default file for the organization](https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions/creating-a-default-community-health-file).
2021-04-23 23:12:51 -07:00
94 changed files with 5368 additions and 2507 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 name: Release
# always build releases (to make sure wheel-building works)
# but only publish to PyPI on tags
on: on:
push:
branches:
- "!dependabot/**"
tags:
- "*"
pull_request: 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: jobs:
build-release: build-release:
@@ -96,7 +113,6 @@ jobs:
# Setup docker to build for multiple platforms, see: # 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/tree/v2.4.0#usage
# https://github.com/docker/build-push-action/blob/v2.4.0/docs/advanced/multi-platform.md # https://github.com/docker/build-push-action/blob/v2.4.0/docs/advanced/multi-platform.md
- name: Set up QEMU (for docker buildx) - name: Set up QEMU (for docker buildx)
uses: docker/setup-qemu-action@25f0500ff22e406f7191a2a8ba8cda16901ca018 # associated tag: v1.0.2 uses: docker/setup-qemu-action@25f0500ff22e406f7191a2a8ba8cda16901ca018 # associated tag: v1.0.2
@@ -120,6 +136,8 @@ jobs:
run: | run: |
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}" docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}"
# image: jupyterhub/jupyterhub
#
# https://github.com/jupyterhub/action-major-minor-tag-calculator # https://github.com/jupyterhub/action-major-minor-tag-calculator
# If this is a tagged build this will return additional parent tags. # If this is a tagged build this will return additional parent tags.
# E.g. 1.2.3 is expanded to Docker 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 []. # If GITHUB_TOKEN isn't available (e.g. in PRs) returns no tags [].
- name: Get list of jupyterhub tags - name: Get list of jupyterhub tags
id: jupyterhubtags id: jupyterhubtags
uses: jupyterhub/action-major-minor-tag-calculator@v1 uses: jupyterhub/action-major-minor-tag-calculator@v2
with: with:
githubToken: ${{ secrets.GITHUB_TOKEN }} githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub:" prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub:"
@@ -137,7 +155,7 @@ jobs:
branchRegex: ^\w[\w-.]*$ branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub - name: Build and push jupyterhub
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0 uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
@@ -146,11 +164,11 @@ jobs:
# array into a comma separated list of tags # array into a comma separated list of tags
tags: ${{ join(fromJson(steps.jupyterhubtags.outputs.tags)) }} tags: ${{ join(fromJson(steps.jupyterhubtags.outputs.tags)) }}
# jupyterhub-onbuild # image: jupyterhub/jupyterhub-onbuild
#
- name: Get list of jupyterhub-onbuild tags - name: Get list of jupyterhub-onbuild tags
id: onbuildtags id: onbuildtags
uses: jupyterhub/action-major-minor-tag-calculator@v1 uses: jupyterhub/action-major-minor-tag-calculator@v2
with: with:
githubToken: ${{ secrets.GITHUB_TOKEN }} githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:" prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:"
@@ -158,7 +176,7 @@ jobs:
branchRegex: ^\w[\w-.]*$ branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub-onbuild - name: Build and push jupyterhub-onbuild
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0 uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
with: with:
build-args: | build-args: |
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }} BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
@@ -167,11 +185,11 @@ jobs:
push: true push: true
tags: ${{ join(fromJson(steps.onbuildtags.outputs.tags)) }} tags: ${{ join(fromJson(steps.onbuildtags.outputs.tags)) }}
# jupyterhub-demo # image: jupyterhub/jupyterhub-demo
#
- name: Get list of jupyterhub-demo tags - name: Get list of jupyterhub-demo tags
id: demotags id: demotags
uses: jupyterhub/action-major-minor-tag-calculator@v1 uses: jupyterhub/action-major-minor-tag-calculator@v2
with: with:
githubToken: ${{ secrets.GITHUB_TOKEN }} githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:" prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:"
@@ -179,7 +197,7 @@ jobs:
branchRegex: ^\w[\w-.]*$ branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub-demo - name: Build and push jupyterhub-demo
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0 uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
with: with:
build-args: | build-args: |
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }} BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
@@ -190,3 +208,24 @@ jobs:
platforms: linux/amd64 platforms: linux/amd64
push: true push: true
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }} tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}
# 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. # 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 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: on:
pull_request: pull_request:
paths-ignore:
- "docs/**"
- "**.md"
- "**.rst"
- ".github/workflows/*"
- "!.github/workflows/test.yml"
push: push:
paths-ignore:
- "docs/**"
- "**.md"
- "**.rst"
- ".github/workflows/*"
- "!.github/workflows/test.yml"
branches-ignore:
- "dependabot/**"
- "pre-commit-ci-update-config"
tags:
- "**"
workflow_dispatch: workflow_dispatch:
env: env:
# UTF-8 content may be interpreted as ascii and causes errors without this. # UTF-8 content may be interpreted as ascii and causes errors without this.
LANG: C.UTF-8 LANG: C.UTF-8
PYTEST_ADDOPTS: "--verbose --color=yes"
jobs: jobs:
jstest:
# Run javascript tests
runs-on: ubuntu-20.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v2
# NOTE: actions/setup-node@v1 make use of a cache within the GitHub base
# environment and setup in a fraction of a second.
- name: Install Node
uses: actions/setup-node@v1
with:
node-version: "14"
- name: Install Node dependencies
run: |
npm install -g yarn
- name: Run yarn
run: |
cd jsx
yarn
- name: yarn test
run: |
cd jsx
yarn test
# Run "pytest jupyterhub/tests" in various configurations # Run "pytest jupyterhub/tests" in various configurations
pytest: pytest:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
timeout-minutes: 10 timeout-minutes: 15
strategy: strategy:
# Keep running even if one variation of the job fail # Keep running even if one variation of the job fail
@@ -106,7 +148,6 @@ jobs:
run: | run: |
npm install npm install
npm install -g configurable-http-proxy npm install -g configurable-http-proxy
npm install -g yarn
npm list npm list
# NOTE: actions/setup-python@v2 make use of a cache within the GitHub base # NOTE: actions/setup-python@v2 make use of a cache within the GitHub base
@@ -169,26 +210,25 @@ jobs:
if: ${{ matrix.db }} if: ${{ matrix.db }}
run: | run: |
if [ "${{ matrix.db }}" == "mysql" ]; then if [ "${{ matrix.db }}" == "mysql" ]; then
if [[ -z "$(which mysql)" ]]; then
sudo apt-get update sudo apt-get update
sudo apt-get install -y mysql-client sudo apt-get install -y mysql-client
fi
DB=mysql bash ci/docker-db.sh DB=mysql bash ci/docker-db.sh
DB=mysql bash ci/init-db.sh DB=mysql bash ci/init-db.sh
fi fi
if [ "${{ matrix.db }}" == "postgres" ]; then if [ "${{ matrix.db }}" == "postgres" ]; then
if [[ -z "$(which psql)" ]]; then
sudo apt-get update sudo apt-get update
sudo apt-get install -y postgresql-client sudo apt-get install -y postgresql-client
fi
DB=postgres bash ci/docker-db.sh DB=postgres bash ci/docker-db.sh
DB=postgres bash ci/init-db.sh DB=postgres bash ci/init-db.sh
fi fi
- name: Run pytest - name: Run pytest
# FIXME: --color=yes explicitly set because:
# https://github.com/actions/runner/issues/241
run: | run: |
pytest -v --maxfail=2 --color=yes --cov=jupyterhub jupyterhub/tests pytest --maxfail=2 --cov=jupyterhub jupyterhub/tests
- name: Run yarn jest test
run: |
cd jsx && yarn && yarn test
- name: Submit codecov report - name: Submit codecov report
run: | run: |
codecov codecov

View File

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

View File

@@ -4,10 +4,12 @@ sphinx:
configuration: docs/source/conf.py configuration: docs/source/conf.py
build: build:
image: latest os: ubuntu-20.04
tools:
nodejs: "16"
python: "3.9"
python: python:
version: 3.7
install: install:
- method: pip - method: pip
path: . 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. servers.
JupyterHub also provides a JupyterHub also provides a
[REST API](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/HEAD/docs/rest-api.yml#/default) [REST API][]
for administration of the Hub and its users. for administration of the Hub and its users.
[rest api]: https://juptyerhub.readthedocs.io/en/latest/reference/rest-api.html
## Installation ## Installation
### Check prerequisites ### Check prerequisites
@@ -115,8 +117,7 @@ To start the Hub server, run the command:
jupyterhub jupyterhub
Visit `https://localhost:8000` in your browser, and sign in with your unix Visit `http://localhost:8000` in your browser, and sign in with your system username and password.
PAM credentials.
_Note_: To allow multiple users to sign in to the server, you will need to _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. 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) - [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
- [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial) - [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial)
- [Documentation for JupyterHub](https://jupyterhub.readthedocs.io/en/latest/) | [PDF (latest)](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf) | [PDF (stable)](https://media.readthedocs.org/pdf/jupyterhub/stable/jupyterhub.pdf) - [Documentation for JupyterHub](https://jupyterhub.readthedocs.io/en/latest/) | [PDF (latest)](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf) | [PDF (stable)](https://media.readthedocs.org/pdf/jupyterhub/stable/jupyterhub.pdf)
- [Documentation for JupyterHub's REST API](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/HEAD/docs/rest-api.yml#/default) - [Documentation for JupyterHub's REST API][rest api]
- [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf) - [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)
- [Project Jupyter website](https://jupyter.org) - [Project Jupyter website](https://jupyter.org)
- [Project Jupyter community](https://jupyter.org/community) - [Project Jupyter community](https://jupyter.org/community)

50
RELEASE.md Normal file
View File

@@ -0,0 +1,50 @@
# How to make a release
`jupyterhub` is a package [available on
PyPI](https://pypi.org/project/jupyterhub/) and
[conda-forge](https://conda-forge.org/).
These are instructions on how to make a release on PyPI.
The PyPI release is done automatically by CI when a tag is pushed.
For you to follow along according to these instructions, you need:
- To have push rights to the [jupyterhub GitHub
repository](https://github.com/jupyterhub/jupyterhub).
## Steps to make a release
1. Checkout main and make sure it is up to date.
```shell
ORIGIN=${ORIGIN:-origin} # set to the canonical remote, e.g. 'upstream' if 'origin' is not the official repo
git checkout main
git fetch $ORIGIN main
git reset --hard $ORIGIN/main
```
1. Make sure `docs/source/changelog.md` is up-to-date.
[github-activity][] can help with this.
1. Update the version with `tbump`.
You can see what will happen without making any changes with `tbump --dry-run ${VERSION}`
```shell
tbump ${VERSION}
```
This will tag and publish a release,
which will be finished on CI.
1. Reset the version back to dev, e.g. `2.1.0.dev` after releasing `2.0.0`
```shell
tbump --no-tag ${NEXT_VERSION}.dev
```
1. Following the release to PyPI, an automated PR should arrive to
[conda-forge/jupyterhub-feedstock][],
check for the tests to succeed on this PR and then merge it to successfully
update the package for `conda` on the conda-forge channel.
[github-activity]: https://github.com/choldgraf/github-activity
[conda-forge/jupyterhub-feedstock]: https://github.com/conda-forge/jupyterhub-feedstock

5
SECURITY.md Normal file
View File

@@ -0,0 +1,5 @@
# Reporting a Vulnerability
If you believe youve found a security vulnerability in a Jupyter
project, please report it to security@ipython.org. If you prefer to
encrypt your security reports, you can use [this PGP public key](https://jupyter-notebook.readthedocs.io/en/stable/_downloads/1d303a645f2505a8fd283826fafc9908/ipython_security.asc).

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
{
"name": "jupyterhub-docs-build",
"version": "0.8.0",
"description": "build JupyterHub swagger docs",
"scripts": {
"rest-api": "bootprint openapi ./rest-api.yml source/_static/rest-api"
},
"author": "",
"license": "BSD-3-Clause",
"devDependencies": {
"bootprint": "^1.0.0",
"bootprint-openapi": "^1.0.0"
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -130,6 +130,30 @@ html_static_path = ['_static']
htmlhelp_basename = 'JupyterHubdoc' 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 --------------------------------------------- # -- Options for LaTeX output ---------------------------------------------
latex_elements = { latex_elements = {
@@ -205,7 +229,10 @@ epub_exclude_files = ['search.html']
# -- Intersphinx ---------------------------------------------------------- # -- Intersphinx ----------------------------------------------------------
intersphinx_mapping = {'https://docs.python.org/3/': None} intersphinx_mapping = {
'python': ('https://docs.python.org/3/', None),
'tornado': ('https://www.tornadoweb.org/en/stable/', None),
}
# -- Read The Docs -------------------------------------------------------- # -- Read The Docs --------------------------------------------------------
@@ -215,7 +242,7 @@ if on_rtd:
# build both metrics and rest-api, since RTD doesn't run make # build both metrics and rest-api, since RTD doesn't run make
from subprocess import check_call as sh from subprocess import check_call as sh
sh(['make', 'metrics', 'rest-api', 'scopes'], cwd=docs) sh(['make', 'metrics', 'scopes'], cwd=docs)
# -- Spell checking ------------------------------------------------------- # -- Spell checking -------------------------------------------------------

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 Users in the `allowed_users` set are added to the Hub database when the Hub is
started. started.
```{warning}
If this configuration value is not set, then **all authenticated users will be allowed into your hub**.
```
## Configure admins (`admin_users`) ## Configure admins (`admin_users`)
```{note} ```{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 notebook servers
For convenient administration of the Hub, its users, and services, For convenient administration of the Hub, its users, and services,
JupyterHub also provides a `REST API`_. JupyterHub also provides a :doc:`REST API <reference/rest-api>`.
The JupyterHub team and Project Jupyter value our community, and JupyterHub The JupyterHub team and Project Jupyter value our community, and JupyterHub
follows the Jupyter `Community Guides <https://jupyter.readthedocs.io/en/latest/community/content-community.html>`_. follows the Jupyter `Community Guides <https://jupyter.readthedocs.io/en/latest/community/content-community.html>`_.
@@ -155,4 +155,3 @@ Questions? Suggestions?
.. _JupyterHub: https://github.com/jupyterhub/jupyterhub .. _JupyterHub: https://github.com/jupyterhub/jupyterhub
.. _Jupyter notebook: https://jupyter-notebook.readthedocs.io/en/latest/ .. _Jupyter notebook: https://jupyter-notebook.readthedocs.io/en/latest/
.. _REST API: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#!/default

View File

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

View File

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

View File

@@ -0,0 +1,128 @@
(api-only)=
# Deploying JupyterHub in "API only mode"
As a service for deploying and managing Jupyter servers for users, JupyterHub
exposes this functionality _primarily_ via a [REST API](rest).
For convenience, JupyterHub also ships with a _basic_ web UI built using that REST API.
The basic web UI enables users to click a button to quickly start and stop their servers,
and it lets admins perform some basic user and server management tasks.
The REST API has always provided additional functionality beyond what is available in the basic web UI.
Similarly, we avoid implementing UI functionality that is also not available via the API.
With JupyterHub 2.0, the basic web UI will **always** be composed using the REST API.
In other words, no UI pages should rely on information not available via the REST API.
Previously, some admin UI functionality could only be achieved via admin pages,
such as paginated requests.
## Limited UI customization via templates
The JupyterHub UI is customizable via extensible HTML [templates](templates),
but this has some limited scope to what can be customized.
Adding some content and messages to existing pages is well supported,
but changing the page flow and what pages are available are beyond the scope of what is customizable.
## Rich UI customization with REST API based apps
Increasingly, JupyterHub is used purely as an API for managing Jupyter servers
for other Jupyter-based applications that might want to present a different user experience.
If you want a fully customized user experience,
you can now disable the Hub UI and use your own pages together with the JupyterHub REST API
to build your own web application to serve your users,
relying on the Hub only as an API for managing users and servers.
One example of such an application is [BinderHub][], which powers https://mybinder.org,
and motivates many of these changes.
BinderHub is distinct from a traditional JupyterHub deployment
because it uses temporary users created for each launch.
Instead of presenting a login page,
users are presented with a form to specify what environment they would like to launch:
![Binder launch form](../images/binderhub-form.png)
When a launch is requested:
1. an image is built, if necessary
2. a temporary user is created,
3. a server is launched for that user, and
4. when running, users are redirected to an already running server with an auth token in the URL
5. after the session is over, the user is deleted
This means that a lot of JupyterHub's UI flow doesn't make sense:
- there is no way for users to login
- the human user doesn't map onto a JupyterHub `User` in a meaningful way
- when a server isn't running, there isn't a 'restart your server' action available because the user has been deleted
- users do not have any access to any Hub functionality, so presenting pages for those features would be confusing
BinderHub is one of the motivating use cases for JupyterHub supporting being used _only_ via its API.
We'll use BinderHub here as an example of various configuration options.
[binderhub]: https://binderhub.readthedocs.io
## Disabling Hub UI
`c.JupyterHub.hub_routespec` is a configuration option to specify which URL prefix should be routed to the Hub.
The default is `/` which means that the Hub will receive all requests not already specified to be routed somewhere else.
There are three values that are most logical for `hub_routespec`:
- `/` - this is the default, and used in most deployments.
It is also the only option prior to JupyterHub 1.4.
- `/hub/` - this serves only Hub pages, both UI and API
- `/hub/api` - this serves _only the Hub API_, so all Hub UI is disabled,
aside from the OAuth confirmation page, if used.
If you choose a hub routespec other than `/`,
the main JupyterHub feature you will lose is the automatic handling of requests for `/user/:username`
when the requested server is not running.
JupyterHub's handling of this request shows this page,
telling you that the server is not running,
with a button to launch it again:
![screenshot of hub page for server not running](../images/server-not-running.png)
If you set `hub_routespec` to something other than `/`,
it is likely that you also want to register another destination for `/` to handle requests to not-running servers.
If you don't, you will see a default 404 page from the proxy:
![screenshot of CHP default 404](../images/chp-404.png)
For mybinder.org, the default "start my server" page doesn't make sense,
because when a server is gone, there is no restart action.
Instead, we provide hints about how to get back to a link to start a _new_ server:
![screenshot of mybinder.org 404](../images/binder-404.png)
To achieve this, mybinder.org registers a route for `/` that goes to a custom endpoint
that runs nginx and only serves this static HTML error page.
This is set with
```python
c.Proxy.extra_routes = {
"/": "http://custom-404-entpoint/",
}
```
You may want to use an alternate behavior, such as redirecting to a landing page,
or taking some other action based on the requested page.
If you use `c.JupyterHub.hub_routespec = "/hub/"`,
then all the Hub pages will be available,
and only this default-page-404 issue will come up.
If you use `c.JupyterHub.hub_routespec = "/hub/api/"`,
then only the Hub _API_ will be available,
and all UI will be up to you.
mybinder.org takes this last option,
because none of the Hub UI pages really make sense.
Binder users don't have any reason to know or care that JupyterHub happens
to be an implementation detail of how their environment is managed.
Seeing Hub error pages and messages in that situation is more likely to be confusing than helpful.
:::{versionadded} 1.4
`c.JupyterHub.hub_routespec` and `c.Proxy.extra_routes` are new in JupyterHub 1.4.
:::

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
# JupyterHub REST API
Below is an interactive view of JupyterHub's OpenAPI specification.
<!-- client-rendered openapi UI copied from FastAPI -->
<link type="text/css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui.css">
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@4.1/swagger-ui-bundle.js"></script>
<!-- `SwaggerUIBundle` is now available on the page -->
<!-- render the ui here -->
<div id="openapi-ui"></div>
<script>
const ui = SwaggerUIBundle({
url: '../_static/rest-api.yml',
dom_id: '#openapi-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout",
deepLinking: true,
showExtensions: true,
showCommonExtensions: true,
});
</script>

View File

@@ -1,14 +0,0 @@
:orphan:
===================
JupyterHub REST API
===================
.. this doc exists as a resolvable link target
.. which _static files are not
.. meta::
:http-equiv=refresh: 0;url=../_static/rest-api/index.html
The rest API docs are `here <../_static/rest-api/index.html>`_
if you are not redirected automatically.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,15 @@
import React from "react"; import React from "react";
import Enzyme, { mount } from "enzyme"; import "@testing-library/jest-dom";
import AddUser from "./AddUser"; import { act } from "react-dom/test-utils";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider, useDispatch, useSelector } from "react-redux"; import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux"; import { createStore } from "redux";
import { HashRouter } from "react-router-dom"; import { HashRouter } from "react-router-dom";
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() }); import AddUser from "./AddUser";
jest.mock("react-redux", () => ({ jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"), ...jest.requireActual("react-redux"),
@@ -14,64 +17,123 @@ jest.mock("react-redux", () => ({
useSelector: jest.fn(), useSelector: jest.fn(),
})); }));
describe("AddUser Component: ", () => { var mockAsync = (result) =>
var mockAsync = () => jest.fn().mockImplementation(() => Promise.resolve(result));
jest
.fn()
.mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
var addUserJsx = (callbackSpy) => ( var mockAsyncRejection = () =>
<Provider store={createStore(() => {}, {})}> jest.fn().mockImplementation(() => Promise.reject());
<HashRouter>
<AddUser
addUsers={callbackSpy}
failRegexEvent={callbackSpy}
updateUsers={callbackSpy}
history={{ push: () => {} }}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({ var addUserJsx = (spy, spy2, spy3) => (
limit: 3, <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 () => {};
}); });
useSelector.mockImplementation((callback) => {
beforeEach(() => { return callback(mockAppState());
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);
}); });
}); });
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 ( return (
<> <>
<div className="container"> <div className="container" data-testid="container">
{errorAlert != null ? ( {errorAlert != null ? (
<div className="row"> <div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2"> <div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">{errorAlert}</div> <div className="alert alert-danger">
{errorAlert}
<button
type="button"
className="close"
onClick={() => setErrorAlert(null)}
>
<span>&times;</span>
</button>
</div>
</div> </div>
</div> </div>
) : ( ) : (
@@ -44,6 +53,7 @@ const CreateGroup = (props) => {
<div className="input-group"> <div className="input-group">
<input <input
className="group-name-input" className="group-name-input"
data-testid="group-input"
type="text" type="text"
id="group-name" id="group-name"
value={groupName} value={groupName}
@@ -61,6 +71,7 @@ const CreateGroup = (props) => {
<span> </span> <span> </span>
<button <button
id="submit" id="submit"
data-testid="submit"
className="btn btn-primary" className="btn btn-primary"
onClick={() => { onClick={() => {
createGroup(groupName) createGroup(groupName)
@@ -69,16 +80,18 @@ const CreateGroup = (props) => {
? updateGroups(0, limit) ? updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0)) .then((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups")) .then(() => history.push("/groups"))
.catch((err) => console.log(err)) .catch(() =>
setErrorAlert(`Could not update groups list.`)
)
: setErrorAlert( : setErrorAlert(
`[${data.status}] Failed to create group. ${ `Failed to create group. ${
data.status == 409 data.status == 409
? "Group already exists." ? "Group already exists."
: "" : ""
}` }`
); );
}) })
.catch((err) => console.log(err)); .catch(() => setErrorAlert(`Failed to create group.`));
}} }}
> >
Create Create

View File

@@ -1,13 +1,14 @@
import React from "react"; import React from "react";
import Enzyme, { mount } from "enzyme"; import "@testing-library/jest-dom";
import CreateGroup from "./CreateGroup"; import { act } from "react-dom/test-utils";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider, useDispatch, useSelector } from "react-redux"; import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux"; import { createStore } from "redux";
import { HashRouter } from "react-router-dom"; import { HashRouter } from "react-router-dom";
import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line // eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() }); import CreateGroup from "./CreateGroup";
jest.mock("react-redux", () => ({ jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"), ...jest.requireActual("react-redux"),
@@ -15,52 +16,100 @@ jest.mock("react-redux", () => ({
useSelector: jest.fn(), useSelector: jest.fn(),
})); }));
describe("CreateGroup Component: ", () => { var mockAsync = (result) =>
var mockAsync = (result) => jest.fn().mockImplementation(() => Promise.resolve(result));
jest.fn().mockImplementation(() => Promise.resolve(result));
var createGroupJsx = (callbackSpy) => ( var mockAsyncRejection = () =>
<Provider store={createStore(() => {}, {})}> jest.fn().mockImplementation(() => Promise.reject());
<HashRouter>
<CreateGroup
createGroup={callbackSpy}
updateGroups={callbackSpy}
history={{ push: () => {} }}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({ var createGroupJsx = (callbackSpy) => (
limit: 3, <Provider store={createStore(() => {}, {})}>
<HashRouter>
<CreateGroup
createGroup={callbackSpy}
updateGroups={callbackSpy}
history={{ push: () => {} }}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({
limit: 3,
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => () => {};
}); });
useSelector.mockImplementation((callback) => {
beforeEach(() => { return callback(mockAppState());
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);
}); });
}); });
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 { var { editUser, deleteUser, noChangeEvent, updateUsers, history } = props;
editUser,
deleteUser,
failRegexEvent,
noChangeEvent,
updateUsers,
history,
} = props;
if (props.location.state == undefined) { if (props.location.state == undefined) {
props.history.push("/"); props.history.push("/");
@@ -40,11 +33,20 @@ const EditUser = (props) => {
return ( return (
<> <>
<div className="container"> <div className="container" data-testid="container">
{errorAlert != null ? ( {errorAlert != null ? (
<div className="row"> <div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2"> <div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">{errorAlert}</div> <div className="alert alert-danger">
{errorAlert}
<button
type="button"
className="close"
onClick={() => setErrorAlert(null)}
>
<span>&times;</span>
</button>
</div>
</div> </div>
</div> </div>
) : ( ) : (
@@ -61,6 +63,7 @@ const EditUser = (props) => {
<div className="form-group"> <div className="form-group">
<textarea <textarea
className="form-control" className="form-control"
data-testid="edit-username-input"
id="exampleFormControlTextarea1" id="exampleFormControlTextarea1"
rows="3" rows="3"
placeholder="updated username" placeholder="updated username"
@@ -81,20 +84,26 @@ const EditUser = (props) => {
<br></br> <br></br>
<button <button
id="delete-user" id="delete-user"
data-testid="delete-user"
className="btn btn-danger btn-sm" className="btn btn-danger btn-sm"
onClick={() => { onClick={(e) => {
e.preventDefault();
deleteUser(username) deleteUser(username)
.then((data) => { .then((data) => {
data.status < 300 data.status < 300
? updateUsers(0, limit) ? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0)) .then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/")) .then(() => history.push("/"))
.catch((err) => console.log(err)) .catch(() =>
: setErrorAlert( setErrorAlert(
`[${data.status}] Failed to edit user.` `Could not update users list.`
); )
)
: setErrorAlert(`Failed to edit user.`);
}) })
.catch((err) => console.log(err)); .catch(() => {
setErrorAlert(`Failed to edit user.`);
});
}} }}
> >
Delete user Delete user
@@ -109,8 +118,10 @@ const EditUser = (props) => {
<span> </span> <span> </span>
<button <button
id="submit" id="submit"
data-testid="submit"
className="btn btn-primary" className="btn btn-primary"
onClick={() => { onClick={(e) => {
e.preventDefault();
if (updatedUsername == "" && admin == has_admin) { if (updatedUsername == "" && admin == has_admin) {
noChangeEvent(); noChangeEvent();
return; return;
@@ -129,17 +140,20 @@ const EditUser = (props) => {
? updateUsers(0, limit) ? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0)) .then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/")) .then(() => history.push("/"))
.catch((err) => console.log(err)) .catch(() =>
: setErrorAlert( setErrorAlert(
`[${data.status}] Failed to edit user.` `Could not update users list.`
); )
)
: setErrorAlert(`Failed to edit user.`);
}) })
.catch((err) => { .catch(() => {
console.log(err); setErrorAlert(`Failed to edit user.`);
}); });
} else { } else {
setUpdatedUsername(""); setErrorAlert(
failRegexEvent(); `Failed to edit user. Make sure the username does not contain special characters.`
);
} }
} else { } else {
editUser(username, username, admin) editUser(username, username, admin)
@@ -148,13 +162,13 @@ const EditUser = (props) => {
? updateUsers(0, limit) ? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0)) .then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/")) .then(() => history.push("/"))
.catch((err) => console.log(err)) .catch(() =>
: setErrorAlert( setErrorAlert(`Could not update users list.`)
`[${data.status}] Failed to edit user.` )
); : setErrorAlert(`Failed to edit user.`);
}) })
.catch((err) => { .catch(() => {
console.log(err); setErrorAlert(`Failed to edit user.`);
}); });
} }
}} }}

View File

@@ -1,12 +1,14 @@
import React from "react"; import React from "react";
import Enzyme, { mount } from "enzyme"; import "@testing-library/jest-dom";
import EditUser from "./EditUser"; import { act } from "react-dom/test-utils";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { render, screen, fireEvent } from "@testing-library/react";
import { Provider, useDispatch, useSelector } from "react-redux"; import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux"; import { createStore } from "redux";
import { HashRouter } from "react-router-dom"; import { HashRouter } from "react-router-dom";
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() }); import EditUser from "./EditUser";
jest.mock("react-redux", () => ({ jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"), ...jest.requireActual("react-redux"),
@@ -14,67 +16,124 @@ jest.mock("react-redux", () => ({
useSelector: jest.fn(), useSelector: jest.fn(),
})); }));
describe("EditUser Component: ", () => { var mockAsync = (data) =>
var mockAsync = () => jest.fn().mockImplementation(() => Promise.resolve(data));
jest
.fn()
.mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
var mockSync = () => jest.fn();
var editUserJsx = (callbackSpy, empty) => ( var mockAsyncRejection = () =>
<Provider store={createStore(() => {}, {})}> jest.fn().mockImplementation(() => Promise.reject());
<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 = () => ({ var editUserJsx = (callbackSpy, empty) => (
limit: 3, <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 () => {};
}); });
useSelector.mockImplementation((callback) => {
beforeEach(() => { return callback(mockAppState());
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);
}); });
}); });
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) => { const GroupEdit = (props) => {
var [selected, setSelected] = useState([]), var [selected, setSelected] = useState([]),
[changed, setChanged] = useState(false), [changed, setChanged] = useState(false),
[errorAlert, setErrorAlert] = useState(null),
limit = useSelector((state) => state.limit); limit = useSelector((state) => state.limit);
var dispatch = useDispatch(); var dispatch = useDispatch();
@@ -41,7 +42,25 @@ const GroupEdit = (props) => {
if (!group_data) return <div></div>; if (!group_data) return <div></div>;
return ( return (
<div className="container"> <div className="container" data-testid="container">
{errorAlert != null ? (
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">
{errorAlert}
<button
type="button"
className="close"
onClick={() => setErrorAlert(null)}
>
<span>&times;</span>
</button>
</div>
</div>
</div>
) : (
<></>
)}
<div className="row"> <div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2"> <div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<h3>Editing Group {group_data.name}</h3> <h3>Editing Group {group_data.name}</h3>
@@ -65,6 +84,7 @@ const GroupEdit = (props) => {
<span> </span> <span> </span>
<button <button
id="submit" id="submit"
data-testid="submit"
className="btn btn-primary" className="btn btn-primary"
onClick={() => { onClick={() => {
// check for changes // check for changes
@@ -89,29 +109,43 @@ const GroupEdit = (props) => {
); );
Promise.all(promiseQueue) Promise.all(promiseQueue)
.then(() => { .then((data) => {
updateGroups(0, limit) // ensure status of all requests are < 300
.then((data) => dispatchPageUpdate(data, 0)) let allPassed =
.then(() => history.push("/groups")); 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 Apply
</button> </button>
<button <button
id="delete-group" id="delete-group"
data-testid="delete-group"
className="btn btn-danger" className="btn btn-danger"
style={{ float: "right" }} style={{ float: "right" }}
onClick={() => { onClick={() => {
var groupName = group_data.name; var groupName = group_data.name;
deleteGroup(groupName) deleteGroup(groupName)
.then(() => { // TODO add error if res not ok
updateGroups(0, limit) .then((data) => {
.then((data) => dispatchPageUpdate(data, 0)) data.status < 300
.then(() => history.push("/groups")); ? 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 Delete Group

View File

@@ -1,100 +1,228 @@
import React from "react"; import React from "react";
import Enzyme, { mount } from "enzyme"; import "@testing-library/jest-dom";
import GroupEdit from "./GroupEdit"; import { act } from "react-dom/test-utils";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider, useSelector } from "react-redux"; import { Provider, useSelector } from "react-redux";
import { createStore } from "redux"; import { createStore } from "redux";
import { HashRouter } from "react-router-dom"; import { HashRouter } from "react-router-dom";
import { act } from "react-dom/test-utils"; // eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() }); import GroupEdit from "./GroupEdit";
jest.mock("react-redux", () => ({ jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"), ...jest.requireActual("react-redux"),
useSelector: jest.fn(), useSelector: jest.fn(),
})); }));
describe("GroupEdit Component: ", () => { var mockAsync = (data) =>
var mockAsync = () => jest.fn().mockImplementation(() => Promise.resolve()); jest.fn().mockImplementation(() => Promise.resolve(data));
var okPacket = new Promise((resolve) => resolve(true)); var mockAsyncRejection = () =>
jest.fn().mockImplementation(() => Promise.reject());
var groupEditJsx = (callbackSpy) => ( var okPacket = new Promise((resolve) => resolve(true));
<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 mockAppState = () => ({ var groupEditJsx = (callbackSpy) => (
limit: 3, <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(() => { var mockAppState = () => ({
useSelector.mockImplementation((callback) => { limit: 3,
return callback(mockAppState()); });
});
});
afterEach(() => { beforeEach(() => {
useSelector.mockClear(); useSelector.mockImplementation((callback) => {
}); return callback(mockAppState());
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");
}); });
}); });
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"> <div className="input-group">
<input <input
id="username-input" id="username-input"
data-testid="username-input"
type="text" type="text"
className="form-control" className="form-control"
placeholder="Add by username" placeholder="Add by username"
@@ -35,6 +36,7 @@ const GroupSelect = (props) => {
<span className="input-group-btn"> <span className="input-group-btn">
<button <button
id="validate-user" id="validate-user"
data-testid="validate-user"
className="btn btn-default" className="btn btn-default"
type="button" type="button"
onClick={() => { onClick={() => {

View File

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

View File

@@ -1,12 +1,14 @@
import React from "react"; import React from "react";
import Enzyme, { mount } from "enzyme"; import "@testing-library/jest-dom";
import Groups from "./Groups"; import { act } from "react-dom/test-utils";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { render, screen } from "@testing-library/react";
import { Provider, useDispatch, useSelector } from "react-redux"; import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux"; import { createStore } from "redux";
import { HashRouter } from "react-router-dom"; import { HashRouter } from "react-router-dom";
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() }); import Groups from "./Groups";
jest.mock("react-redux", () => ({ jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"), ...jest.requireActual("react-redux"),
@@ -14,52 +16,75 @@ jest.mock("react-redux", () => ({
useDispatch: jest.fn(), useDispatch: jest.fn(),
})); }));
describe("Groups Component: ", () => { var mockAsync = () =>
var mockAsync = () => jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
var groupsJsx = (callbackSpy) => ( var groupsJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}> <Provider store={createStore(() => {}, {})}>
<HashRouter> <HashRouter>
<Groups location={{ search: "0" }} updateGroups={callbackSpy} /> <Groups location={{ search: "0" }} updateGroups={callbackSpy} />
</HashRouter> </HashRouter>
</Provider> </Provider>
); );
var mockAppState = () => ({ var mockAppState = () => ({
user_data: JSON.parse( 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":{}}]' '[{"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( groups_data: JSON.parse(
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]' '[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
), ),
limit: 10,
});
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
}); });
useDispatch.mockImplementation(() => {
beforeEach(() => { return () => {};
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>");
}); });
}); });
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)), runningAsc = (e) => e.sort((a) => (a.server == null ? -1 : 1)),
runningDesc = (e) => e.sort((a) => (a.server == null ? 1 : -1)); runningDesc = (e) => e.sort((a) => (a.server == null ? 1 : -1));
var [errorAlert, setErrorAlert] = useState(null);
var [sortMethod, setSortMethod] = useState(null); var [sortMethod, setSortMethod] = useState(null);
var user_data = useSelector((state) => state.user_data), var user_data = useSelector((state) => state.user_data),
@@ -60,7 +61,7 @@ const ServerDashboard = (props) => {
}; };
if (!user_data) { if (!user_data) {
return <div></div>; return <div data-testid="no-show"></div>;
} }
if (page != user_page) { if (page != user_page) {
@@ -72,7 +73,25 @@ const ServerDashboard = (props) => {
} }
return ( return (
<div className="container"> <div className="container" data-testid="container">
{errorAlert != null ? (
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">
{errorAlert}
<button
type="button"
className="close"
onClick={() => setErrorAlert(null)}
>
<span>&times;</span>
</button>
</div>
</div>
</div>
) : (
<></>
)}
<div className="manage-groups" style={{ float: "right", margin: "20px" }}> <div className="manage-groups" style={{ float: "right", margin: "20px" }}>
<Link to="/groups">{"> Manage Groups"}</Link> <Link to="/groups">{"> Manage Groups"}</Link>
</div> </div>
@@ -85,6 +104,7 @@ const ServerDashboard = (props) => {
<SortHandler <SortHandler
sorts={{ asc: usernameAsc, desc: usernameDesc }} sorts={{ asc: usernameAsc, desc: usernameDesc }}
callback={(method) => setSortMethod(() => method)} callback={(method) => setSortMethod(() => method)}
testid="user-sort"
/> />
</th> </th>
<th id="admin-header"> <th id="admin-header">
@@ -92,6 +112,7 @@ const ServerDashboard = (props) => {
<SortHandler <SortHandler
sorts={{ asc: adminAsc, desc: adminDesc }} sorts={{ asc: adminAsc, desc: adminDesc }}
callback={(method) => setSortMethod(() => method)} callback={(method) => setSortMethod(() => method)}
testid="admin-sort"
/> />
</th> </th>
<th id="last-activity-header"> <th id="last-activity-header">
@@ -99,6 +120,7 @@ const ServerDashboard = (props) => {
<SortHandler <SortHandler
sorts={{ asc: dateAsc, desc: dateDesc }} sorts={{ asc: dateAsc, desc: dateDesc }}
callback={(method) => setSortMethod(() => method)} callback={(method) => setSortMethod(() => method)}
testid="last-activity-sort"
/> />
</th> </th>
<th id="running-status-header"> <th id="running-status-header">
@@ -106,6 +128,7 @@ const ServerDashboard = (props) => {
<SortHandler <SortHandler
sorts={{ asc: runningAsc, desc: runningDesc }} sorts={{ asc: runningAsc, desc: runningDesc }}
callback={(method) => setSortMethod(() => method)} callback={(method) => setSortMethod(() => method)}
testid="running-status-sort"
/> />
</th> </th>
<th id="actions-header">Actions</th> <th id="actions-header">Actions</th>
@@ -125,17 +148,33 @@ const ServerDashboard = (props) => {
<Button <Button
variant="primary" variant="primary"
className="start-all" className="start-all"
data-testid="start-all"
onClick={() => { onClick={() => {
Promise.all(startAll(user_data.map((e) => e.name))) Promise.all(startAll(user_data.map((e) => e.name)))
.then((res) => {
let failedServers = res.filter((e) => !e.ok);
if (failedServers.length > 0) {
setErrorAlert(
`Failed to start ${failedServers.length} ${
failedServers.length > 1 ? "servers" : "server"
}. ${
failedServers.length > 1 ? "Are they " : "Is it "
} already running?`
);
}
return res;
})
.then((res) => { .then((res) => {
updateUsers(...slice) updateUsers(...slice)
.then((data) => { .then((data) => {
dispatchPageUpdate(data, page); dispatchPageUpdate(data, page);
}) })
.catch((err) => console.log(err)); .catch(() =>
setErrorAlert(`Failed to update users list.`)
);
return res; return res;
}) })
.catch((err) => console.log(err)); .catch(() => setErrorAlert(`Failed to start servers.`));
}} }}
> >
Start All Start All
@@ -145,17 +184,33 @@ const ServerDashboard = (props) => {
<Button <Button
variant="danger" variant="danger"
className="stop-all" className="stop-all"
data-testid="stop-all"
onClick={() => { onClick={() => {
Promise.all(stopAll(user_data.map((e) => e.name))) Promise.all(stopAll(user_data.map((e) => e.name)))
.then((res) => {
let failedServers = res.filter((e) => !e.ok);
if (failedServers.length > 0) {
setErrorAlert(
`Failed to stop ${failedServers.length} ${
failedServers.length > 1 ? "servers" : "server"
}. ${
failedServers.length > 1 ? "Are they " : "Is it "
} already stopped?`
);
}
return res;
})
.then((res) => { .then((res) => {
updateUsers(...slice) updateUsers(...slice)
.then((data) => { .then((data) => {
dispatchPageUpdate(data, page); dispatchPageUpdate(data, page);
}) })
.catch((err) => console.log(err)); .catch(() =>
setErrorAlert(`Failed to update users list.`)
);
return res; return res;
}) })
.catch((err) => console.log(err)); .catch(() => setErrorAlert(`Failed to stop servers.`));
}} }}
> >
Stop All Stop All
@@ -174,12 +229,12 @@ const ServerDashboard = (props) => {
</tr> </tr>
{user_data.map((e, i) => ( {user_data.map((e, i) => (
<tr key={i + "row"} className="user-row"> <tr key={i + "row"} className="user-row">
<td>{e.name}</td> <td data-testid="user-row-name">{e.name}</td>
<td>{e.admin ? "admin" : ""}</td> <td data-testid="user-row-admin">{e.admin ? "admin" : ""}</td>
<td> <td data-testid="user-row-last-activity">
{e.last_activity ? timeSince(e.last_activity) : "Never"} {e.last_activity ? timeSince(e.last_activity) : "Never"}
</td> </td>
<td> <td data-testid="user-row-server-activity">
{e.server != null ? ( {e.server != null ? (
// Stop Single-user server // Stop Single-user server
<button <button
@@ -187,12 +242,20 @@ const ServerDashboard = (props) => {
onClick={() => onClick={() =>
stopServer(e.name) stopServer(e.name)
.then((res) => { .then((res) => {
updateUsers(...slice).then((data) => { if (res.status < 300) {
dispatchPageUpdate(data, page); updateUsers(...slice)
}); .then((data) => {
dispatchPageUpdate(data, page);
})
.catch(() =>
setErrorAlert(`Failed to update users list.`)
);
} else {
setErrorAlert(`Failed to stop server.`);
}
return res; return res;
}) })
.catch((err) => console.log(err)) .catch(() => setErrorAlert(`Failed to stop server.`))
} }
> >
Stop Server Stop Server
@@ -204,12 +267,22 @@ const ServerDashboard = (props) => {
onClick={() => onClick={() =>
startServer(e.name) startServer(e.name)
.then((res) => { .then((res) => {
updateUsers(...slice).then((data) => { if (res.status < 300) {
dispatchPageUpdate(data, page); updateUsers(...slice)
}); .then((data) => {
dispatchPageUpdate(data, page);
})
.catch(() =>
setErrorAlert(`Failed to update users list.`)
);
} else {
setErrorAlert(`Failed to start server.`);
}
return res; return res;
}) })
.catch((err) => console.log(err)) .catch(() => {
setErrorAlert(`Failed to start server.`);
})
} }
> >
Start Server Start Server
@@ -269,13 +342,14 @@ ServerDashboard.propTypes = {
}; };
const SortHandler = (props) => { const SortHandler = (props) => {
var { sorts, callback } = props; var { sorts, callback, testid } = props;
var [direction, setDirection] = useState(undefined); var [direction, setDirection] = useState(undefined);
return ( return (
<div <div
className="sort-icon" className="sort-icon"
data-testid={testid}
onClick={() => { onClick={() => {
if (!direction) { if (!direction) {
callback(sorts.desc); callback(sorts.desc);
@@ -303,6 +377,7 @@ const SortHandler = (props) => {
SortHandler.propTypes = { SortHandler.propTypes = {
sorts: PropTypes.object, sorts: PropTypes.object,
callback: PropTypes.func, callback: PropTypes.func,
testid: PropTypes.string,
}; };
export default ServerDashboard; export default ServerDashboard;

View File

@@ -1,161 +1,437 @@
import React from "react"; import React from "react";
import Enzyme, { mount } from "enzyme"; import "@testing-library/jest-dom";
import ServerDashboard from "./ServerDashboard"; import { act } from "react-dom/test-utils";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { render, screen, fireEvent } from "@testing-library/react";
import { HashRouter, Switch } from "react-router-dom"; import { HashRouter, Switch } from "react-router-dom";
import { Provider, useSelector } from "react-redux"; import { Provider, useSelector } from "react-redux";
import { createStore } from "redux"; import { createStore } from "redux";
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() }); import ServerDashboard from "./ServerDashboard";
jest.mock("react-redux", () => ({ jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"), ...jest.requireActual("react-redux"),
useSelector: jest.fn(), useSelector: jest.fn(),
})); }));
describe("ServerDashboard Component: ", () => { var serverDashboardJsx = (spy) => (
var serverDashboardJsx = (callbackSpy) => ( <Provider store={createStore(() => {}, {})}>
<Provider store={createStore(() => {}, {})}> <HashRouter>
<HashRouter> <Switch>
<Switch> <ServerDashboard
<ServerDashboard updateUsers={spy}
updateUsers={callbackSpy} shutdownHub={spy}
shutdownHub={callbackSpy} startServer={spy}
startServer={callbackSpy} stopServer={spy}
stopServer={callbackSpy} startAll={spy}
startAll={callbackSpy} stopAll={spy}
stopAll={callbackSpy} />
/> </Switch>
</Switch> </HashRouter>
</HashRouter> </Provider>
</Provider> );
);
var mockAsync = () => var mockAsync = (data) =>
jest jest.fn().mockImplementation(() => Promise.resolve(data ? data : { k: "v" }));
.fn()
.mockImplementation(() =>
Promise.resolve({ json: () => Promise.resolve({ k: "v" }) })
);
var mockAppState = () => ({ var mockAsyncRejection = () =>
user_data: JSON.parse( jest.fn().mockImplementation(() => Promise.reject());
'[{"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":{}}]'
),
});
beforeEach(() => { var mockAppState = () => ({
useSelector.mockImplementation((callback) => { user_data: JSON.parse(
return callback(mockAppState()); '[{"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(() => { beforeEach(() => {
useSelector.mockClear(); useSelector.mockImplementation((callback) => {
}); return callback(mockAppState());
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>");
}); });
}); });
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) => { export const jhapiRequest = (endpoint, method, data) => {
return fetch("/hub/api" + endpoint, { let base_url = window.base_url,
api_url = `${base_url}hub/api`;
return fetch(api_url + endpoint, {
method: method, method: method,
json: true, json: true,
headers: { headers: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,6 +58,14 @@ class SelfAPIHandler(APIHandler):
model = get_model(user) model = get_model(user)
# add session_id associated with token
# added in 2.0
token = self.get_token()
if token:
model["session_id"] = token.session_id
else:
model["session_id"] = None
# add scopes to identify model, # add scopes to identify model,
# but not the scopes we added to ensure we could read our own model # but not the scopes we added to ensure we could read our own model
model["scopes"] = sorted(self.expanded_scopes.difference(_added_scopes)) model["scopes"] = sorted(self.expanded_scopes.difference(_added_scopes))
@@ -397,9 +405,11 @@ class UserTokenListAPIHandler(APIHandler):
token_roles = body.get('roles') token_roles = body.get('roles')
try: try:
api_token = user.new_api_token( api_token = user.new_api_token(
note=note, expires_in=body.get('expires_in', None), roles=token_roles note=note,
expires_in=body.get('expires_in', None),
roles=token_roles,
) )
except NameError: except KeyError:
raise web.HTTPError(404, "Requested roles %r not found" % token_roles) raise web.HTTPError(404, "Requested roles %r not found" % token_roles)
except ValueError: except ValueError:
raise web.HTTPError( raise web.HTTPError(
@@ -421,6 +431,7 @@ class UserTokenListAPIHandler(APIHandler):
token_model = self.token_model(orm.APIToken.find(self.db, api_token)) token_model = self.token_model(orm.APIToken.find(self.db, api_token))
token_model['token'] = api_token token_model['token'] = api_token
self.write(json.dumps(token_model)) self.write(json.dumps(token_model))
self.set_status(201)
class UserTokenAPIHandler(APIHandler): class UserTokenAPIHandler(APIHandler):
@@ -483,6 +494,11 @@ class UserServerAPIHandler(APIHandler):
@needs_scope('servers') @needs_scope('servers')
async def post(self, user_name, server_name=''): async def post(self, user_name, server_name=''):
user = self.find_user(user_name) user = self.find_user(user_name)
if user is None:
# this can be reached if a token has `servers`
# permission on *all* users
raise web.HTTPError(404)
if server_name: if server_name:
if not self.allow_named_servers: if not self.allow_named_servers:
raise web.HTTPError(400, "Named servers are not enabled.") raise web.HTTPError(400, "Named servers are not enabled.")
@@ -698,7 +714,12 @@ class SpawnProgressAPIHandler(APIHandler):
# check if spawner has just failed # check if spawner has just failed
f = spawn_future f = spawn_future
if f and f.done() and f.exception(): 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) await self.send_event(failed_event)
return return
else: else:
@@ -731,7 +752,12 @@ class SpawnProgressAPIHandler(APIHandler):
# what happened? Maybe spawn failed? # what happened? Maybe spawn failed?
f = spawn_future f = spawn_future
if f and f.done() and f.exception(): 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: else:
self.log.warning( self.log.warning(
"Server %s didn't start for unknown reason", spawner._log_name "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 .proxy import Proxy, ConfigurableHTTPProxy
from .traitlets import URLPrefix, Command, EntryPointType, Callable from .traitlets import URLPrefix, Command, EntryPointType, Callable
from .utils import ( from .utils import (
AnyTimeoutError,
catch_db_error, catch_db_error,
maybe_future, maybe_future,
url_path_join, url_path_join,
@@ -790,6 +791,16 @@ class JupyterHub(Application):
self.proxy_api_ip or '127.0.0.1', self.proxy_api_port or self.port + 1 self.proxy_api_ip or '127.0.0.1', self.proxy_api_port or self.port + 1
) )
forwarded_host_header = Unicode(
'',
help="""Alternate header to use as the Host (e.g., X-Forwarded-Host)
when determining whether a request is cross-origin
This may be useful when JupyterHub is running behind a proxy that rewrites
the Host header.
""",
).tag(config=True)
hub_port = Integer( hub_port = Integer(
8081, 8081,
help="""The internal port for the Hub process. help="""The internal port for the Hub process.
@@ -1518,6 +1529,25 @@ class JupyterHub(Application):
""", """,
).tag(config=True) ).tag(config=True)
use_legacy_stopped_server_status_code = Bool(
False,
help="""
Return 503 rather than 424 when request comes in for a non-running server.
Prior to JupyterHub 2.0, we returned a 503 when any request came in for
a user server that was currently not running. By default, JupyterHub 2.0
will return a 424 - this makes operational metric dashboards more useful.
JupyterLab < 3.2 expected the 503 to know if the user server is no longer
running, and prompted the user to start their server. Set this config to
true to retain the old behavior, so JupyterLab < 3.2 can continue to show
the appropriate UI when the user server is stopped.
This option will be removed in a future release.
""",
config=True,
)
def init_handlers(self): def init_handlers(self):
h = [] h = []
# load handlers from the authenticator # load handlers from the authenticator
@@ -1873,6 +1903,7 @@ class JupyterHub(Application):
user = orm.User.find(db, name) user = orm.User.find(db, name)
if user is None: if user is None:
user = orm.User(name=name, admin=True) user = orm.User(name=name, admin=True)
roles.assign_default_roles(self.db, entity=user)
new_users.append(user) new_users.append(user)
db.add(user) db.add(user)
else: else:
@@ -1963,6 +1994,7 @@ class JupyterHub(Application):
self.log.info(f"Creating user {username}") self.log.info(f"Creating user {username}")
user = orm.User(name=username) user = orm.User(name=username)
self.db.add(user) self.db.add(user)
roles.assign_default_roles(self.db, entity=user)
self.db.commit() self.db.commit()
return user return user
@@ -1984,14 +2016,25 @@ class JupyterHub(Application):
async def init_role_creation(self): async def init_role_creation(self):
"""Load default and predefined roles into the database""" """Load default and predefined roles into the database"""
self.log.debug('Loading default roles to database') self.log.debug('Loading roles into database')
default_roles = roles.get_default_roles() default_roles = roles.get_default_roles()
config_role_names = [r['name'] for r in self.load_roles] config_role_names = [r['name'] for r in self.load_roles]
init_roles = default_roles default_roles_dict = {role["name"]: role for role in default_roles}
init_roles = []
roles_with_new_permissions = [] roles_with_new_permissions = []
for role_spec in self.load_roles: for role_spec in self.load_roles:
role_name = role_spec['name'] role_name = role_spec['name']
if role_name in default_roles_dict:
self.log.debug(f"Overriding default role {role_name}")
# merge custom role spec with default role spec when overriding
# so the new role can be partially defined
default_role_spec = default_roles_dict.pop(role_name)
merged_role_spec = {}
merged_role_spec.update(default_role_spec)
merged_role_spec.update(role_spec)
role_spec = merged_role_spec
# Check for duplicates # Check for duplicates
if config_role_names.count(role_name) > 1: if config_role_names.count(role_name) > 1:
raise ValueError( raise ValueError(
@@ -2002,10 +2045,13 @@ class JupyterHub(Application):
old_role = orm.Role.find(self.db, name=role_name) old_role = orm.Role.find(self.db, name=role_name)
if old_role: if old_role:
if not set(role_spec['scopes']).issubset(old_role.scopes): if not set(role_spec['scopes']).issubset(old_role.scopes):
app_log.warning( self.log.warning(
"Role %s has obtained extra permissions" % role_name "Role %s has obtained extra permissions" % role_name
) )
roles_with_new_permissions.append(role_name) roles_with_new_permissions.append(role_name)
# make sure we load any default roles not overridden
init_roles = list(default_roles_dict.values()) + init_roles
if roles_with_new_permissions: if roles_with_new_permissions:
unauthorized_oauth_tokens = ( unauthorized_oauth_tokens = (
self.db.query(orm.APIToken) self.db.query(orm.APIToken)
@@ -2017,7 +2063,7 @@ class JupyterHub(Application):
.filter(orm.APIToken.client_id != 'jupyterhub') .filter(orm.APIToken.client_id != 'jupyterhub')
) )
for token in unauthorized_oauth_tokens: for token in unauthorized_oauth_tokens:
app_log.warning( self.log.warning(
"Deleting OAuth token %s; one of its roles obtained new permissions that were not authorized by user" "Deleting OAuth token %s; one of its roles obtained new permissions that were not authorized by user"
% token % token
) )
@@ -2025,14 +2071,19 @@ class JupyterHub(Application):
self.db.commit() self.db.commit()
init_role_names = [r['name'] for r in init_roles] init_role_names = [r['name'] for r in init_roles]
if not orm.Role.find(self.db, name='admin'): if (
self.db.query(orm.Role).first() is None
and self.db.query(orm.User).first() is not None
):
# apply rbac-upgrade default role assignment if there are users in the db,
# but not any roles
self._rbac_upgrade = True self._rbac_upgrade = True
else: else:
self._rbac_upgrade = False self._rbac_upgrade = False
for role in self.db.query(orm.Role).filter( for role in self.db.query(orm.Role).filter(
orm.Role.name.notin_(init_role_names) orm.Role.name.notin_(init_role_names)
): ):
app_log.info(f"Deleting role {role.name}") self.log.warning(f"Deleting role {role.name}")
self.db.delete(role) self.db.delete(role)
self.db.commit() self.db.commit()
for role in init_roles: for role in init_roles:
@@ -2048,66 +2099,89 @@ class JupyterHub(Application):
if config_admin_users: if config_admin_users:
for role_spec in self.load_roles: for role_spec in self.load_roles:
if role_spec['name'] == 'admin': if role_spec['name'] == 'admin':
app_log.warning( self.log.warning(
"Configuration specifies both admin_users and users in the admin role specification. " "Configuration specifies both admin_users and users in the admin role specification. "
"If admin role is present in config, c.authenticator.admin_users should not be used." "If admin role is present in config, c.Authenticator.admin_users should not be used."
) )
app_log.info( self.log.info(
"Merging admin_users set with users list in admin role" "Merging admin_users set with users list in admin role"
) )
role_spec['users'] = set(role_spec.get('users', [])) role_spec['users'] = set(role_spec.get('users', []))
role_spec['users'] |= config_admin_users role_spec['users'] |= config_admin_users
self.log.debug('Loading predefined roles from config file to database') self.log.debug('Loading role assignments from config')
has_admin_role_spec = {role_bearer: False for role_bearer in admin_role_objects} has_admin_role_spec = {role_bearer: False for role_bearer in admin_role_objects}
for predef_role in self.load_roles: for role_spec in self.load_roles:
predef_role_obj = orm.Role.find(db, name=predef_role['name']) role = orm.Role.find(db, name=role_spec['name'])
if predef_role['name'] == 'admin': role_name = role_spec["name"]
if role_name == 'admin':
for kind in admin_role_objects: for kind in admin_role_objects:
has_admin_role_spec[kind] = kind in predef_role has_admin_role_spec[kind] = kind in role_spec
if has_admin_role_spec[kind]: if has_admin_role_spec[kind]:
app_log.info(f"Admin role specifies static {kind} list") self.log.info(f"Admin role specifies static {kind} list")
else: else:
app_log.info( self.log.info(
f"Admin role does not specify {kind}, preserving admin membership in database" f"Admin role does not specify {kind}, preserving admin membership in database"
) )
# add users, services, and/or groups, # add users, services, and/or groups,
# tokens need to be checked for permissions # tokens need to be checked for permissions
for kind in kinds: for kind in kinds:
orm_role_bearers = [] orm_role_bearers = []
if kind in predef_role.keys(): if kind in role_spec:
for bname in predef_role[kind]: for name in role_spec[kind]:
if kind == 'users': if kind == 'users':
bname = self.authenticator.normalize_username(bname) name = self.authenticator.normalize_username(name)
if not ( if not (
await maybe_future( await maybe_future(
self.authenticator.check_allowed(bname, None) self.authenticator.check_allowed(name, None)
) )
): ):
raise ValueError( raise ValueError(
"Username %r is not in Authenticator.allowed_users" f"Username {name} is not in Authenticator.allowed_users"
% bname
) )
Class = orm.get_class(kind) Class = orm.get_class(kind)
orm_obj = Class.find(db, bname) orm_obj = Class.find(db, name)
if orm_obj: if orm_obj is not None:
orm_role_bearers.append(orm_obj) orm_role_bearers.append(orm_obj)
else: else:
app_log.info( self.log.info(
f"Found unexisting {kind} {bname} in role definition {predef_role['name']}" f"Found unexisting {kind} {name} in role definition {role_name}"
) )
if kind == 'users': if kind == 'users':
orm_obj = await self._get_or_create_user(bname) orm_obj = await self._get_or_create_user(name)
orm_role_bearers.append(orm_obj) orm_role_bearers.append(orm_obj)
elif kind == 'groups':
group = orm.Group(name=name)
db.add(group)
db.commit()
orm_role_bearers.append(group)
else: else:
raise ValueError( raise ValueError(
f"{kind} {bname} defined in config role definition {predef_role['name']} but not present in database" f"{kind} {name} defined in config role definition {role_name} but not present in database"
) )
# Ensure all with admin role have admin flag # Ensure all with admin role have admin flag
if predef_role['name'] == 'admin': if role_name == 'admin':
orm_obj.admin = True orm_obj.admin = True
setattr(predef_role_obj, kind, orm_role_bearers) # explicitly defined list
# ensure membership list is exact match (adds and revokes permissions)
setattr(role, kind, orm_role_bearers)
else:
# no defined members
# leaving 'users' undefined in overrides of the default 'user' role
# should not clear membership on startup
# since allowed users could be managed by the authenticator
if kind == "users" and role_name == "user":
# Default user lists can be managed by the Authenticator,
# if unspecified in role config
pass
else:
# otherwise, omitting a member category is equivalent to specifying an empty list
setattr(role, kind, [])
db.commit() db.commit()
if self.authenticator.allowed_users: if self.authenticator.allowed_users:
self.log.debug(
f"Assigning {len(self.authenticator.allowed_users)} allowed_users to the user role"
)
allowed_users = db.query(orm.User).filter( allowed_users = db.query(orm.User).filter(
orm.User.name.in_(self.authenticator.allowed_users) orm.User.name.in_(self.authenticator.allowed_users)
) )
@@ -2124,8 +2198,8 @@ class JupyterHub(Application):
db.commit() db.commit()
# make sure that on hub upgrade, all users, services and tokens have at least one role (update with default) # make sure that on hub upgrade, all users, services and tokens have at least one role (update with default)
if getattr(self, '_rbac_upgrade', False): if getattr(self, '_rbac_upgrade', False):
app_log.warning( self.log.warning(
"No admin role found; assuming hub upgrade. Initializing default roles for all entities" "No roles found; assuming hub upgrade. Initializing default roles for all entities"
) )
for kind in kinds: for kind in kinds:
roles.check_for_default_roles(db, kind) roles.check_for_default_roles(db, kind)
@@ -2331,7 +2405,7 @@ class JupyterHub(Application):
continue continue
try: try:
await Server.from_orm(service.orm.server).wait_up(timeout=1, http=True) await Server.from_orm(service.orm.server).wait_up(timeout=1, http=True)
except TimeoutError: except AnyTimeoutError:
self.log.warning( self.log.warning(
"Cannot connect to %s service %s at %s", "Cannot connect to %s service %s at %s",
service.kind, service.kind,
@@ -2409,7 +2483,7 @@ class JupyterHub(Application):
) )
try: try:
await user._wait_up(spawner) await user._wait_up(spawner)
except TimeoutError: except AnyTimeoutError:
self.log.error( self.log.error(
"%s does not appear to be running at %s, shutting it down.", "%s does not appear to be running at %s, shutting it down.",
spawner._log_name, spawner._log_name,
@@ -2773,7 +2847,7 @@ class JupyterHub(Application):
await gen.with_timeout( await gen.with_timeout(
timedelta(seconds=max(init_spawners_timeout, 1)), init_spawners_future timedelta(seconds=max(init_spawners_timeout, 1)), init_spawners_future
) )
except gen.TimeoutError: except AnyTimeoutError:
self.log.warning( self.log.warning(
"init_spawners did not complete within %i seconds. " "init_spawners did not complete within %i seconds. "
"Allowing to complete in the background.", "Allowing to complete in the background.",
@@ -3036,7 +3110,7 @@ class JupyterHub(Application):
await Server.from_orm(service.orm.server).wait_up( await Server.from_orm(service.orm.server).wait_up(
http=True, timeout=1, ssl_context=ssl_context http=True, timeout=1, ssl_context=ssl_context
) )
except TimeoutError: except AnyTimeoutError:
if service.managed: if service.managed:
status = await service.spawner.poll() status = await service.spawner.poll()
if status is not None: if status is not None:

View File

@@ -45,9 +45,12 @@ from ..metrics import ServerSpawnStatus
from ..metrics import ServerStopStatus from ..metrics import ServerStopStatus
from ..metrics import TOTAL_USERS from ..metrics import TOTAL_USERS
from ..objects import Server from ..objects import Server
from ..scopes import needs_scope
from ..spawner import LocalProcessSpawner from ..spawner import LocalProcessSpawner
from ..user import User from ..user import User
from ..utils import AnyTimeoutError
from ..utils import get_accepted_mimetype from ..utils import get_accepted_mimetype
from ..utils import get_browser_protocol
from ..utils import maybe_future from ..utils import maybe_future
from ..utils import url_path_join from ..utils import url_path_join
@@ -70,6 +73,12 @@ SESSION_COOKIE_NAME = 'jupyterhub-session-id'
class BaseHandler(RequestHandler): class BaseHandler(RequestHandler):
"""Base Handler class with access to common methods and properties.""" """Base Handler class with access to common methods and properties."""
# by default, only accept cookie-based authentication
# The APIHandler base class enables token auth
# versionadded: 2.0
_accept_cookie_auth = True
_accept_token_auth = False
async def prepare(self): async def prepare(self):
"""Identify the user during the prepare stage of each request """Identify the user during the prepare stage of each request
@@ -339,6 +348,7 @@ class BaseHandler(RequestHandler):
auth_info['auth_state'] = await user.get_auth_state() auth_info['auth_state'] = await user.get_auth_state()
return await self.auth_to_user(auth_info, user) return await self.auth_to_user(auth_info, user)
@functools.lru_cache()
def get_token(self): def get_token(self):
"""get token from authorization header""" """get token from authorization header"""
token = self.get_auth_token() token = self.get_auth_token()
@@ -409,9 +419,11 @@ class BaseHandler(RequestHandler):
async def get_current_user(self): async def get_current_user(self):
"""get current username""" """get current username"""
if not hasattr(self, '_jupyterhub_user'): if not hasattr(self, '_jupyterhub_user'):
user = None
try: try:
user = self.get_current_user_token() if self._accept_token_auth:
if user is None: user = self.get_current_user_token()
if user is None and self._accept_cookie_auth:
user = self.get_current_user_cookie() user = self.get_current_user_cookie()
if user and isinstance(user, User): if user and isinstance(user, User):
user = await self.refresh_auth(user) user = await self.refresh_auth(user)
@@ -622,12 +634,10 @@ class BaseHandler(RequestHandler):
next_url = self.get_argument('next', default='') next_url = self.get_argument('next', default='')
# protect against some browsers' buggy handling of backslash as slash # protect against some browsers' buggy handling of backslash as slash
next_url = next_url.replace('\\', '%5C') next_url = next_url.replace('\\', '%5C')
if (next_url + '/').startswith( proto = get_browser_protocol(self.request)
( host = self.request.host
f'{self.request.protocol}://{self.request.host}/',
f'//{self.request.host}/', if (next_url + '/').startswith((f'{proto}://{host}/', f'//{host}/',)) or (
)
) or (
self.subdomain_host self.subdomain_host
and urlparse(next_url).netloc and urlparse(next_url).netloc
and ("." + urlparse(next_url).netloc).endswith( and ("." + urlparse(next_url).netloc).endswith(
@@ -761,8 +771,9 @@ class BaseHandler(RequestHandler):
# Only set `admin` if the authenticator returned an explicit value. # Only set `admin` if the authenticator returned an explicit value.
if admin is not None and admin != user.admin: if admin is not None and admin != user.admin:
user.admin = admin user.admin = admin
roles.assign_default_roles(self.db, entity=user) # always ensure default roles ('user', 'admin' if admin) are assigned
self.db.commit() # after a successful login
roles.assign_default_roles(self.db, entity=user)
# always set auth_state and commit, # always set auth_state and commit,
# because there could be key-rotation or clearing of previous values # because there could be key-rotation or clearing of previous values
# going on. # going on.
@@ -1021,7 +1032,7 @@ class BaseHandler(RequestHandler):
await gen.with_timeout( await gen.with_timeout(
timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future
) )
except gen.TimeoutError: except AnyTimeoutError:
# waiting_for_response indicates server process has started, # waiting_for_response indicates server process has started,
# but is yet to become responsive. # but is yet to become responsive.
if spawner._spawn_pending and not spawner._waiting_for_response: if spawner._spawn_pending and not spawner._waiting_for_response:
@@ -1168,7 +1179,7 @@ class BaseHandler(RequestHandler):
try: try:
await gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), future) await gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), future)
except gen.TimeoutError: except AnyTimeoutError:
# hit timeout, but stop is still pending # hit timeout, but stop is still pending
self.log.warning( self.log.warning(
"User %s:%s server is slow to stop (timeout=%s)", "User %s:%s server is slow to stop (timeout=%s)",
@@ -1357,7 +1368,7 @@ class UserUrlHandler(BaseHandler):
**Changed Behavior as of 1.0** This handler no longer triggers a spawn. Instead, it checks if: **Changed Behavior as of 1.0** This handler no longer triggers a spawn. Instead, it checks if:
1. server is not active, serve page prompting for spawn (status: 503) 1. server is not active, serve page prompting for spawn (status: 424)
2. server is ready (This shouldn't happen! Proxy isn't updated yet. Wait a bit and redirect.) 2. server is ready (This shouldn't happen! Proxy isn't updated yet. Wait a bit and redirect.)
3. server is active, redirect to /hub/spawn-pending to monitor launch progress 3. server is active, redirect to /hub/spawn-pending to monitor launch progress
(will redirect back when finished) (will redirect back when finished)
@@ -1371,12 +1382,22 @@ class UserUrlHandler(BaseHandler):
Note that this only occurs if bob's server is not already running. Note that this only occurs if bob's server is not already running.
""" """
# accept token auth for API requests that are probably to non-running servers
_accept_token_auth = True
def _fail_api_request(self, user_name='', server_name=''): def _fail_api_request(self, user_name='', server_name=''):
"""Fail an API request to a not-running server""" """Fail an API request to a not-running server"""
self.log.warning( self.log.warning(
"Failing suspected API request to not-running server: %s", self.request.path "Failing suspected API request to not-running server: %s", self.request.path
) )
self.set_status(503)
# If we got here, the server is not running. To differentiate
# that the *server* itself is not running, rather than just the particular
# resource *in* the server is not found, we return a 424 instead of a 404.
# We allow retaining the old behavior to support older JupyterLab versions
self.set_status(
424 if not self.app.use_legacy_stopped_server_status_code else 503
)
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
spawn_url = urlparse(self.request.full_url())._replace(query="") spawn_url = urlparse(self.request.full_url())._replace(query="")
@@ -1428,54 +1449,24 @@ class UserUrlHandler(BaseHandler):
delete = non_get delete = non_get
@web.authenticated @web.authenticated
@needs_scope("access:servers")
async def get(self, user_name, user_path): async def get(self, user_name, user_path):
if not user_path: if not user_path:
user_path = '/' user_path = '/'
current_user = self.current_user current_user = self.current_user
if user_name != current_user.name:
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
user = self.find_user(user_name) user = self.find_user(user_name)
if user is None: if user is None:
# no such user # 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( self.log.info(
"Admin %s requesting spawn on behalf of %s", f"User {current_user.name} requesting spawn on behalf of {user.name}"
current_user.name,
user.name,
) )
admin_spawn = True admin_spawn = True
should_spawn = True should_spawn = True
redirect_to_self = False redirect_to_self = False
else: else:
user = current_user 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, # If people visit /user/:user_name directly on the Hub,
# the redirects will just loop, because the proxy is bypassed. # the redirects will just loop, because the proxy is bypassed.
@@ -1541,15 +1532,17 @@ class UserUrlHandler(BaseHandler):
self.redirect(pending_url, status=303) self.redirect(pending_url, status=303)
return return
# if we got here, the server is not running # If we got here, the server is not running. To differentiate
# serve a page prompting for spawn and 503 error # that the *server* itself is not running, rather than just the particular
# visiting /user/:name no longer triggers implicit spawn # page *in* the server is not found, we return a 424 instead of a 404.
# without explicit user action # We allow retaining the old behavior to support older JupyterLab versions
spawn_url = url_concat( spawn_url = url_concat(
url_path_join(self.hub.base_url, "spawn", user.escaped_name, server_name), url_path_join(self.hub.base_url, "spawn", user.escaped_name, server_name),
{"next": self.request.uri}, {"next": self.request.uri},
) )
self.set_status(503) self.set_status(
424 if not self.app.use_legacy_stopped_server_status_code else 503
)
auth_state = await user.get_auth_state() auth_state = await user.get_auth_state()
html = await self.render_template( html = await self.render_template(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
import json import json
from unittest import mock
import pytest
from .utils import add_user
from .utils import api_request from .utils import api_request
from .utils import get_page
from jupyterhub import metrics from jupyterhub import metrics
from jupyterhub import orm from jupyterhub import orm
from jupyterhub import roles
async def test_total_users(app): async def test_total_users(app):
@@ -32,3 +36,42 @@ async def test_total_users(app):
sample = metrics.TOTAL_USERS.collect()[0].samples[0] sample = metrics.TOTAL_USERS.collect()[0].samples[0]
assert sample.value == num_users 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 tornado.httputil import url_concat
from .. import orm from .. import orm
from .. import roles
from .. import scopes from .. import scopes
from ..auth import Authenticator from ..auth import Authenticator
from ..handlers import BaseHandler from ..handlers import BaseHandler
@@ -20,7 +21,6 @@ from ..utils import url_path_join as ujoin
from .mocking import FalsyCallableFormSpawner from .mocking import FalsyCallableFormSpawner
from .mocking import FormSpawner from .mocking import FormSpawner
from .test_api import next_event from .test_api import next_event
from .utils import add_user
from .utils import api_request from .utils import api_request
from .utils import async_requests from .utils import async_requests
from .utils import AsyncSession 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) # 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' name = 'wash'
cookies = await app.login_user(name) 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}) url = '/?' + urlencode({'next': next_url})
r = await get_page(url, app, cookies=cookies) r = await get_page(url, app, cookies=cookies)
path = urlparse(r.url).path path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name) assert path == ujoin(app.base_url, f'hub/user/{user.name}/test.ipynb')
# serve "server not running" page, which has status 503 # preserves choice to requested user, which 404s as unavailable without access
assert r.status_code == 503 assert r.status_code == 404
async def test_root_default_url_noauth(app): async def test_root_default_url_noauth(app):
@@ -172,7 +172,7 @@ async def test_spawn_redirect(app):
r = await get_page('user/' + name, app, hub=False, cookies=cookies) r = await get_page('user/' + name, app, hub=False, cookies=cookies)
path = urlparse(r.url).path path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/' % name) assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
assert r.status_code == 503 assert r.status_code == 424
async def test_spawn_handler_access(app): async def test_spawn_handler_access(app):
@@ -203,13 +203,34 @@ async def test_spawn_handler_access(app):
r.raise_for_status() r.raise_for_status()
async def test_spawn_admin_access(app, admin_access): @pytest.mark.parametrize("has_access", ["all", "user", "group", False])
"""GET /user/:name as admin with admin-access spawns user's server""" async def test_spawn_other_user(
cookies = await app.login_user('admin') app, user, username, group, create_temp_role, has_access
name = 'mariel' ):
user = add_user(app.db, app=app, name=name) """GET /user/:name as another user with access to spawns user's server"""
app.db.commit() 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) r = await get_page('spawn/' + name, app, cookies=cookies)
if not has_access:
assert r.status_code == 404
return
r.raise_for_status() r.raise_for_status()
while '/spawn-pending/' in r.url: 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") 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}): with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
cookies = await app.login_user('admin') r = await get_page('spawn/' + user.name, app, cookies=cookies)
u = add_user(app.db, app=app, name='melanie') if not has_access:
r = await get_page('spawn/' + u.name, app, cookies=cookies) assert r.status_code == 404
assert r.url.endswith('/spawn/' + u.name) return
assert r.status_code == 200
assert r.url.endswith('/spawn/' + user.name)
assert FormSpawner.options_form in r.text 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): 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}): with mock.patch.dict(app.tornado_settings, {'spawner_class': FormSpawner}):
base_url = ujoin(public_host(app), app.hub.base_url) base_url = ujoin(public_host(app), app.hub.base_url)
cookies = await app.login_user('admin') next_url = ujoin(app.base_url, 'user', user.name, 'tree')
u = add_user(app.db, app=app, name='martha')
next_url = ujoin(app.base_url, 'user', u.name, 'tree')
r = await async_requests.post( 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, cookies=cookies,
data={'bounds': ['-3', '3'], 'energy': '938MeV'}, data={'bounds': ['-3', '3'], 'energy': '938MeV'},
) )
if not has_access:
assert r.status_code == 404
return
r.raise_for_status() r.raise_for_status()
while '/spawn-pending/' in r.url: while '/spawn-pending/' in r.url:
@@ -342,8 +406,8 @@ async def test_spawn_form_admin_access(app, admin_access):
r.raise_for_status() r.raise_for_status()
assert r.history assert r.history
assert r.url.startswith(public_url(app, u)) assert r.url.startswith(public_url(app, user))
assert u.spawner.user_options == { assert user.spawner.user_options == {
'energy': '938MeV', 'energy': '938MeV',
'bounds': [-3, 3], 'bounds': [-3, 3],
'notspecified': 5, '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') assert redirected_url.path == ujoin(app.base_url, 'user', username, 'terminals/1')
async def test_user_redirect_deprecated(app, username): @pytest.mark.parametrize("has_access", ["all", "user", "group", False])
"""redirecting from /user/someonelse/ URLs (deprecated)""" 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 name = username
cookies = await app.login_user(name) 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)) print(urlparse(r.url))
path = urlparse(r.url).path path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/' % name) assert path == ujoin(app.base_url, f'hub/user/{other_user.name}/')
assert r.status_code == 503 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)) print(urlparse(r.url))
path = urlparse(r.url).path path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name) assert path == ujoin(app.base_url, f'hub/user/{other_user.name}/test.ipynb')
assert r.status_code == 503 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() r.raise_for_status()
print(urlparse(r.url)) print(urlparse(r.url))
path = urlparse(r.url).path path = urlparse(r.url).path
assert path == ujoin(app.base_url, '/hub/login') assert path == ujoin(app.base_url, '/hub/login')
query = urlparse(r.url).query query = urlparse(r.url).query
assert query == urlencode( 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) assert action.endswith(form_action)
@pytest.mark.parametrize(
"url, token_in",
[
("/home", "url"),
("/home", "header"),
("/login", "url"),
("/login", "header"),
],
)
async def test_page_with_token(app, user, url, token_in):
cookies = await app.login_user(user.name)
token = user.new_api_token()
if token_in == "url":
url = url_concat(url, {"token": token})
headers = None
elif token_in == "header":
headers = {
"Authorization": f"token {token}",
}
# request a page with ?token= in URL shouldn't be allowed
r = await get_page(
url,
app,
headers=headers,
allow_redirects=False,
)
if "/hub/login" in r.url:
assert r.status_code == 200
else:
assert r.status_code == 302
assert r.headers["location"].partition("?")[0].endswith("/hub/login")
assert not r.cookies
async def test_login_fail(app): async def test_login_fail(app):
name = 'wash' name = 'wash'
base_url = public_url(app) base_url = public_url(app)
@@ -1061,24 +1183,18 @@ async def test_token_page(app):
async def test_server_not_running_api_request(app): async def test_server_not_running_api_request(app):
cookies = await app.login_user("bees") cookies = await app.login_user("bees")
r = await get_page("user/bees/api/status", app, hub=False, cookies=cookies) r = await get_page("user/bees/api/status", app, hub=False, cookies=cookies)
assert r.status_code == 503 assert r.status_code == 424
assert r.headers["content-type"] == "application/json" assert r.headers["content-type"] == "application/json"
message = r.json()['message'] message = r.json()['message']
assert ujoin(app.base_url, "hub/spawn/bees") in message assert ujoin(app.base_url, "hub/spawn/bees") in message
assert " /user/bees" in message assert " /user/bees" in message
async def test_metrics_no_auth(app): async def test_server_not_running_api_request_legacy_status(app):
r = await get_page("metrics", app) app.use_legacy_stopped_server_status_code = True
assert r.status_code == 403 cookies = await app.login_user("bees")
r = await get_page("user/bees/api/status", app, hub=False, cookies=cookies)
assert r.status_code == 503
async def test_metrics_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): async def test_health_check_request(app):
@@ -1087,7 +1203,7 @@ async def test_health_check_request(app):
async def test_pre_spawn_start_exc_no_form(app): async def test_pre_spawn_start_exc_no_form(app):
exc = "pre_spawn_start error" exc = "Unhandled error starting server"
# throw exception from pre_spawn_start # throw exception from pre_spawn_start
async def mock_pre_spawn_start(user, spawner): async def mock_pre_spawn_start(user, spawner):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,12 +23,12 @@ from operator import itemgetter
from async_generator import aclosing from async_generator import aclosing
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from tornado import gen
from tornado import ioloop from tornado import ioloop
from tornado import web from tornado import web
from tornado.httpclient import AsyncHTTPClient from tornado.httpclient import AsyncHTTPClient
from tornado.httpclient import HTTPError from tornado.httpclient import HTTPError
from tornado.log import app_log from tornado.log import app_log
from tornado.platform.asyncio import to_asyncio_future
# For compatibility with python versions 3.6 or earlier. # For compatibility with python versions 3.6 or earlier.
# asyncio.Task.all_tasks() is fully moved to asyncio.all_tasks() starting with 3.9. Also applies to current_task. # asyncio.Task.all_tasks() is fully moved to asyncio.all_tasks() starting with 3.9. Also applies to current_task.
@@ -97,6 +97,10 @@ def make_ssl_context(keyfile, certfile, cafile=None, verify=True, check_hostname
return ssl_context return ssl_context
# AnyTimeoutError catches TimeoutErrors coming from asyncio, tornado, stdlib
AnyTimeoutError = (gen.TimeoutError, asyncio.TimeoutError, TimeoutError)
async def exponential_backoff( async def exponential_backoff(
pass_func, pass_func,
fail_message, fail_message,
@@ -182,7 +186,7 @@ async def exponential_backoff(
if dt < max_wait: if dt < max_wait:
scale *= scale_factor scale *= scale_factor
await asyncio.sleep(dt) await asyncio.sleep(dt)
raise TimeoutError(fail_message) raise asyncio.TimeoutError(fail_message)
async def wait_for_server(ip, port, timeout=10): async def wait_for_server(ip, port, timeout=10):
@@ -288,12 +292,39 @@ def authenticated_403(self):
raise web.HTTPError(403) raise web.HTTPError(403)
def admin_only(f):
"""Deprecated!"""
# write it this way to trigger deprecation warning at decoration time,
# not on the method call
warnings.warn(
"""@jupyterhub.utils.admin_only is deprecated in JupyterHub 2.0.
Use the new `@jupyterhub.scopes.needs_scope` decorator to resolve permissions,
or check against `self.current_user.parsed_scopes`.
""",
DeprecationWarning,
stacklevel=2,
)
# the original decorator
@auth_decorator
def admin_only(self):
"""Decorator for restricting access to admin users"""
user = self.current_user
if user is None or not user.admin:
raise web.HTTPError(403)
return admin_only(f)
@auth_decorator @auth_decorator
def metrics_authentication(self): def metrics_authentication(self):
"""Decorator for restricting access to metrics""" """Decorator for restricting access to metrics"""
user = self.current_user if not self.authenticate_prometheus:
if user is None and self.authenticate_prometheus: return
raise web.HTTPError(403) scope = 'read:metrics'
if scope not in self.parsed_scopes:
raise web.HTTPError(403, f"Access to metrics requires scope '{scope}'")
# Token utilities # Token utilities
@@ -326,7 +357,7 @@ def hash_token(token, salt=8, rounds=16384, algorithm='sha512'):
h.update(btoken) h.update(btoken)
digest = h.hexdigest() digest = h.hexdigest()
return "{algorithm}:{rounds}:{salt}:{digest}".format(**locals()) return f"{algorithm}:{rounds}:{salt}:{digest}"
def compare_token(compare, token): def compare_token(compare, token):
@@ -654,3 +685,44 @@ def catch_db_error(f):
return r return r
return catching 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", "py37",
"py38", "py38",
] ]
[tool.tbump]
# Uncomment this if your project is hosted on GitHub:
github_url = "https://github.com/jupyterhub/jupyterhub"
[tool.tbump.version]
current = "2.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""" """Get data files in share/jupyter"""
data_files = [] data_files = []
ntrim = len(here + os.path.sep)
for (d, dirs, filenames) in os.walk(share_jupyterhub): for (d, dirs, filenames) in os.walk(share_jupyterhub):
data_files.append((d[ntrim:], [pjoin(d, f) for f in filenames])) rel_d = os.path.relpath(d, here)
data_files.append((rel_d, [os.path.join(rel_d, f) for f in filenames]))
return data_files return data_files

File diff suppressed because one or more lines are too long

View File

@@ -1,24 +1,10 @@
{% extends "page.html" %} {% 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 %} {% block main %}
<div id="react-admin-hook"> <div id="react-admin-hook">
<script id="jupyterhub-admin-config"> <script id="jupyterhub-admin-config">
window.api_page_limit = parseInt("{{ api_page_limit|safe }}") window.api_page_limit = parseInt("{{ api_page_limit|safe }}")
window.base_url = "{{ base_url|safe }}"
</script> </script>
<script src="static/js/admin-react.js"></script> <script src="static/js/admin-react.js"></script>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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