Compare commits

...

247 Commits

Author SHA1 Message Date
Erik Sundell
3590d16e30 Bump to 2.2.2 2022-03-14 12:32:25 +01:00
Erik Sundell
572d258cd2 Merge pull request #3828 from consideRatio/pr/changelog-2.2.2
Add changelog for 2.2.2
2022-03-14 12:30:27 +01:00
Min RK
11d0954551 Merge pull request #3826 from consideRatio/pr/add-dedicated-jsx-tests 2022-03-14 12:27:59 +01:00
Erik Sundell
650d47d5c1 Add changelog for 2.2.2 2022-03-14 12:15:54 +01:00
Erik Sundell
945fc824d8 Remove autoformat added new line for generated file 2022-03-12 01:12:29 +01:00
Erik Sundell
a8aa737b00 Don't autoformat generated admin-react.js 2022-03-12 01:12:29 +01:00
Erik Sundell
cd689a1fab ci: test jsx in a dedicated workflow along with src->dist check 2022-03-12 01:12:29 +01:00
Erik Sundell
fbcf857991 Add inline comments to .pre-commit-config.yaml 2022-03-12 00:00:27 +01:00
Erik Sundell
6c5e5452bc Merge pull request #3825 from NarekA/narek/fix-admin-table-sorting-2
Update admin-react.js
2022-03-11 18:33:33 +01:00
pre-commit-ci[bot]
2f5ba7ba30 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-03-11 17:15:21 +00:00
Narek Amirbekian
a045eefa64 React file not updated 2022-03-11 09:13:14 -08:00
Min RK
6ea4f2af0d Bump to 2.3.0.dev 2022-03-11 17:00:28 +01:00
Min RK
3d3ad2929c Bump to 2.2.1 2022-03-11 16:59:52 +01:00
Erik Sundell
00287ff5ba Merge pull request #3824 from minrk/221
changelog for 2.2.1
2022-03-11 16:17:11 +01:00
Min RK
805d063d1d changelog for 2.2.1 2022-03-11 15:52:41 +01:00
Min RK
e6bacf7109 Merge pull request #3822 from NarekA/narek/fix-admin-table-sorting
Fix admin dashboard table sorting
2022-03-11 15:48:45 +01:00
Min RK
33ccfa7963 handle null user.server
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2022-03-11 14:16:30 +01:00
Erik Sundell
593404f558 Merge pull request #3823 from minrk/clear-cookie-kwargs
Fix clearing cookie with custom xsrf cookie options
2022-03-11 09:53:43 +01:00
Min RK
e7bc282c80 clear_cookie only accepts path, domain cookie args 2022-03-11 09:24:31 +01:00
pre-commit-ci[bot]
b939b482a1 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-03-11 06:38:13 +00:00
Narek Amirbekian
8afc2c9ae9 Fix admin table sorting 2022-03-10 22:20:01 -08:00
Chris Holdgraf
d11eda14ed Merge pull request #3820 from yuvipanda/log-docs 2022-03-10 16:38:01 -08:00
Yuvi Panda
ab79251fe2 Reword for clarity
Co-authored-by: Chris Holdgraf <choldgraf@gmail.com>
2022-03-10 15:54:42 -08:00
Yuvi Panda
484dbf48de Merge pull request #3819 from minrk/raise-no-orm-spawner
allow Spawner.server to be mocked without underlying orm_spawner
2022-03-10 13:46:49 -08:00
YuviPanda
6eb526d08a Add a little more structure 2022-03-10 13:45:28 -08:00
YuviPanda
e0a17db5f1 Add some docs on common log messages
When debugging errors and outages, looking at the logs emitted by
JupyterHub is very helpful. This document tries to document some common
log messages, and what they mean.

I currently added just one log message, but we can add more
over time.

Ref https://github.com/2i2c-org/infrastructure/issues/1081
where this would've been useful troubleshooting
2022-03-10 12:45:09 -08:00
Min RK
45132b7244 allow Spawner.server to be mocked without underlying orm_spawner 2022-03-10 15:40:01 +01:00
Min RK
c23cddeb51 Bump to 2.2.0 2022-03-07 14:35:46 +01:00
Erik Sundell
672e19a22a Merge pull request #3815 from minrk/changelog-2.2
Changelog for 2.2
2022-03-07 14:32:56 +01:00
Min RK
4a6c9c3a01 Prepare changelog for 2.2 2022-03-07 14:27:31 +01:00
Erik Sundell
2b79bc44da Merge pull request #3802 from minrk/fresh-spawner
Replace failed spawners when starting new launch
2022-03-07 14:23:22 +01:00
Min RK
7861662e17 Replace failed spawners when starting new launch
Avoids leaving stale state when re-using a spawner that failed the last time it started

we keep failed spawners around to track their errors,
but we don't want to re-use them when it comes time to start a new launch.

adds User.get_spawner(server_name, replace_failed=True) to always get a non-failed Spawner
2022-03-07 14:03:48 +01:00
Simon Li
4a1842bf8a Merge pull request #3809 from minrk/page_config_hook
Add user token to JupyterLab PageConfig
2022-03-04 21:27:34 +00:00
Min RK
8f18303e50 fix some links revealed by myst
mostly pre-myst markdown links
2022-03-04 10:41:20 +01:00
Min RK
bcad6e287d Merge pull request #3812 from ktaletsk/patch-1
Update example to not reference an undefined scope
2022-03-04 10:03:53 +01:00
Min RK
9de1951952 Merge pull request #3813 from rzo1/apache-sec
Apache2 Documentation: Updates Reverse Proxy Configuration (TLS/SSL, Protocols, Headers)
2022-03-04 10:03:01 +01:00
pre-commit-ci[bot]
99cb1f17f0 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-03-03 15:41:17 +00:00
Richard Zowalla
10d5157e95 Apache2 Documentation: Updates Reverse Proxy Configuration (TLS/SSL, Protocols, Headers) 2022-03-03 16:40:38 +01:00
Konstantin Taletskiy
2fc4f26832 Update example to not reference an undefined scope
Fixes #3811
2022-03-01 12:25:54 -08:00
Simon Li
f6230001bb Merge pull request #3810 from minrk/server-trait
Keep Spawner.server in sync with underlying orm_spawner.server
2022-03-01 17:40:33 +00:00
Min RK
960f7cbeb9 Keep Spawner.server in sync with underlying orm_spawner.server
Rather than one-time sets of ._server allowing it to become out-of-sync
with underlying orm_spawner.server
2022-03-01 15:59:16 +01:00
Erik Sundell
76f06a6b55 Merge pull request #3808 from manics/apache-x-forwarded-proto
Apache: set X-Forwarded-Proto header
2022-03-01 14:14:34 +01:00
Min RK
9c498aa5d4 Document HubOAuth.get_token for requests on behalf of users 2022-03-01 10:05:17 +01:00
Min RK
a0b60f9118 place JupyterHub token in JupyterLab PageConfig
restores token field useful for javascript-originating API requests,
removed in 1.5 / 2.0 for security reasons because it was the wrong token.

This places the _user's_ token in PageConfig,
so it should have the right permissions.

requires jupyterlab_server 2.9, has no effect on earlier versions.
2022-03-01 09:45:14 +01:00
Min RK
27cb56429b HubAuth.get_token returns oauth token stored in cookie
Useful for backend services that want to use the user's token.

Added `in_cookie` bool argument to exclude cookies (previous behavior),
since notebook servers do some things differently when auth is in query param or header vs cookies
2022-03-01 09:43:01 +01:00
Simon Li
b1ffd4b10b Apache: set X-Forwarded-Proto header 2022-02-28 21:46:53 +00:00
dependabot[bot]
a9ea064202 Merge pull request #3807 from jupyterhub/dependabot/npm_and_yarn/jsx/url-parse-1.5.10 2022-02-28 09:56:10 +00:00
dependabot[bot]
687a41a467 Bump url-parse from 1.5.7 to 1.5.10 in /jsx
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.7 to 1.5.10.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.7...1.5.10)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-28 04:27:03 +00:00
Erik Sundell
5348451b2e Merge pull request #3803 from tmtabor/patch-1
idle-culler example config missing closing bracket
2022-02-22 23:09:15 +01:00
Thorin Tabor
55f0579dcc idle-culler example config missing closing bracket 2022-02-22 13:16:37 -08:00
Simon Li
a3ea0f0449 Merge pull request #3799 from jupyterhub/dependabot/npm_and_yarn/jsx/url-parse-1.5.7
Bump url-parse from 1.5.3 to 1.5.7 in /jsx
2022-02-19 08:11:52 +00:00
dependabot[bot]
78492a4a8e Bump url-parse from 1.5.3 to 1.5.7 in /jsx
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.3 to 1.5.7.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.3...1.5.7)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-19 07:35:42 +00:00
Min RK
f22203f50e Merge pull request #3793 from satra/patch-1
show insecure-login-warning for all authenticators
2022-02-15 11:24:09 +01:00
Simon Li
500b354a00 Merge pull request #3795 from jupyterhub/dependabot/npm_and_yarn/jsx/follow-redirects-1.14.8
Bump follow-redirects from 1.14.7 to 1.14.8 in /jsx
2022-02-14 09:52:27 +00:00
dependabot[bot]
9d4093782f Bump follow-redirects from 1.14.7 to 1.14.8 in /jsx
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.7 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.7...v1.14.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-14 08:52:00 +00:00
Min RK
43b3cebfff Merge pull request #3791 from rcthomas/async-options
Enable options_from_form(spawner, form_data) from configuration file
2022-02-14 09:45:04 +01:00
Min RK
63c381431d Merge pull request #3787 from minrk/stop_open_session_default
Stop opening PAM sessions by default
2022-02-14 09:43:12 +01:00
Min RK
bf41767b33 Merge pull request #3790 from NarekA/narek/admin-named-servers
Add Missing Features In Admin Console
2022-02-14 09:25:25 +01:00
Satrajit Ghosh
83d6e4e993 fix: insecure-login-warning for all authenticators 2022-02-11 22:19:39 -05:00
pre-commit-ci[bot]
d64a2ddd95 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-02-11 21:11:10 +00:00
Rollin Thomas
392176d873 Add tests for both forms of options_from_form 2022-02-11 12:55:09 -08:00
Narek Amirbekian
58420b3307 Merge remote-tracking branch 'origin' into narek/admin-named-servers 2022-02-11 10:52:21 -08:00
Narek Amirbekian
a5e3b66dee One edit user button per user 2022-02-11 10:50:58 -08:00
Rollin Thomas
a9fbe5c9f6 Enable options_from_form w/spawner from configuration 2022-02-11 10:06:38 -08:00
Erik Sundell
71bbbe4a67 Merge pull request #3792 from minrk/short-circuit
short-circuit token permission check if token and owner share role
2022-02-11 17:36:07 +01:00
Min RK
3843885382 short-circuit token permission check if token and owner share role
No need to compute intersection when we know it's a subset already
2022-02-11 15:20:14 +01:00
Narek Amirbekian
25ea559e0d Pull out button components 2022-02-09 15:21:12 -08:00
Narek Amirbekian
c18815de91 Fix failing tests 2022-02-09 14:04:38 -08:00
Narek Amirbekian
50d53667ce Add start server back 2022-02-09 13:15:27 -08:00
pre-commit-ci[bot]
68e2baf4aa [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-02-09 21:04:22 +00:00
Narek Amirbekian
6fc9d40e51 Merge remote-tracking branch 'origin/narek/admin-named-servers' into narek/admin-named-servers 2022-02-09 13:02:19 -08:00
Narek Amirbekian
0b25694b40 Add "spawn new" button 2022-02-09 12:59:34 -08:00
pre-commit-ci[bot]
bf750e488f [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-02-09 19:58:05 +00:00
Narek Amirbekian
359f9055fc Admin missing features 2022-02-09 11:43:06 -08:00
Min RK
b84dd5d735 Stop opening PAM sessions by default
We don't do it correctly, so don't try by default

It does work _sometimes_, but most of the time it does work, it's because it's a no-op.
Turning it off by default makes it more likely folks will see the caveat that it may not work.
2022-02-07 15:45:38 +01:00
Erik Sundell
3ed345f496 Merge pull request #3784 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-01-31 23:57:45 +01:00
pre-commit-ci[bot]
6633f8ef28 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-01-31 22:17:11 +00:00
pre-commit-ci[bot]
757053a9ec [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/reorder_python_imports: v2.6.0 → v2.7.1](https://github.com/asottile/reorder_python_imports/compare/v2.6.0...v2.7.1)
- [github.com/psf/black: 21.12b0 → 22.1.0](https://github.com/psf/black/compare/21.12b0...22.1.0)
2022-01-31 22:16:31 +00:00
Erik Sundell
36cad38ddf Merge pull request #3781 from cqzlxl/cqzlxl-patch-1
Log proxy's public_url only when started by JupyterHub
2022-01-29 09:16:29 +01:00
pre-commit-ci[bot]
1e9a1cb621 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-01-29 05:59:52 +00:00
cqzlxl
9f051d3172 Update jupyterhub/app.py
Co-authored-by: Min RK <benjaminrk@gmail.com>
2022-01-29 13:59:20 +08:00
cqzlxl
53576c8f82 Update app.py
When we run the proxy separately,  defaults of `hub.bind_url` may be different from proxy's public url. Actually, the hub has no ways to know about which address the proxy is serving at if we do not configure its `bind_url` explicitly.
2022-01-27 21:05:05 +08:00
Min RK
bb5ec39b2f Merge pull request #3548 from C4IROcean/authenticator_user_group_management
Authenticator user group management
2022-01-25 14:36:41 +01:00
Min RK
4c54c6dcc8 Bump to 2.2.0.dev 2022-01-25 14:36:24 +01:00
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
Min RK
88be7a9967 test coverage for Authenticator.managed_groups
- tests
- docs
- ensure all group APIs are rejected when auth is in control
- use 'groups' field in return value of authenticate/refresh_user, instead of defining new method
- log group changes in sync_groups
2022-01-24 13:45:35 +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
Thomas Li Fredriksen
144abcb965 Added authenticator hook for synchronizing user groups
- Added hook function stub to authenticator base class
- Added new config option `manage_groups` to base `Authenticator` class
- Call authenticator hook from `refresh_auth`-function in `Base` handler class
- Added example
2022-01-20 13:30:03 +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
90 changed files with 5402 additions and 2123 deletions

View File

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

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/

108
.github/workflows/test-jsx.yml vendored Normal file
View File

@@ -0,0 +1,108 @@
# This is a GitHub workflow defining a set of jobs with a set of steps.
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
#
name: Test jsx (admin-react.js)
on:
pull_request:
paths:
- "jsx/**"
- ".github/workflows/test-jsx.yml"
push:
paths:
- "jsx/**"
- ".github/workflows/test-jsx.yml"
branches-ignore:
- "dependabot/**"
- "pre-commit-ci-update-config"
tags:
- "**"
workflow_dispatch:
jobs:
# The ./jsx folder contains React based source code files that are to compile
# to share/jupyterhub/static/js/admin-react.js. The ./jsx folder includes
# tests also has tests that this job is meant to run with `yarn test`
# according to the documentation in jsx/README.md.
test-jsx-admin-react:
runs-on: ubuntu-20.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: "14"
- name: Install yarn
run: |
npm install -g yarn
- name: yarn
run: |
cd jsx
yarn
- name: yarn test
run: |
cd jsx
yarn test
# The ./jsx folder contains React based source files that are to compile to
# share/jupyterhub/static/js/admin-react.js. This job makes sure that whatever
# we have in jsx/src matches the compiled asset that we package and
# distribute.
#
# This job's purpose is to make sure we don't forget to compile changes and to
# verify nobody sneaks in a change in the hard to review compiled asset.
#
# NOTE: In the future we may want to stop version controlling the compiled
# artifact and instead generate it whenever we package JupyterHub. If we
# do this, we are required to setup node and compile the source code
# more often, at the same time we could avoid having this check be made.
#
compile-jsx-admin-react:
runs-on: ubuntu-20.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: "14"
- name: Install yarn
run: |
npm install -g yarn
- name: yarn
run: |
cd jsx
yarn
- name: yarn build
run: |
cd jsx
yarn build
- name: yarn place
run: |
cd jsx
yarn place
- name: Verify compiled jsx/src matches version controlled artifact
run: |
if [[ `git status --porcelain=v1` ]]; then
echo "The source code in ./jsx compiles to something different than found in ./share/jupyterhub/static/js/admin-react.js!"
echo
echo "Please re-compile the source code in ./jsx with the following commands:"
echo
echo "yarn"
echo "yarn build"
echo "yarn place"
echo
echo "See ./jsx/README.md for more details."
exit 1
else
echo "Compilation of jsx/src to share/jupyterhub/static/js/admin-react.js didn't lead to changes."
fi

View File

@@ -1,14 +1,28 @@
# This is a GitHub workflow defining a set of jobs with a set of steps.
# ref: https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
#
name: Test
# Trigger the workflow's on all PRs but only on pushed tags or commits to
# main/master branch to avoid PRs developed in a GitHub fork's dedicated branch
# to trigger.
on:
pull_request:
paths-ignore:
- "docs/**"
- "**.md"
- "**.rst"
- ".github/workflows/*"
- "!.github/workflows/test.yml"
push:
paths-ignore:
- "docs/**"
- "**.md"
- "**.rst"
- ".github/workflows/*"
- "!.github/workflows/test.yml"
branches-ignore:
- "dependabot/**"
- "pre-commit-ci-update-config"
tags:
- "**"
workflow_dispatch:
env:
@@ -17,29 +31,10 @@ env:
PYTEST_ADDOPTS: "--verbose --color=yes"
jobs:
rest-api:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Validate REST API
uses: char0n/swagger-editor-validate@182d1a5d26ff5c2f4f452c43bd55e2c7d8064003
with:
definition-file: docs/source/_static/rest-api.yml
- uses: actions/setup-python@v2
with:
python-version: "3.9"
# in addition to the doc requirements
# the docs *tests* require pre-commit and pytest
- run: |
pip install -r docs/requirements.txt pytest pre-commit -e .
- run: |
pytest docs/
# Run "pytest jupyterhub/tests" in various configurations
pytest:
runs-on: ubuntu-20.04
timeout-minutes: 10
timeout-minutes: 15
strategy:
# Keep running even if one variation of the job fail
@@ -126,7 +121,6 @@ jobs:
run: |
npm install
npm install -g configurable-http-proxy
npm install -g yarn
npm list
# NOTE: actions/setup-python@v2 make use of a cache within the GitHub base
@@ -189,14 +183,18 @@ jobs:
if: ${{ matrix.db }}
run: |
if [ "${{ matrix.db }}" == "mysql" ]; then
if [[ -z "$(which mysql)" ]]; then
sudo apt-get update
sudo apt-get install -y mysql-client
fi
DB=mysql bash ci/docker-db.sh
DB=mysql bash ci/init-db.sh
fi
if [ "${{ matrix.db }}" == "postgres" ]; then
if [[ -z "$(which psql)" ]]; then
sudo apt-get update
sudo apt-get install -y postgresql-client
fi
DB=postgres bash ci/docker-db.sh
DB=postgres bash ci/init-db.sh
fi
@@ -204,9 +202,6 @@ jobs:
- name: Run pytest
run: |
pytest --maxfail=2 --cov=jupyterhub jupyterhub/tests
- name: Run yarn jest test
run: |
cd jsx && yarn && yarn test
- name: Submit codecov report
run: |
codecov

View File

@@ -1,30 +1,53 @@
# pre-commit is a tool to perform a predefined set of tasks manually and/or
# automatically before git commits are made.
#
# Config reference: https://pre-commit.com/#pre-commit-configyaml---top-level
#
# Common tasks
#
# - Run on all files: pre-commit run --all-files
# - Register git hooks: pre-commit install --install-hooks
#
repos:
# Autoformat: Python code, syntax patterns are modernized
- repo: https://github.com/asottile/pyupgrade
rev: v2.29.0
rev: v2.31.0
hooks:
- id: pyupgrade
args:
- --py36-plus
# Autoformat: Python code
- repo: https://github.com/asottile/reorder_python_imports
rev: v2.6.0
rev: v2.7.1
hooks:
- id: reorder-python-imports
# Autoformat: Python code
- repo: https://github.com/psf/black
rev: 21.10b0
rev: 22.1.0
hooks:
- id: black
args: [--target-version=py36]
# Autoformat: markdown, yaml, javascript (see the file .prettierignore)
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.4.1
rev: v2.5.1
hooks:
- id: prettier
# Autoformat and linting, misc. details
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0
hooks:
- id: end-of-file-fixer
exclude: share/jupyterhub/static/js/admin-react.js
- id: requirements-txt-fixer
- id: check-case-conflict
- id: check-executables-have-shebangs
# Linting: Python code (see the file .flake8)
- repo: https://github.com/PyCQA/flake8
rev: "4.0.1"
hooks:
- id: flake8
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
hooks:
- id: end-of-file-fixer
- id: check-case-conflict
- id: check-executables-have-shebangs
- id: requirements-txt-fixer

View File

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

View File

@@ -117,8 +117,7 @@ To start the Hub server, run the command:
jupyterhub
Visit `https://localhost:8000` in your browser, and sign in with your unix
PAM credentials.
Visit `http://localhost:8000` in your browser, and sign in with your system username and password.
_Note_: To allow multiple users to sign in to the server, you will need to
run the `jupyterhub` command as a _privileged user_, such as root.

View File

@@ -3,8 +3,10 @@
alabaster_jupyterhub
autodoc-traits
myst-parser
pre-commit
pydata-sphinx-theme
pytablewriter>=0.56
ruamel.yaml
sphinx>=1.7
sphinx-copybutton
sphinx-jsonschema

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
# Common log messages emitted by JupyterHub
When debugging errors and outages, looking at the logs emitted by
JupyterHub is very helpful. This document tries to document some common
log messages, and what they mean.
## Failing suspected API request to not-running server
### Example
Your logs might be littered with lines that might look slightly scary
```
[W 2022-03-10 17:25:19.774 JupyterHub base:1349] Failing suspected API request to not-running server: /hub/user/<user-name>/api/metrics/v1
```
### Most likely cause
This likely means is that the user's server has stopped running but they
still have a browser tab open. For example, you might have 3 tabs open, and shut
your server down via one. Or you closed your laptop, your server was
culled for inactivity, and then you reopen your laptop again! The
client side code (JupyterLab, Classic Notebook, etc) does not know
yet that the server is dead, and continues to make some API requests.
JupyterHub's architecture means that the proxy routes all requests that
don't go to a running user server to the hub process itself. The hub
process then explicitly returns a failure response, so the client knows
that the server is not running anymore. This is used by JupyterLab to
tell you your server is not running anymore, and offer you the option
to let you restart it.
Most commonly, you'll see this in reference to the `/api/metrics/v1`
URL, used by [jupyter-resource-usage](https://github.com/jupyter-server/jupyter-resource-usage).
### Actions you can take
This log message is benign, and there is usually no action for you to take.

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -21,6 +21,7 @@ extensions = [
'myst_parser',
]
myst_heading_anchors = 2
myst_enable_extensions = [
'colon_fence',
'deflist',
@@ -130,6 +131,30 @@ html_static_path = ['_static']
htmlhelp_basename = 'JupyterHubdoc'
html_theme_options = {
"icon_links": [
{
"name": "GitHub",
"url": "https://github.com/jupyterhub/jupyterhub",
"icon": "fab fa-github-square",
},
{
"name": "Discourse",
"url": "https://discourse.jupyter.org/c/jupyterhub/10",
"icon": "fab fa-discourse",
},
],
"use_edit_page_button": True,
"navbar_align": "left",
}
html_context = {
"github_user": "jupyterhub",
"github_repo": "jupyterhub",
"github_version": "main",
"doc_path": "docs",
}
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
@@ -205,7 +230,10 @@ epub_exclude_files = ['search.html']
# -- Intersphinx ----------------------------------------------------------
intersphinx_mapping = {'https://docs.python.org/3/': None}
intersphinx_mapping = {
'python': ('https://docs.python.org/3/', None),
'tornado': ('https://www.tornadoweb.org/en/stable/', None),
}
# -- Read The Docs --------------------------------------------------------

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -10,4 +10,5 @@ well as other information relevant to running your own JupyterHub over time.
troubleshooting
admin/upgrading
admin/log-messages
changelog

View File

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

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

@@ -1,6 +1,6 @@
# Authenticators
The [Authenticator][] is the mechanism for authorizing users to use the
The {class}`.Authenticator` is the mechanism for authorizing users to use the
Hub and single user notebook servers.
## The default PAM Authenticator
@@ -137,8 +137,8 @@ via other mechanisms. One such example is using [GitHub OAuth][].
Because the username is passed from the Authenticator to the Spawner,
a custom Authenticator and Spawner are often used together.
For example, the Authenticator methods, [pre_spawn_start(user, spawner)][]
and [post_spawn_stop(user, spawner)][], are hooks that can be used to do
For example, the Authenticator methods, {meth}`.Authenticator.pre_spawn_start`
and {meth}`.Authenticator.post_spawn_stop`, are hooks that can be used to do
auth-related startup (e.g. opening PAM sessions) and cleanup
(e.g. closing PAM sessions).
@@ -223,7 +223,7 @@ If there are multiple keys present, the **first** key is always used to persist
Typically, if `auth_state` is persisted it is desirable to affect the Spawner environment in some way.
This may mean defining environment variables, placing certificate in the user's home directory, etc.
The `Authenticator.pre_spawn_start` method can be used to pass information from authenticator state
The {meth}`Authenticator.pre_spawn_start` method can be used to pass information from authenticator state
to Spawner environment:
```python
@@ -247,10 +247,42 @@ class MyAuthenticator(Authenticator):
spawner.environment['UPSTREAM_TOKEN'] = auth_state['upstream_token']
```
(authenticator-groups)=
## Authenticator-managed group membership
:::{versionadded} 2.2
:::
Some identity providers may have their own concept of group membership that you would like to preserve in JupyterHub.
This is now possible with `Authenticator.managed_groups`.
You can set the config:
```python
c.Authenticator.manage_groups = True
```
to enable this behavior.
The default is False for Authenticators that ship with JupyterHub,
but may be True for custom Authenticators.
Check your Authenticator's documentation for manage_groups support.
If True, {meth}`.Authenticator.authenticate` and {meth}`.Authenticator.refresh_user` may include a field `groups`
which is a list of group names the user should be a member of:
- Membership will be added for any group in the list
- Membership in any groups not in the list will be revoked
- Any groups not already present in the database will be created
- If `None` is returned, no changes are made to the user's group membership
If authenticator-managed groups are enabled,
all group-management via the API is disabled.
## pre_spawn_start and post_spawn_stop hooks
Authenticators uses two hooks, [pre_spawn_start(user, spawner)][] and
[post_spawn_stop(user, spawner)][] to add pass additional state information
Authenticators uses two hooks, {meth}`.Authenticator.pre_spawn_start` and
{meth}`.Authenticator.post_spawn_stop(user, spawner)` to add pass additional state information
between the authenticator and a spawner. These hooks are typically used auth-related
startup, i.e. opening a PAM session, and auth-related cleanup, i.e. closing a
PAM session.
@@ -259,10 +291,7 @@ PAM session.
Beginning with version 0.8, JupyterHub is an OAuth provider.
[authenticator]: https://github.com/jupyterhub/jupyterhub/blob/HEAD/jupyterhub/auth.py
[pam]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
[oauth]: https://en.wikipedia.org/wiki/OAuth
[github oauth]: https://developer.github.com/v3/oauth/
[oauthenticator]: https://github.com/jupyterhub/oauthenticator
[pre_spawn_start(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.pre_spawn_start
[post_spawn_stop(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.post_spawn_stop

View File

@@ -165,7 +165,7 @@ As with nginx above, you can use [Apache](https://httpd.apache.org) as the rever
First, we will need to enable the apache modules that we are going to need:
```bash
a2enmod ssl rewrite proxy proxy_http proxy_wstunnel
a2enmod ssl rewrite proxy headers proxy_http proxy_wstunnel
```
Our Apache configuration is equivalent to the nginx configuration above:
@@ -188,13 +188,24 @@ Listen 443
ServerName HUB.DOMAIN.TLD
# enable HTTP/2, if available
Protocols h2 http/1.1
# HTTP Strict Transport Security (mod_headers is required) (63072000 seconds)
Header always set Strict-Transport-Security "max-age=63072000"
# configure SSL
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
SSLProtocol All -SSLv2 -SSLv3
SSLOpenSSLConfCmd DHParameters /etc/ssl/certs/dhparam.pem
SSLCipherSuite EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH
# intermediate configuration from ssl-config.mozilla.org (2022-03-03)
# Please note, that this configuration might be out-dated - please update it accordingly using https://ssl-config.mozilla.org/
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
SSLHonorCipherOrder off
SSLSessionTickets off
# Use RewriteEngine to handle websocket connection upgrades
RewriteEngine On
@@ -208,6 +219,7 @@ Listen 443
# proxy to JupyterHub
ProxyPass http://127.0.0.1:8000/
ProxyPassReverse http://127.0.0.1:8000/
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
</Location>
</VirtualHost>
```

View File

@@ -21,6 +21,7 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
monitoring
database
templates
api-only
../events/index
config-user-env
config-examples

View File

@@ -5,7 +5,7 @@ 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@3/swagger-ui-bundle.js"></script>
<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 -->

View File

@@ -1,3 +1,5 @@
(rest-api)=
# Using JupyterHub's REST API
This section will give you information on:
@@ -111,7 +113,6 @@ c.JupyterHub.load_roles = [
"scopes": [
# specify the permissions the token should have
"admin:users",
"admin:services",
],
"services": [
# assign the service the above permissions

View File

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

View File

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

View File

@@ -275,7 +275,7 @@ where `ssl_cert` is example-chained.crt and ssl_key to your private key.
Then restart JupyterHub.
See also [JupyterHub SSL encryption](./getting-started/security-basics.html#ssl-encryption).
See also {ref}`ssl-encryption`.
### Install JupyterHub without a network connection

View File

@@ -10,7 +10,9 @@ here = Path(__file__).absolute().parent
root = here.parent
def test_rest_api_version():
def test_rest_api_version_is_updated():
"""Checks that the version in JupyterHub's REST API definition file
(rest-api.yml) is matching the JupyterHub version."""
version_py = root.joinpath("jupyterhub", "_version.py")
rest_api_yaml = root.joinpath("docs", "source", "_static", "rest-api.yml")
ns = {}
@@ -25,18 +27,17 @@ def test_rest_api_version():
assert jupyterhub_version == rest_api_version
def test_restapi_scopes():
def test_rest_api_rbac_scope_descriptions_are_updated():
"""Checks that the RBAC scope descriptions in JupyterHub's REST API
definition file (rest-api.yml) as can be updated by generate-scope-table.py
matches what is committed."""
run([sys.executable, "source/rbac/generate-scope-table.py"], cwd=here, check=True)
run(
['pre-commit', 'run', 'prettier', '--files', 'source/_static/rest-api.yml'],
cwd=here,
check=False,
)
run(
[
"git",
"diff",
"--no-pager",
"diff",
"--color=always",
"--exit-code",
str(here.joinpath("source", "_static", "rest-api.yml")),
],

View File

@@ -0,0 +1,30 @@
"""sample jupyterhub config file for testing
configures jupyterhub with dummyauthenticator and simplespawner
to enable testing without administrative privileges.
"""
c = get_config() # noqa
c.Application.log_level = 'DEBUG'
from oauthenticator.azuread import AzureAdOAuthenticator
import os
c.JupyterHub.authenticator_class = AzureAdOAuthenticator
c.AzureAdOAuthenticator.client_id = os.getenv("AAD_CLIENT_ID")
c.AzureAdOAuthenticator.client_secret = os.getenv("AAD_CLIENT_SECRET")
c.AzureAdOAuthenticator.oauth_callback_url = os.getenv("AAD_CALLBACK_URL")
c.AzureAdOAuthenticator.tenant_id = os.getenv("AAD_TENANT_ID")
c.AzureAdOAuthenticator.username_claim = "email"
c.AzureAdOAuthenticator.authorize_url = os.getenv("AAD_AUTHORIZE_URL")
c.AzureAdOAuthenticator.token_url = os.getenv("AAD_TOKEN_URL")
c.Authenticator.manage_groups = True
c.Authenticator.refresh_pre_spawn = True
# Optionally set a global password that all users must use
# c.DummyAuthenticator.password = "your_password"
from jupyterhub.spawner import SimpleLocalProcessSpawner
c.JupyterHub.spawner_class = SimpleLocalProcessSpawner

View File

@@ -0,0 +1,2 @@
oauthenticator
pyjwt

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,14 @@ import "./server-dashboard.css";
import { timeSince } from "../../util/timeSince";
import PaginationFooter from "../PaginationFooter/PaginationFooter";
const AccessServerButton = ({ userName, serverName }) => (
<a href={`/user/${userName}/${serverName || ""}`}>
<button className="btn btn-primary btn-xs" style={{ marginRight: 20 }}>
Access Server
</button>
</a>
);
const ServerDashboard = (props) => {
// sort methods
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
@@ -27,7 +35,9 @@ const ServerDashboard = (props) => {
runningAsc = (e) => e.sort((a) => (a.server == null ? -1 : 1)),
runningDesc = (e) => e.sort((a) => (a.server == null ? 1 : -1));
var [errorAlert, setErrorAlert] = useState(null);
var [sortMethod, setSortMethod] = useState(null);
var [disabledButtons, setDisabledButtons] = useState({});
var user_data = useSelector((state) => state.user_data),
user_page = useSelector((state) => state.user_page),
@@ -60,7 +70,7 @@ const ServerDashboard = (props) => {
};
if (!user_data) {
return <div></div>;
return <div data-testid="no-show"></div>;
}
if (page != user_page) {
@@ -71,8 +81,128 @@ const ServerDashboard = (props) => {
user_data = sortMethod(user_data);
}
const StopServerButton = ({ serverName, userName }) => {
var [isDisabled, setIsDisabled] = useState(false);
return (
<button
className="btn btn-danger btn-xs stop-button"
disabled={isDisabled}
onClick={() => {
setIsDisabled(true);
stopServer(userName, serverName)
.then((res) => {
if (res.status < 300) {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
})
.catch(() => {
setIsDisabled(false);
setErrorAlert(`Failed to update users list.`);
});
} else {
setErrorAlert(`Failed to stop server.`);
setIsDisabled(false);
}
return res;
})
.catch(() => {
setErrorAlert(`Failed to stop server.`);
setIsDisabled(false);
});
}}
>
Stop Server
</button>
);
};
const StartServerButton = ({ serverName, userName }) => {
var [isDisabled, setIsDisabled] = useState(false);
return (
<button
className="btn btn-success btn-xs start-button"
disabled={isDisabled}
onClick={() => {
setIsDisabled(true);
startServer(userName, serverName)
.then((res) => {
if (res.status < 300) {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
})
.catch(() => {
setErrorAlert(`Failed to update users list.`);
setIsDisabled(false);
});
} else {
setErrorAlert(`Failed to start server.`);
setIsDisabled(false);
}
return res;
})
.catch(() => {
setErrorAlert(`Failed to start server.`);
setIsDisabled(false);
});
}}
>
Start Server
</button>
);
};
const EditUserCell = ({ user }) => {
return (
<td>
<button
className="btn btn-primary btn-xs"
style={{ marginRight: 20 }}
onClick={() =>
history.push({
pathname: "/edit-user",
state: {
username: user.name,
has_admin: user.admin,
},
})
}
>
Edit User
</button>
</td>
);
};
let servers = user_data.flatMap((user) => {
let userServers = Object.values({
"": user.server || {},
...(user.servers || {}),
});
return userServers.map((server) => [user, server]);
});
return (
<div className="container">
<div className="container" data-testid="container">
{errorAlert != null ? (
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">
{errorAlert}
<button
type="button"
className="close"
onClick={() => setErrorAlert(null)}
>
<span>&times;</span>
</button>
</div>
</div>
</div>
) : (
<></>
)}
<div className="manage-groups" style={{ float: "right", margin: "20px" }}>
<Link to="/groups">{"> Manage Groups"}</Link>
</div>
@@ -85,6 +215,7 @@ const ServerDashboard = (props) => {
<SortHandler
sorts={{ asc: usernameAsc, desc: usernameDesc }}
callback={(method) => setSortMethod(() => method)}
testid="user-sort"
/>
</th>
<th id="admin-header">
@@ -92,6 +223,15 @@ const ServerDashboard = (props) => {
<SortHandler
sorts={{ asc: adminAsc, desc: adminDesc }}
callback={(method) => setSortMethod(() => method)}
testid="admin-sort"
/>
</th>
<th id="server-header">
Server{" "}
<SortHandler
sorts={{ asc: usernameAsc, desc: usernameDesc }}
callback={(method) => setSortMethod(() => method)}
testid="server-sort"
/>
</th>
<th id="last-activity-header">
@@ -99,6 +239,7 @@ const ServerDashboard = (props) => {
<SortHandler
sorts={{ asc: dateAsc, desc: dateDesc }}
callback={(method) => setSortMethod(() => method)}
testid="last-activity-sort"
/>
</th>
<th id="running-status-header">
@@ -106,6 +247,7 @@ const ServerDashboard = (props) => {
<SortHandler
sorts={{ asc: runningAsc, desc: runningDesc }}
callback={(method) => setSortMethod(() => method)}
testid="running-status-sort"
/>
</th>
<th id="actions-header">Actions</th>
@@ -125,17 +267,33 @@ const ServerDashboard = (props) => {
<Button
variant="primary"
className="start-all"
data-testid="start-all"
onClick={() => {
Promise.all(startAll(user_data.map((e) => e.name)))
.then((res) => {
let failedServers = res.filter((e) => !e.ok);
if (failedServers.length > 0) {
setErrorAlert(
`Failed to start ${failedServers.length} ${
failedServers.length > 1 ? "servers" : "server"
}. ${
failedServers.length > 1 ? "Are they " : "Is it "
} already running?`
);
}
return res;
})
.then((res) => {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
})
.catch((err) => console.log(err));
.catch(() =>
setErrorAlert(`Failed to update users list.`)
);
return res;
})
.catch((err) => console.log(err));
.catch(() => setErrorAlert(`Failed to start servers.`));
}}
>
Start All
@@ -145,17 +303,33 @@ const ServerDashboard = (props) => {
<Button
variant="danger"
className="stop-all"
data-testid="stop-all"
onClick={() => {
Promise.all(stopAll(user_data.map((e) => e.name)))
.then((res) => {
let failedServers = res.filter((e) => !e.ok);
if (failedServers.length > 0) {
setErrorAlert(
`Failed to stop ${failedServers.length} ${
failedServers.length > 1 ? "servers" : "server"
}. ${
failedServers.length > 1 ? "Are they " : "Is it "
} already stopped?`
);
}
return res;
})
.then((res) => {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
})
.catch((err) => console.log(err));
.catch(() =>
setErrorAlert(`Failed to update users list.`)
);
return res;
})
.catch((err) => console.log(err));
.catch(() => setErrorAlert(`Failed to stop servers.`));
}}
>
Stop All
@@ -172,70 +346,66 @@ const ServerDashboard = (props) => {
</Button>
</td>
</tr>
{user_data.map((e, i) => (
<tr key={i + "row"} className="user-row">
<td>{e.name}</td>
<td>{e.admin ? "admin" : ""}</td>
<td>
{e.last_activity ? timeSince(e.last_activity) : "Never"}
</td>
<td>
{e.server != null ? (
// Stop Single-user server
<button
className="btn btn-danger btn-xs stop-button"
onClick={() =>
stopServer(e.name)
.then((res) => {
updateUsers(...slice).then((data) => {
dispatchPageUpdate(data, page);
});
return res;
})
.catch((err) => console.log(err))
}
>
Stop Server
</button>
) : (
// Start Single-user server
<button
className="btn btn-primary btn-xs start-button"
onClick={() =>
startServer(e.name)
.then((res) => {
updateUsers(...slice).then((data) => {
dispatchPageUpdate(data, page);
});
return res;
})
.catch((err) => console.log(err))
}
>
Start Server
</button>
)}
</td>
<td>
{/* Edit User */}
<button
className="btn btn-primary btn-xs"
style={{ marginRight: 20 }}
onClick={() =>
history.push({
pathname: "/edit-user",
state: {
username: e.name,
has_admin: e.admin,
},
})
}
>
edit user
</button>
</td>
</tr>
))}
{servers.map(([user, server], i) => {
server.name = server.name || "";
return (
<tr key={i + "row"} className="user-row">
<td data-testid="user-row-name">{user.name}</td>
<td data-testid="user-row-admin">
{user.admin ? "admin" : ""}
</td>
<td data-testid="user-row-server">
{server.name ? (
<p class="text-secondary">{server.name}</p>
) : (
<p style={{ color: "lightgrey" }}>[MAIN]</p>
)}
</td>
<td data-testid="user-row-last-activity">
{server.last_activity
? timeSince(server.last_activity)
: "Never"}
</td>
<td data-testid="user-row-server-activity">
{server.started ? (
// Stop Single-user server
<>
<StopServerButton
serverName={server.name}
userName={user.name}
/>
<AccessServerButton
serverName={server.name}
userName={user.name}
/>
</>
) : (
// Start Single-user server
<>
<StartServerButton
serverName={server.name}
userName={user.name}
/>
<a
href={`/spawn/${user.name}${
server.name && "/" + server.name
}`}
>
<button
className="btn btn-secondary btn-xs"
style={{ marginRight: 20 }}
>
Spawn Page
</button>
</a>
</>
)}
</td>
<EditUserCell user={user} />
</tr>
);
})}
</tbody>
</table>
<PaginationFooter
@@ -269,13 +439,14 @@ ServerDashboard.propTypes = {
};
const SortHandler = (props) => {
var { sorts, callback } = props;
var { sorts, callback, testid } = props;
var [direction, setDirection] = useState(undefined);
return (
<div
className="sort-icon"
data-testid={testid}
onClick={() => {
if (!direction) {
callback(sorts.desc);
@@ -303,6 +474,7 @@ const SortHandler = (props) => {
SortHandler.propTypes = {
sorts: PropTypes.object,
callback: PropTypes.func,
testid: PropTypes.string,
};
export default ServerDashboard;

View File

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

View File

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

View File

@@ -11,8 +11,10 @@ const withAPI = withProps(() => ({
(data) => data.json()
),
shutdownHub: () => jhapiRequest("/shutdown", "POST"),
startServer: (name) => jhapiRequest("/users/" + name + "/server", "POST"),
stopServer: (name) => jhapiRequest("/users/" + name + "/server", "DELETE"),
startServer: (name, serverName = "") =>
jhapiRequest("/users/" + name + "/servers/" + (serverName || ""), "POST"),
stopServer: (name, serverName = "") =>
jhapiRequest("/users/" + name + "/servers/" + (serverName || ""), "DELETE"),
startAll: (names) =>
names.map((e) => jhapiRequest("/users/" + e + "/server", "POST")),
stopAll: (names) =>
@@ -36,13 +38,14 @@ const withAPI = withProps(() => ({
jhapiRequest("/users/" + username, "GET")
.then((data) => data.status)
.then((data) => (data > 200 ? false : true)),
failRegexEvent: () =>
alert(
"Cannot change username - either contains special characters or is too short."
),
noChangeEvent: () => {
returns;
// Temporarily Unused
failRegexEvent: () => {
return null;
},
noChangeEvent: () => {
return null;
},
//
refreshGroupsData: () =>
jhapiRequest("/groups", "GET").then((data) => data.json()),
refreshUserData: () =>

View File

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

View File

@@ -2,7 +2,7 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
# version_info updated by running `tbump`
version_info = (2, 0, 0, "rc3", "")
version_info = (2, 2, 2, "", "")
# pep 440 version: no dot before beta/rc, but before .dev
# 0.1.0rc1

View File

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

View File

@@ -16,6 +16,7 @@ from tornado import web
from .. import orm
from .. import roles
from .. import scopes
from ..utils import get_browser_protocol
from ..utils import token_authenticated
from .base import APIHandler
from .base import BaseHandler
@@ -115,7 +116,10 @@ class OAuthHandler:
# make absolute local redirects full URLs
# to satisfy oauthlib's absolute URI requirement
redirect_uri = (
self.request.protocol + "://" + self.request.headers['Host'] + redirect_uri
get_browser_protocol(self.request)
+ "://"
+ self.request.host
+ redirect_uri
)
parsed_url = urlparse(uri)
query_list = parse_qsl(parsed_url.query, keep_blank_values=True)

View File

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

View File

@@ -33,6 +33,11 @@ class _GroupAPIHandler(APIHandler):
raise web.HTTPError(404, "No such group: %s", group_name)
return group
def check_authenticator_managed_groups(self):
"""Raise error on group-management APIs if Authenticator is managing groups"""
if self.authenticator.manage_groups:
raise web.HTTPError(400, "Group management via API is disabled")
class GroupListAPIHandler(_GroupAPIHandler):
@needs_scope('list:groups')
@@ -68,6 +73,9 @@ class GroupListAPIHandler(_GroupAPIHandler):
@needs_scope('admin:groups')
async def post(self):
"""POST creates Multiple groups"""
self.check_authenticator_managed_groups()
model = self.get_json_body()
if not model or not isinstance(model, dict) or not model.get('groups'):
raise web.HTTPError(400, "Must specify at least one group to create")
@@ -106,6 +114,7 @@ class GroupAPIHandler(_GroupAPIHandler):
@needs_scope('admin:groups')
async def post(self, group_name):
"""POST creates a group by name"""
self.check_authenticator_managed_groups()
model = self.get_json_body()
if model is None:
model = {}
@@ -132,6 +141,7 @@ class GroupAPIHandler(_GroupAPIHandler):
@needs_scope('delete:groups')
def delete(self, group_name):
"""Delete a group by name"""
self.check_authenticator_managed_groups()
group = self.find_group(group_name)
self.log.info("Deleting group %s", group_name)
self.db.delete(group)
@@ -145,6 +155,7 @@ class GroupUsersAPIHandler(_GroupAPIHandler):
@needs_scope('groups')
def post(self, group_name):
"""POST adds users to a group"""
self.check_authenticator_managed_groups()
group = self.find_group(group_name)
data = self.get_json_body()
self._check_group_model(data)
@@ -163,6 +174,7 @@ class GroupUsersAPIHandler(_GroupAPIHandler):
@needs_scope('groups')
async def delete(self, group_name):
"""DELETE removes users from a group"""
self.check_authenticator_managed_groups()
group = self.find_group(group_name)
data = self.get_json_body()
self._check_group_model(data)

View File

@@ -58,6 +58,14 @@ class SelfAPIHandler(APIHandler):
model = get_model(user)
# add session_id associated with token
# added in 2.0
token = self.get_token()
if token:
model["session_id"] = token.session_id
else:
model["session_id"] = None
# add scopes to identify model,
# but not the scopes we added to ensure we could read our own model
model["scopes"] = sorted(self.expanded_scopes.difference(_added_scopes))
@@ -507,7 +515,7 @@ class UserServerAPIHandler(APIHandler):
user_name, self.named_server_limit_per_user
),
)
spawner = user.spawners[server_name]
spawner = user.get_spawner(server_name, replace_failed=True)
pending = spawner.pending
if pending == 'spawn':
self.set_header('Content-Type', 'text/plain')
@@ -706,7 +714,12 @@ class SpawnProgressAPIHandler(APIHandler):
# check if spawner has just failed
f = spawn_future
if f and f.done() and f.exception():
failed_event['message'] = "Spawn failed: %s" % f.exception()
exc = f.exception()
message = getattr(exc, "jupyterhub_message", str(exc))
failed_event['message'] = f"Spawn failed: {message}"
html_message = getattr(exc, "jupyterhub_html_message", "")
if html_message:
failed_event['html_message'] = html_message
await self.send_event(failed_event)
return
else:
@@ -739,7 +752,12 @@ class SpawnProgressAPIHandler(APIHandler):
# what happened? Maybe spawn failed?
f = spawn_future
if f and f.done() and f.exception():
failed_event['message'] = "Spawn failed: %s" % f.exception()
exc = f.exception()
message = getattr(exc, "jupyterhub_message", str(exc))
failed_event['message'] = f"Spawn failed: {message}"
html_message = getattr(exc, "jupyterhub_html_message", "")
if html_message:
failed_event['html_message'] = html_message
else:
self.log.warning(
"Server %s didn't start for unknown reason", spawner._log_name

View File

@@ -791,6 +791,16 @@ class JupyterHub(Application):
self.proxy_api_ip or '127.0.0.1', self.proxy_api_port or self.port + 1
)
forwarded_host_header = Unicode(
'',
help="""Alternate header to use as the Host (e.g., X-Forwarded-Host)
when determining whether a request is cross-origin
This may be useful when JupyterHub is running behind a proxy that rewrites
the Host header.
""",
).tag(config=True)
hub_port = Integer(
8081,
help="""The internal port for the Hub process.
@@ -1893,6 +1903,7 @@ class JupyterHub(Application):
user = orm.User.find(db, name)
if user is None:
user = orm.User(name=name, admin=True)
roles.assign_default_roles(self.db, entity=user)
new_users.append(user)
db.add(user)
else:
@@ -1983,12 +1994,16 @@ class JupyterHub(Application):
self.log.info(f"Creating user {username}")
user = orm.User(name=username)
self.db.add(user)
roles.assign_default_roles(self.db, entity=user)
self.db.commit()
return user
async def init_groups(self):
"""Load predefined groups into the database"""
db = self.db
if self.authenticator.manage_groups and self.load_groups:
raise ValueError("Group management has been offloaded to the authenticator")
for name, usernames in self.load_groups.items():
group = orm.Group.find(db, name)
if group is None:
@@ -2004,14 +2019,25 @@ class JupyterHub(Application):
async def init_role_creation(self):
"""Load default and predefined roles into the database"""
self.log.debug('Loading default roles to database')
self.log.debug('Loading roles into database')
default_roles = roles.get_default_roles()
config_role_names = [r['name'] for r in self.load_roles]
init_roles = default_roles
default_roles_dict = {role["name"]: role for role in default_roles}
init_roles = []
roles_with_new_permissions = []
for role_spec in self.load_roles:
role_name = role_spec['name']
if role_name in default_roles_dict:
self.log.debug(f"Overriding default role {role_name}")
# merge custom role spec with default role spec when overriding
# so the new role can be partially defined
default_role_spec = default_roles_dict.pop(role_name)
merged_role_spec = {}
merged_role_spec.update(default_role_spec)
merged_role_spec.update(role_spec)
role_spec = merged_role_spec
# Check for duplicates
if config_role_names.count(role_name) > 1:
raise ValueError(
@@ -2022,10 +2048,13 @@ class JupyterHub(Application):
old_role = orm.Role.find(self.db, name=role_name)
if old_role:
if not set(role_spec['scopes']).issubset(old_role.scopes):
app_log.warning(
self.log.warning(
"Role %s has obtained extra permissions" % role_name
)
roles_with_new_permissions.append(role_name)
# make sure we load any default roles not overridden
init_roles = list(default_roles_dict.values()) + init_roles
if roles_with_new_permissions:
unauthorized_oauth_tokens = (
self.db.query(orm.APIToken)
@@ -2037,7 +2066,7 @@ class JupyterHub(Application):
.filter(orm.APIToken.client_id != 'jupyterhub')
)
for token in unauthorized_oauth_tokens:
app_log.warning(
self.log.warning(
"Deleting OAuth token %s; one of its roles obtained new permissions that were not authorized by user"
% token
)
@@ -2045,14 +2074,19 @@ class JupyterHub(Application):
self.db.commit()
init_role_names = [r['name'] for r in init_roles]
if not orm.Role.find(self.db, name='admin'):
if (
self.db.query(orm.Role).first() is None
and self.db.query(orm.User).first() is not None
):
# apply rbac-upgrade default role assignment if there are users in the db,
# but not any roles
self._rbac_upgrade = True
else:
self._rbac_upgrade = False
for role in self.db.query(orm.Role).filter(
orm.Role.name.notin_(init_role_names)
):
app_log.info(f"Deleting role {role.name}")
self.log.warning(f"Deleting role {role.name}")
self.db.delete(role)
self.db.commit()
for role in init_roles:
@@ -2068,71 +2102,89 @@ class JupyterHub(Application):
if config_admin_users:
for role_spec in self.load_roles:
if role_spec['name'] == 'admin':
app_log.warning(
self.log.warning(
"Configuration specifies both admin_users and users in the admin role specification. "
"If admin role is present in config, c.Authenticator.admin_users should not be used."
)
app_log.info(
self.log.info(
"Merging admin_users set with users list in admin role"
)
role_spec['users'] = set(role_spec.get('users', []))
role_spec['users'] |= config_admin_users
self.log.debug('Loading predefined roles from config file to database')
self.log.debug('Loading role assignments from config')
has_admin_role_spec = {role_bearer: False for role_bearer in admin_role_objects}
for predef_role in self.load_roles:
predef_role_obj = orm.Role.find(db, name=predef_role['name'])
if predef_role['name'] == 'admin':
for role_spec in self.load_roles:
role = orm.Role.find(db, name=role_spec['name'])
role_name = role_spec["name"]
if role_name == 'admin':
for kind in admin_role_objects:
has_admin_role_spec[kind] = kind in predef_role
has_admin_role_spec[kind] = kind in role_spec
if has_admin_role_spec[kind]:
app_log.info(f"Admin role specifies static {kind} list")
self.log.info(f"Admin role specifies static {kind} list")
else:
app_log.info(
self.log.info(
f"Admin role does not specify {kind}, preserving admin membership in database"
)
# add users, services, and/or groups,
# tokens need to be checked for permissions
for kind in kinds:
orm_role_bearers = []
if kind in predef_role.keys():
for bname in predef_role[kind]:
if kind in role_spec:
for name in role_spec[kind]:
if kind == 'users':
bname = self.authenticator.normalize_username(bname)
name = self.authenticator.normalize_username(name)
if not (
await maybe_future(
self.authenticator.check_allowed(bname, None)
self.authenticator.check_allowed(name, None)
)
):
raise ValueError(
"Username %r is not in Authenticator.allowed_users"
% bname
f"Username {name} is not in Authenticator.allowed_users"
)
Class = orm.get_class(kind)
orm_obj = Class.find(db, bname)
orm_obj = Class.find(db, name)
if orm_obj is not None:
orm_role_bearers.append(orm_obj)
else:
app_log.info(
f"Found unexisting {kind} {bname} in role definition {predef_role['name']}"
self.log.info(
f"Found unexisting {kind} {name} in role definition {role_name}"
)
if kind == 'users':
orm_obj = await self._get_or_create_user(bname)
orm_obj = await self._get_or_create_user(name)
orm_role_bearers.append(orm_obj)
elif kind == 'groups':
group = orm.Group(name=bname)
group = orm.Group(name=name)
db.add(group)
db.commit()
orm_role_bearers.append(group)
else:
raise ValueError(
f"{kind} {bname} defined in config role definition {predef_role['name']} but not present in database"
f"{kind} {name} defined in config role definition {role_name} but not present in database"
)
# Ensure all with admin role have admin flag
if predef_role['name'] == 'admin':
if role_name == 'admin':
orm_obj.admin = True
setattr(predef_role_obj, kind, orm_role_bearers)
# explicitly defined list
# ensure membership list is exact match (adds and revokes permissions)
setattr(role, kind, orm_role_bearers)
else:
# no defined members
# leaving 'users' undefined in overrides of the default 'user' role
# should not clear membership on startup
# since allowed users could be managed by the authenticator
if kind == "users" and role_name == "user":
# Default user lists can be managed by the Authenticator,
# if unspecified in role config
pass
else:
# otherwise, omitting a member category is equivalent to specifying an empty list
setattr(role, kind, [])
db.commit()
if self.authenticator.allowed_users:
self.log.debug(
f"Assigning {len(self.authenticator.allowed_users)} allowed_users to the user role"
)
allowed_users = db.query(orm.User).filter(
orm.User.name.in_(self.authenticator.allowed_users)
)
@@ -2149,8 +2201,8 @@ class JupyterHub(Application):
db.commit()
# make sure that on hub upgrade, all users, services and tokens have at least one role (update with default)
if getattr(self, '_rbac_upgrade', False):
app_log.warning(
"No admin role found; assuming hub upgrade. Initializing default roles for all entities"
self.log.warning(
"No roles found; assuming hub upgrade. Initializing default roles for all entities"
)
for kind in kinds:
roles.check_for_default_roles(db, kind)
@@ -3098,7 +3150,12 @@ class JupyterHub(Application):
self.last_activity_callback = pc
pc.start()
self.log.info("JupyterHub is now running at %s", self.proxy.public_url)
if self.proxy.should_start:
self.log.info("JupyterHub is now running at %s", self.proxy.public_url)
else:
self.log.info(
"JupyterHub is now running, internal Hub API at %s", self.hub.url
)
# Use atexit for Windows, it doesn't have signal handling support
if _mswindows:
atexit.register(self.atexit)

View File

@@ -582,9 +582,13 @@ class Authenticator(LoggingConfigurable):
or None if Authentication failed.
The Authenticator may return a dict instead, which MUST have a
key `name` holding the username, and MAY have two optional keys
set: `auth_state`, a dictionary of of auth state that will be
persisted; and `admin`, the admin setting value for the user.
key `name` holding the username, and MAY have additional keys:
- `auth_state`, a dictionary of of auth state that will be
persisted;
- `admin`, the admin setting value for the user
- `groups`, the list of group names the user should be a member of,
if Authenticator.manage_groups is True.
"""
def pre_spawn_start(self, user, spawner):
@@ -635,6 +639,19 @@ class Authenticator(LoggingConfigurable):
"""
self.allowed_users.discard(user.name)
manage_groups = Bool(
False,
config=True,
help="""Let authenticator manage user groups
If True, Authenticator.authenticate and/or .refresh_user
may return a list of group names in the 'groups' field,
which will be assigned to the user.
All group-assignment APIs are disabled if this is True.
""",
)
auto_login = Bool(
False,
config=True,
@@ -958,16 +975,24 @@ class PAMAuthenticator(LocalAuthenticator):
).tag(config=True)
open_sessions = Bool(
True,
False,
help="""
Whether to open a new PAM session when spawners are started.
This may trigger things like mounting shared filsystems,
loading credentials, etc. depending on system configuration,
but it does not always work.
This may trigger things like mounting shared filesystems,
loading credentials, etc. depending on system configuration.
The lifecycle of PAM sessions is not correct,
so many PAM session configurations will not work.
If any errors are encountered when opening/closing PAM sessions,
this is automatically set to False.
.. versionchanged:: 2.2
Due to longstanding problems in the session lifecycle,
this is now disabled by default.
You may opt-in to opening sessions by setting this to True.
""",
).tag(config=True)

View File

@@ -45,10 +45,12 @@ from ..metrics import ServerSpawnStatus
from ..metrics import ServerStopStatus
from ..metrics import TOTAL_USERS
from ..objects import Server
from ..scopes import needs_scope
from ..spawner import LocalProcessSpawner
from ..user import User
from ..utils import AnyTimeoutError
from ..utils import get_accepted_mimetype
from ..utils import get_browser_protocol
from ..utils import maybe_future
from ..utils import url_path_join
@@ -71,6 +73,12 @@ SESSION_COOKIE_NAME = 'jupyterhub-session-id'
class BaseHandler(RequestHandler):
"""Base Handler class with access to common methods and properties."""
# by default, only accept cookie-based authentication
# The APIHandler base class enables token auth
# versionadded: 2.0
_accept_cookie_auth = True
_accept_token_auth = False
async def prepare(self):
"""Identify the user during the prepare stage of each request
@@ -340,6 +348,7 @@ class BaseHandler(RequestHandler):
auth_info['auth_state'] = await user.get_auth_state()
return await self.auth_to_user(auth_info, user)
@functools.lru_cache()
def get_token(self):
"""get token from authorization header"""
token = self.get_auth_token()
@@ -410,9 +419,11 @@ class BaseHandler(RequestHandler):
async def get_current_user(self):
"""get current username"""
if not hasattr(self, '_jupyterhub_user'):
user = None
try:
user = self.get_current_user_token()
if user is None:
if self._accept_token_auth:
user = self.get_current_user_token()
if user is None and self._accept_cookie_auth:
user = self.get_current_user_cookie()
if user and isinstance(user, User):
user = await self.refresh_auth(user)
@@ -515,10 +526,16 @@ class BaseHandler(RequestHandler):
path=url_path_join(self.base_url, 'services'),
**kwargs,
)
# clear tornado cookie
# clear_cookie only accepts a subset of set_cookie's kwargs
clear_xsrf_cookie_kwargs = {
key: value
for key, value in self.settings.get('xsrf_cookie_kwargs', {})
if key in {"path", "domain"}
}
self.clear_cookie(
'_xsrf',
**self.settings.get('xsrf_cookie_kwargs', {}),
**clear_xsrf_cookie_kwargs,
)
# Reset _jupyterhub_user
self._jupyterhub_user = None
@@ -623,12 +640,10 @@ class BaseHandler(RequestHandler):
next_url = self.get_argument('next', default='')
# protect against some browsers' buggy handling of backslash as slash
next_url = next_url.replace('\\', '%5C')
if (next_url + '/').startswith(
(
f'{self.request.protocol}://{self.request.host}/',
f'//{self.request.host}/',
)
) or (
proto = get_browser_protocol(self.request)
host = self.request.host
if (next_url + '/').startswith((f'{proto}://{host}/', f'//{host}/',)) or (
self.subdomain_host
and urlparse(next_url).netloc
and ("." + urlparse(next_url).netloc).endswith(
@@ -762,15 +777,25 @@ class BaseHandler(RequestHandler):
# Only set `admin` if the authenticator returned an explicit value.
if admin is not None and admin != user.admin:
user.admin = admin
roles.assign_default_roles(self.db, entity=user)
self.db.commit()
# always ensure default roles ('user', 'admin' if admin) are assigned
# after a successful login
roles.assign_default_roles(self.db, entity=user)
# apply authenticator-managed groups
if self.authenticator.manage_groups:
group_names = authenticated.get("groups")
if group_names is not None:
user.sync_groups(group_names)
# always set auth_state and commit,
# because there could be key-rotation or clearing of previous values
# going on.
if not self.authenticator.enable_auth_state:
# auth_state is not enabled. Force None.
auth_state = None
await user.save_auth_state(auth_state)
return user
async def login_user(self, data=None):
@@ -784,6 +809,7 @@ class BaseHandler(RequestHandler):
self.set_login_cookie(user)
self.statsd.incr('login.success')
self.statsd.timing('login.authenticate.success', auth_timer.ms)
self.log.info("User logged in: %s", user.name)
user._auth_refreshed = time.monotonic()
return user
@@ -1372,6 +1398,9 @@ class UserUrlHandler(BaseHandler):
Note that this only occurs if bob's server is not already running.
"""
# accept token auth for API requests that are probably to non-running servers
_accept_token_auth = True
def _fail_api_request(self, user_name='', server_name=''):
"""Fail an API request to a not-running server"""
self.log.warning(
@@ -1436,54 +1465,24 @@ class UserUrlHandler(BaseHandler):
delete = non_get
@web.authenticated
@needs_scope("access:servers")
async def get(self, user_name, user_path):
if not user_path:
user_path = '/'
current_user = self.current_user
if (
current_user
and current_user.name != user_name
and current_user.admin
and self.settings.get('admin_access', False)
):
# allow admins to spawn on behalf of users
if user_name != current_user.name:
user = self.find_user(user_name)
if user is None:
# no such user
raise web.HTTPError(404, "No such user %s" % user_name)
raise web.HTTPError(404, f"No such user {user_name}")
self.log.info(
"Admin %s requesting spawn on behalf of %s",
current_user.name,
user.name,
f"User {current_user.name} requesting spawn on behalf of {user.name}"
)
admin_spawn = True
should_spawn = True
redirect_to_self = False
else:
user = current_user
admin_spawn = False
# For non-admins, spawn if the user requested is the current user
# otherwise redirect users to their own server
should_spawn = current_user and current_user.name == user_name
redirect_to_self = not should_spawn
if redirect_to_self:
# logged in as a different non-admin user, redirect to user's own server
# this is only a stop-gap for a common mistake,
# because the same request will be a 403
# if the requested server is running
self.statsd.incr('redirects.user_to_user', 1)
self.log.warning(
"User %s requested server for %s, which they don't own",
current_user.name,
user_name,
)
target = url_path_join(current_user.url, user_path or '')
if self.request.query:
target = url_concat(target, parse_qsl(self.request.query))
self.redirect(target)
return
# If people visit /user/:user_name directly on the Hub,
# the redirects will just loop, because the proxy is bypassed.
@@ -1527,14 +1526,10 @@ class UserUrlHandler(BaseHandler):
# if request is expecting JSON, assume it's an API request and fail with 503
# because it won't like the redirect to the pending page
if (
get_accepted_mimetype(
self.request.headers.get('Accept', ''),
choices=['application/json', 'text/html'],
)
== 'application/json'
or 'api' in user_path.split('/')
):
if get_accepted_mimetype(
self.request.headers.get('Accept', ''),
choices=['application/json', 'text/html'],
) == 'application/json' or 'api' in user_path.split('/'):
self._fail_api_request(user_name, server_name)
return
@@ -1616,7 +1611,7 @@ class UserUrlHandler(BaseHandler):
if redirects:
self.log.warning("Redirect loop detected on %s", self.request.uri)
# add capped exponential backoff where cap is 10s
await asyncio.sleep(min(1 * (2 ** redirects), 10))
await asyncio.sleep(min(1 * (2**redirects), 10))
# rewrite target url with new `redirects` query value
url_parts = urlparse(target)
query_parts = parse_qs(url_parts.query)

View File

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

View File

@@ -106,22 +106,27 @@ class SpawnHandler(BaseHandler):
)
@web.authenticated
async def get(self, for_user=None, server_name=''):
def get(self, user_name=None, server_name=''):
"""GET renders form for spawning with user-specified options
or triggers spawn via redirect if there is no form.
"""
# two-stage to get the right signature for @require_scopes filter on user_name
if user_name is None:
user_name = self.current_user.name
if server_name is None:
server_name = ""
return self._get(user_name=user_name, server_name=server_name)
@needs_scope("servers")
async def _get(self, user_name, server_name):
for_user = user_name
user = current_user = self.current_user
if for_user is not None and for_user != user.name:
if not user.admin:
raise web.HTTPError(
403, "Only admins can spawn on behalf of other users"
)
if for_user != user.name:
user = self.find_user(for_user)
if user is None:
raise web.HTTPError(404, "No such user: %s" % for_user)
raise web.HTTPError(404, f"No such user: {for_user}")
if server_name:
if not self.allow_named_servers:
@@ -141,15 +146,12 @@ class SpawnHandler(BaseHandler):
)
if not self.allow_named_servers and user.running:
url = self.get_next_url(user, default=user.server_url(server_name))
url = self.get_next_url(user, default=user.server_url(""))
self.log.info("User is running: %s", user.name)
self.redirect(url)
return
if server_name is None:
server_name = ''
spawner = user.spawners[server_name]
spawner = user.get_spawner(server_name, replace_failed=True)
pending_url = self._get_pending_url(user, server_name)
@@ -189,7 +191,6 @@ class SpawnHandler(BaseHandler):
spawner._log_name,
)
options = await maybe_future(spawner.options_from_query(query_options))
pending_url = self._get_pending_url(user, server_name)
return await self._wrap_spawn_single_user(
user, server_name, spawner, pending_url, options
)
@@ -219,19 +220,24 @@ class SpawnHandler(BaseHandler):
)
@web.authenticated
async def post(self, for_user=None, server_name=''):
def post(self, user_name=None, server_name=''):
"""POST spawns with user-specified options"""
if user_name is None:
user_name = self.current_user.name
if server_name is None:
server_name = ""
return self._post(user_name=user_name, server_name=server_name)
@needs_scope("servers")
async def _post(self, user_name, server_name):
for_user = user_name
user = current_user = self.current_user
if for_user is not None and for_user != user.name:
if not user.admin:
raise web.HTTPError(
403, "Only admins can spawn on behalf of other users"
)
if for_user != user.name:
user = self.find_user(for_user)
if user is None:
raise web.HTTPError(404, "No such user: %s" % for_user)
spawner = user.spawners[server_name]
spawner = user.get_spawner(server_name, replace_failed=True)
if spawner.ready:
raise web.HTTPError(400, "%s is already running" % (spawner._log_name))
@@ -249,7 +255,7 @@ class SpawnHandler(BaseHandler):
self.log.debug(
"Triggering spawn with supplied form options for %s", spawner._log_name
)
options = await maybe_future(spawner.options_from_form(form_options))
options = await maybe_future(spawner.run_options_from_form(form_options))
pending_url = self._get_pending_url(user, server_name)
return await self._wrap_spawn_single_user(
user, server_name, spawner, pending_url, options
@@ -308,10 +314,13 @@ class SpawnHandler(BaseHandler):
# otherwise it may cause a redirect loop
if f.done() and f.exception():
exc = f.exception()
self.log.exception(f"Error starting server {spawner._log_name}: {exc}")
if isinstance(exc, web.HTTPError):
# allow custom HTTPErrors to pass through
raise exc
raise web.HTTPError(
500,
"Error in Authenticator.pre_spawn_start: %s %s"
% (type(exc).__name__, str(exc)),
f"Unhandled error starting server {spawner._log_name}",
)
return self.redirect(pending_url)
@@ -334,13 +343,11 @@ class SpawnPendingHandler(BaseHandler):
"""
@web.authenticated
async def get(self, for_user, server_name=''):
@needs_scope("servers")
async def get(self, user_name, server_name=''):
for_user = user_name
user = current_user = self.current_user
if for_user is not None and for_user != current_user.name:
if not current_user.admin:
raise web.HTTPError(
403, "Only admins can spawn on behalf of other users"
)
if for_user != current_user.name:
user = self.find_user(for_user)
if user is None:
raise web.HTTPError(404, "No such user: %s" % for_user)
@@ -362,13 +369,9 @@ class SpawnPendingHandler(BaseHandler):
auth_state = await user.get_auth_state()
# First, check for previous failure.
if (
not spawner.active
and spawner._spawn_future
and spawner._spawn_future.done()
and spawner._spawn_future.exception()
):
# Condition: spawner not active and _spawn_future exists and contains an Exception
if not spawner.active and spawner._failed:
# Condition: spawner not active and last spawn failed
# (failure is available as spawner._spawn_future.exception()).
# Implicit spawn on /user/:name is not allowed if the user's last spawn failed.
# We should point the user to Home if the most recent spawn failed.
exc = spawner._spawn_future.exception()
@@ -384,6 +387,7 @@ class SpawnPendingHandler(BaseHandler):
server_name=server_name,
spawn_url=spawn_url,
failed=True,
failed_html_message=getattr(exc, 'jupyterhub_html_message', ''),
failed_message=getattr(exc, 'jupyterhub_message', ''),
exception=exc,
)
@@ -465,6 +469,7 @@ class AdminHandler(BaseHandler):
named_server_limit_per_user=self.named_server_limit_per_user,
server_version=f'{__version__} {self.version_hash}',
api_page_limit=self.settings["api_page_default_limit"],
base_url=self.settings["base_url"],
)
self.finish(html)

View File

@@ -2,6 +2,7 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import re
from functools import wraps
from itertools import chain
from sqlalchemy import func
@@ -44,6 +45,7 @@ def get_default_roles():
'access:services',
'access:servers',
'read:roles',
'read:metrics',
],
},
{
@@ -239,26 +241,6 @@ def _check_scopes(*args, rolename=None):
)
def _overwrite_role(role, role_dict):
"""Overwrites role's description and/or scopes with role_dict if role not 'admin'"""
for attr in role_dict.keys():
if attr == 'description' or attr == 'scopes':
if role.name == 'admin':
admin_role_spec = [
r for r in get_default_roles() if r['name'] == 'admin'
][0]
if role_dict[attr] != admin_role_spec[attr]:
raise ValueError(
'admin role description or scopes cannot be overwritten'
)
else:
if role_dict[attr] != getattr(role, attr):
setattr(role, attr, role_dict[attr])
app_log.info(
'Role %r %r attribute has been changed', role.name, attr
)
_role_name_pattern = re.compile(r'^[a-z][a-z0-9\-_~\.]{1,253}[a-z0-9]$')
@@ -293,6 +275,17 @@ def create_role(db, role_dict):
description = role_dict.get('description')
scopes = role_dict.get('scopes')
if name == "admin":
for _role in get_default_roles():
if _role["name"] == "admin":
admin_spec = _role
break
for key in ["description", "scopes"]:
if key in role_dict and role_dict[key] != admin_spec[key]:
raise ValueError(
f"Cannot override admin role admin.{key} = {role_dict[key]}"
)
# check if the provided scopes exist
if scopes:
_check_scopes(*scopes, rolename=role_dict['name'])
@@ -306,8 +299,22 @@ def create_role(db, role_dict):
if role_dict not in default_roles:
app_log.info('Role %s added to database', name)
else:
_overwrite_role(role, role_dict)
for attr in ["description", "scopes"]:
try:
new_value = role_dict[attr]
except KeyError:
continue
old_value = getattr(role, attr)
if new_value != old_value:
setattr(role, attr, new_value)
app_log.info(
f'Role attribute {role.name}.{attr} has been changed',
)
app_log.debug(
f'Role attribute {role.name}.{attr} changed from %r to %r',
old_value,
new_value,
)
db.commit()
@@ -327,78 +334,61 @@ def delete_role(db, rolename):
raise KeyError('Cannot remove role %r that does not exist', rolename)
def existing_only(func):
"""Decorator for checking if objects and roles exist"""
def _existing_only(func):
"""Decorator for checking if roles exist"""
def _check_existence(db, entity, rolename):
role = orm.Role.find(db, rolename)
if entity is None:
raise ValueError(
f"{entity!r} of kind {type(entity).__name__!r} does not exist"
)
elif role is None:
raise ValueError("Role %r does not exist" % rolename)
else:
func(db, entity, role)
@wraps(func)
def _check_existence(db, entity, role=None, *, rolename=None):
if isinstance(role, str):
rolename = role
if rolename is not None:
# if given as a str, lookup role by name
role = orm.Role.find(db, rolename)
if role is None:
raise ValueError(f"Role {rolename} does not exist")
return func(db, entity, role)
return _check_existence
@existing_only
def grant_role(db, entity, rolename):
@_existing_only
def grant_role(db, entity, role):
"""Adds a role for users, services, groups or tokens"""
if isinstance(entity, orm.APIToken):
entity_repr = entity
else:
entity_repr = entity.name
if rolename not in entity.roles:
entity.roles.append(rolename)
if role not in entity.roles:
entity.roles.append(role)
db.commit()
app_log.info(
'Adding role %s for %s: %s',
rolename.name,
role.name,
type(entity).__name__,
entity_repr,
)
@existing_only
def strip_role(db, entity, rolename):
@_existing_only
def strip_role(db, entity, role):
"""Removes a role for users, services, groups or tokens"""
if isinstance(entity, orm.APIToken):
entity_repr = entity
else:
entity_repr = entity.name
if rolename in entity.roles:
entity.roles.remove(rolename)
if role in entity.roles:
entity.roles.remove(role)
db.commit()
app_log.info(
'Removing role %s for %s: %s',
rolename.name,
role.name,
type(entity).__name__,
entity_repr,
)
def _switch_default_role(db, obj, admin):
"""Switch between default user/service and admin roles for users/services"""
user_role = orm.Role.find(db, 'user')
admin_role = orm.Role.find(db, 'admin')
def add_and_remove(db, obj, current_role, new_role):
if current_role in obj.roles:
strip_role(db, entity=obj, rolename=current_role.name)
# only add new default role if the user has no other roles
if len(obj.roles) < 1:
grant_role(db, entity=obj, rolename=new_role.name)
if admin:
add_and_remove(db, obj, user_role, admin_role)
else:
add_and_remove(db, obj, admin_role, user_role)
def _token_allowed_role(db, token, role):
"""Checks if requested role for token does not grant the token
higher permissions than the token's owner has
@@ -413,22 +403,21 @@ def _token_allowed_role(db, token, role):
if owner is None:
raise ValueError(f"Owner not found for {token}")
if role in owner.roles:
# shortcut: token is assigned an exact role the owner has
return True
expanded_scopes = _get_subscopes(role, owner=owner)
implicit_permissions = {'inherit', 'read:inherit'}
explicit_scopes = expanded_scopes - implicit_permissions
# ignore horizontal filters
no_filter_scopes = {
scope.split('!', 1)[0] if '!' in scope else scope for scope in explicit_scopes
}
# find the owner's scopes
expanded_owner_scopes = expand_roles_to_scopes(owner)
# ignore horizontal filters
no_filter_owner_scopes = {
scope.split('!', 1)[0] if '!' in scope else scope
for scope in expanded_owner_scopes
}
disallowed_scopes = no_filter_scopes.difference(no_filter_owner_scopes)
allowed_scopes = scopes._intersect_expanded_scopes(
explicit_scopes, expanded_owner_scopes, db
)
disallowed_scopes = explicit_scopes.difference(allowed_scopes)
if not disallowed_scopes:
# no scopes requested outside owner's own scopes
return True
@@ -441,23 +430,36 @@ def _token_allowed_role(db, token, role):
def assign_default_roles(db, entity):
"""Assigns default role(s) to an entity:
users and services get 'user' role, or admin role if they have admin flag
tokens get 'token' role
users and services get 'admin' role if they are admin (removed if they are not)
users always get 'user' role
"""
if isinstance(entity, orm.Group):
pass
elif isinstance(entity, orm.APIToken):
return
if isinstance(entity, orm.APIToken):
app_log.debug('Assigning default role to token')
default_token_role = orm.Role.find(db, 'token')
if not entity.roles and (entity.user or entity.service) is not None:
default_token_role.tokens.append(entity)
app_log.info('Added role %s to token %s', default_token_role.name, entity)
db.commit()
# users and services can have 'user' or 'admin' roles as default
# users and services all have 'user' role by default
# and optionally 'admin' as well
else:
kind = type(entity).__name__
app_log.debug(f'Assigning default role to {kind} {entity.name}')
_switch_default_role(db, entity, entity.admin)
if entity.admin:
grant_role(db, entity=entity, rolename="admin")
else:
admin_role = orm.Role.find(db, 'admin')
if admin_role in entity.roles:
strip_role(db, entity=entity, rolename="admin")
if kind == "User":
grant_role(db, entity=entity, rolename="user")
def update_roles(db, entity, roles):

View File

@@ -131,6 +131,9 @@ scope_definitions = {
'description': 'Read information about the proxys routing table, sync the Hub with the proxy and notify the Hub about a new proxy.'
},
'shutdown': {'description': 'Shutdown the hub.'},
'read:metrics': {
'description': "Read prometheus metrics.",
},
}
@@ -295,7 +298,7 @@ def get_scopes_for(orm_object):
)
if isinstance(orm_object, orm.APIToken):
app_log.warning(f"Authenticated with token {orm_object}")
app_log.debug(f"Authenticated with token {orm_object}")
owner = orm_object.user or orm_object.service
token_scopes = roles.expand_roles_to_scopes(orm_object)
if orm_object.client_id != "jupyterhub":

View File

@@ -3,10 +3,24 @@
Tokens are sent to the Hub for verification.
The Hub replies with a JSON model describing the authenticated user.
``HubAuth`` can be used in any application, even outside tornado.
This contains two levels of authentication:
``HubAuthenticated`` is a mixin class for tornado handlers that should
authenticate with the Hub.
- :class:`HubOAuth` - Use OAuth 2 to authenticate browsers with the Hub.
This should be used for any service that should respond to browser requests
(i.e. most services).
- :class:`HubAuth` - token-only authentication, for a service that only need to handle token-authenticated API requests
The ``Auth`` classes (:class:`HubAuth`, :class:`HubOAuth`)
can be used in any application, even outside tornado.
They contain reference implementations of talking to the Hub API
to resolve a token to a user.
The ``Authenticated`` classes (:class:`HubAuthenticated`, :class:`HubOAuthenticated`)
are mixins for tornado handlers that should authenticate with the Hub.
If you are using OAuth, you will also need to register an oauth callback handler to complete the oauth process.
A tornado implementation is provided in :class:`HubOAuthCallbackHandler`.
"""
import base64
@@ -39,6 +53,7 @@ from traitlets import validate
from traitlets.config import SingletonConfigurable
from ..scopes import _intersect_expanded_scopes
from ..utils import get_browser_protocol
from ..utils import url_path_join
@@ -212,6 +227,7 @@ class HubAuth(SingletonConfigurable):
help="""The base API URL of the Hub.
Typically `http://hub-ip:hub-port/hub/api`
Default: $JUPYTERHUB_API_URL
""",
).tag(config=True)
@@ -227,7 +243,10 @@ class HubAuth(SingletonConfigurable):
os.getenv('JUPYTERHUB_API_TOKEN', ''),
help="""API key for accessing Hub API.
Generate with `jupyterhub token [username]` or add to JupyterHub.services config.
Default: $JUPYTERHUB_API_TOKEN
Loaded from services configuration in jupyterhub_config.
Will be auto-generated for hub-managed services.
""",
).tag(config=True)
@@ -236,6 +255,7 @@ class HubAuth(SingletonConfigurable):
help="""The URL prefix for the Hub itself.
Typically /hub/
Default: $JUPYTERHUB_BASE_URL
""",
).tag(config=True)
@@ -481,11 +501,17 @@ class HubAuth(SingletonConfigurable):
auth_header_name = 'Authorization'
auth_header_pat = re.compile(r'(?:token|bearer)\s+(.+)', re.IGNORECASE)
def get_token(self, handler):
"""Get the user token from a request
def get_token(self, handler, in_cookie=True):
"""Get the token authenticating a request
.. versionchanged:: 2.2
in_cookie added.
Previously, only URL params and header were considered.
Pass `in_cookie=False` to preserve that behavior.
- in URL parameters: ?token=<token>
- in header: Authorization: token <token>
- in cookie (stored after oauth), if in_cookie is True
"""
user_token = handler.get_argument('token', '')
@@ -496,8 +522,14 @@ class HubAuth(SingletonConfigurable):
)
if m:
user_token = m.group(1)
if not user_token and in_cookie:
user_token = self._get_token_cookie(handler)
return user_token
def _get_token_cookie(self, handler):
"""Base class doesn't store tokens in cookies"""
return None
def _get_user_cookie(self, handler):
"""Get the user model from a cookie"""
# overridden in HubOAuth to store the access token after oauth
@@ -533,8 +565,10 @@ class HubAuth(SingletonConfigurable):
handler._cached_hub_user = user_model = None
session_id = self.get_session_id(handler)
# check token first
token = self.get_token(handler)
# check token first, ignoring cookies
# because some checks are different when a request
# is token-authenticated (CORS-related)
token = self.get_token(handler, in_cookie=False)
if token:
user_model = self.user_for_token(token, session_id=session_id)
if user_model:
@@ -594,11 +628,18 @@ class HubOAuth(HubAuth):
"""
return self.cookie_name + '-oauth-state'
def _get_user_cookie(self, handler):
def _get_token_cookie(self, handler):
"""Base class doesn't store tokens in cookies"""
token = handler.get_secure_cookie(self.cookie_name)
if token:
# decode cookie bytes
token = token.decode('ascii', 'replace')
return token
def _get_user_cookie(self, handler):
token = self._get_token_cookie(handler)
session_id = self.get_session_id(handler)
if token:
token = token.decode('ascii', 'replace')
user_model = self.user_for_token(token, session_id=session_id)
if user_model is None:
app_log.warning("Token stored in cookie may have expired")
@@ -753,7 +794,7 @@ class HubOAuth(HubAuth):
# OAuth that doesn't complete shouldn't linger too long.
'max_age': 600,
}
if handler.request.protocol == 'https':
if get_browser_protocol(handler.request) == 'https':
kwargs['secure'] = True
# load user cookie overrides
kwargs.update(self.cookie_options)
@@ -793,7 +834,7 @@ class HubOAuth(HubAuth):
def set_cookie(self, handler, access_token):
"""Set a cookie recording OAuth result"""
kwargs = {'path': self.base_url, 'httponly': True}
if handler.request.protocol == 'https':
if get_browser_protocol(handler.request) == 'https':
kwargs['secure'] = True
# load user cookie overrides
kwargs.update(self.cookie_options)
@@ -854,8 +895,6 @@ class HubAuthenticated:
Examples::
class MyHandler(HubAuthenticated, web.RequestHandler):
hub_users = {'inara', 'mal'}
def initialize(self, hub_auth):
self.hub_auth = hub_auth
@@ -865,6 +904,7 @@ class HubAuthenticated:
"""
# deprecated, pre-2.0 allow sets
hub_services = None # set of allowed services
hub_users = None # set of allowed users
hub_groups = None # set of allowed groups
@@ -960,6 +1000,10 @@ class HubAuthenticated:
raise UserNotAllowed(model)
# proceed with the pre-2.0 way if hub_scopes is not set
warnings.warn(
"hub_scopes ($JUPYTERHUB not set, proceeding with pre-2.0 authentication",
DeprecationWarning,
)
if self.allow_admin and model.get('admin', False):
app_log.debug("Allowing Hub admin %s", name)

View File

@@ -16,8 +16,8 @@ import random
import secrets
import sys
import warnings
from datetime import datetime
from datetime import timezone
from importlib import import_module
from textwrap import dedent
from urllib.parse import urlparse
@@ -492,7 +492,7 @@ class SingleUserNotebookAppMixin(Configurable):
i,
RETRIES,
)
await asyncio.sleep(min(2 ** i, 16))
await asyncio.sleep(min(2**i, 16))
else:
break
else:
@@ -606,10 +606,34 @@ class SingleUserNotebookAppMixin(Configurable):
t = self.hub_activity_interval * (1 + 0.2 * (random.random() - 0.5))
await asyncio.sleep(t)
def _log_app_versions(self):
"""Log application versions at startup
Logs versions of jupyterhub and singleuser-server base versions (jupyterlab, jupyter_server, notebook)
"""
self.log.info(f"Starting jupyterhub single-user server version {__version__}")
# don't log these package versions
seen = {"jupyterhub", "traitlets", "jupyter_core", "builtins"}
for cls in self.__class__.mro():
module_name = cls.__module__.partition(".")[0]
if module_name not in seen:
seen.add(module_name)
try:
mod = import_module(module_name)
mod_version = getattr(mod, "__version__")
except Exception:
mod_version = ""
self.log.info(
f"Extending {cls.__module__}.{cls.__name__} from {module_name} {mod_version}"
)
def initialize(self, argv=None):
# disable trash by default
# this can be re-enabled by config
self.config.FileContentsManager.delete_to_trash = False
self._log_app_versions()
return super().initialize(argv)
def start(self):
@@ -655,6 +679,7 @@ class SingleUserNotebookAppMixin(Configurable):
s['hub_prefix'] = self.hub_prefix
s['hub_host'] = self.hub_host
s['hub_auth'] = self.hub_auth
s['page_config_hook'] = self.page_config_hook
csp_report_uri = s['csp_report_uri'] = self.hub_host + url_path_join(
self.hub_prefix, 'security/csp-report'
)
@@ -682,6 +707,18 @@ class SingleUserNotebookAppMixin(Configurable):
self.patch_default_headers()
self.patch_templates()
def page_config_hook(self, handler, page_config):
"""JupyterLab page config hook
Adds JupyterHub info to page config.
Places the JupyterHub API token in PageConfig.token.
Only has effect on jupyterlab_server >=2.9
"""
page_config["token"] = self.hub_auth.get_token(handler) or ""
return page_config
def patch_default_headers(self):
if hasattr(RequestHandler, '_orig_set_default_headers'):
return

View File

@@ -11,6 +11,7 @@ import shutil
import signal
import sys
import warnings
from inspect import signature
from subprocess import Popen
from tempfile import mkdtemp
from urllib.parse import urlparse
@@ -183,17 +184,38 @@ class Spawner(LoggingConfigurable):
def last_activity(self):
return self.orm_spawner.last_activity
# Spawner.server is a wrapper of the ORM orm_spawner.server
# make sure it's always in sync with the underlying state
# this is harder to do with traitlets,
# which do not run on every access, only on set and first-get
_server = None
@property
def server(self):
if hasattr(self, '_server'):
# always check that we're in sync with orm_spawner
if not self.orm_spawner:
# no ORM spawner, nothing to check
return self._server
if self.orm_spawner and self.orm_spawner.server:
return Server(orm_server=self.orm_spawner.server)
orm_server = self.orm_spawner.server
if orm_server is not None and (
self._server is None or orm_server is not self._server.orm_server
):
# self._server is not connected to orm_spawner
self._server = Server(orm_server=self.orm_spawner.server)
elif orm_server is None:
# no ORM server, clear it
self._server = None
return self._server
@server.setter
def server(self, server):
self._server = server
if self.orm_spawner:
if self.orm_spawner is not None:
if server is not None and server.orm_server == self.orm_spawner.server:
# no change
return
if self.orm_spawner.server is not None:
# delete the old value
db = inspect(self.orm_spawner.server).session
@@ -201,7 +223,13 @@ class Spawner(LoggingConfigurable):
if server is None:
self.orm_spawner.server = None
else:
if server.orm_server is None:
self.log.warning(f"No ORM server for {self._log_name}")
self.orm_spawner.server = server.orm_server
elif server is not None:
self.log.warning(
"Setting Spawner.server for {self._log_name} with no underlying orm_spawner"
)
@property
def name(self):
@@ -424,6 +452,13 @@ class Spawner(LoggingConfigurable):
def _default_options_from_form(self, form_data):
return form_data
def run_options_from_form(self, form_data):
sig = signature(self.options_from_form)
if 'spawner' in sig.parameters:
return self.options_from_form(form_data, spawner=self)
else:
return self.options_from_form(form_data)
def options_from_query(self, query_data):
"""Interpret query arguments passed to /spawn

View File

@@ -9,6 +9,7 @@ from datetime import timedelta
from unittest import mock
from urllib.parse import quote
from urllib.parse import urlparse
from urllib.parse import urlunparse
from pytest import fixture
from pytest import mark
@@ -65,7 +66,15 @@ async def test_auth_api(app):
assert r.status_code == 403
async def test_cors_checks(app):
@mark.parametrize(
"content_type, status",
[
("text/plain", 403),
# accepted, but invalid
("application/json; charset=UTF-8", 400),
],
)
async def test_post_content_type(app, content_type, status):
url = ujoin(public_host(app), app.hub.base_url)
host = urlparse(url).netloc
# add admin user
@@ -74,42 +83,6 @@ async def test_cors_checks(app):
user = add_user(app.db, name='admin', admin=True)
cookies = await app.login_user('admin')
r = await api_request(
app, 'users', headers={'Authorization': '', 'Referer': 'null'}, cookies=cookies
)
assert r.status_code == 403
r = await api_request(
app,
'users',
headers={
'Authorization': '',
'Referer': 'http://attack.com/csrf/vulnerability',
},
cookies=cookies,
)
assert r.status_code == 403
r = await api_request(
app,
'users',
headers={'Authorization': '', 'Referer': url, 'Host': host},
cookies=cookies,
)
assert r.status_code == 200
r = await api_request(
app,
'users',
headers={
'Authorization': '',
'Referer': ujoin(url, 'foo/bar/baz/bat'),
'Host': host,
},
cookies=cookies,
)
assert r.status_code == 200
r = await api_request(
app,
'users',
@@ -117,24 +90,115 @@ async def test_cors_checks(app):
data='{}',
headers={
"Authorization": "",
"Content-Type": "text/plain",
"Content-Type": content_type,
},
cookies=cookies,
)
assert r.status_code == 403
assert r.status_code == status
@mark.parametrize(
"host, referer, extraheaders, status",
[
('$host', '$url', {}, 200),
(None, None, {}, 200),
(None, 'null', {}, 403),
(None, 'http://attack.com/csrf/vulnerability', {}, 403),
('$host', {"path": "/user/someuser"}, {}, 403),
('$host', {"path": "{path}/foo/bar/subpath"}, {}, 200),
# mismatch host
("mismatch.com", "$url", {}, 403),
# explicit host, matches
("fake.example", {"netloc": "fake.example"}, {}, 200),
# explicit port, matches implicit port
("fake.example:80", {"netloc": "fake.example"}, {}, 200),
# explicit port, mismatch
("fake.example:81", {"netloc": "fake.example"}, {}, 403),
# implicit ports, mismatch proto
("fake.example", {"netloc": "fake.example", "scheme": "https"}, {}, 403),
# explicit ports, match
("fake.example:81", {"netloc": "fake.example:81"}, {}, 200),
# Test proxy protocol defined headers taken into account by utils.get_browser_protocol
(
"fake.example",
{"netloc": "fake.example", "scheme": "https"},
{'X-Scheme': 'https'},
200,
),
(
"fake.example",
{"netloc": "fake.example", "scheme": "https"},
{'X-Forwarded-Proto': 'https'},
200,
),
(
"fake.example",
{"netloc": "fake.example", "scheme": "https"},
{
'Forwarded': 'host=fake.example;proto=https,for=1.2.34;proto=http',
'X-Scheme': 'http',
},
200,
),
(
"fake.example",
{"netloc": "fake.example", "scheme": "https"},
{
'Forwarded': 'host=fake.example;proto=http,for=1.2.34;proto=http',
'X-Scheme': 'https',
},
403,
),
("fake.example", {"netloc": "fake.example"}, {'X-Scheme': 'https'}, 403),
("fake.example", {"netloc": "fake.example"}, {'X-Scheme': 'https, http'}, 403),
],
)
async def test_cors_check(request, app, host, referer, extraheaders, status):
url = ujoin(public_host(app), app.hub.base_url)
real_host = urlparse(url).netloc
if host == "$host":
host = real_host
if referer == '$url':
referer = url
elif isinstance(referer, dict):
parsed_url = urlparse(url)
# apply {}
url_ns = {key: getattr(parsed_url, key) for key in parsed_url._fields}
for key, value in referer.items():
referer[key] = value.format(**url_ns)
referer = urlunparse(parsed_url._replace(**referer))
# disable default auth header, cors is for cookie auth
headers = {"Authorization": ""}
if host is not None:
headers['X-Forwarded-Host'] = host
if referer is not None:
headers['Referer'] = referer
headers.update(extraheaders)
# add admin user
user = find_user(app.db, 'admin')
if user is None:
user = add_user(app.db, name='admin', admin=True)
cookies = await app.login_user('admin')
# test custom forwarded_host_header behavior
app.forwarded_host_header = 'X-Forwarded-Host'
# reset the config after the test to avoid leaking state
def reset_header():
app.forwarded_host_header = ""
request.addfinalizer(reset_header)
r = await api_request(
app,
'users',
method='post',
data='{}',
headers={
"Authorization": "",
"Content-Type": "application/json; charset=UTF-8",
},
headers=headers,
cookies=cookies,
)
assert r.status_code == 400 # accepted, but invalid
assert r.status_code == status
# --------------
@@ -160,6 +224,8 @@ def normalize_user(user):
"""
for key in ('created', 'last_activity'):
user[key] = normalize_timestamp(user[key])
if 'roles' in user:
user['roles'] = sorted(user['roles'])
if 'servers' in user:
for server in user['servers'].values():
for key in ('started', 'last_activity'):
@@ -212,7 +278,12 @@ async def test_get_users(app):
}
assert users == [
fill_user(
{'name': 'admin', 'admin': True, 'roles': ['admin'], 'auth_state': None}
{
'name': 'admin',
'admin': True,
'roles': ['admin', 'user'],
'auth_state': None,
}
),
fill_user(user_model),
]
@@ -597,7 +668,7 @@ async def test_add_multi_user_admin(app):
assert user is not None
assert user.name == name
assert user.admin
assert orm.Role.find(db, 'user') not in user.roles
assert orm.Role.find(db, 'user') in user.roles
assert orm.Role.find(db, 'admin') in user.roles
@@ -637,7 +708,7 @@ async def test_add_admin(app):
assert user.name == name
assert user.admin
# assert newadmin has default 'admin' role
assert orm.Role.find(db, 'user') not in user.roles
assert orm.Role.find(db, 'user') in user.roles
assert orm.Role.find(db, 'admin') in user.roles
@@ -672,7 +743,7 @@ async def test_make_admin(app):
assert user is not None
assert user.name == name
assert user.admin
assert orm.Role.find(db, 'user') not in user.roles
assert orm.Role.find(db, 'user') in user.roles
assert orm.Role.find(db, 'admin') in user.roles
@@ -959,7 +1030,7 @@ async def test_never_spawn(app, no_patience, never_spawn):
assert not app_user.spawner._spawn_pending
status = await app_user.spawner.poll()
assert status is not None
# failed spawn should decrements pending count
# failed spawn should decrement pending count
assert app.users.count_active_users()['pending'] == 0
@@ -968,9 +1039,16 @@ async def test_bad_spawn(app, bad_spawn):
name = 'prim'
user = add_user(db, app=app, name=name)
r = await api_request(app, 'users', name, 'server', method='post')
# check that we don't re-use spawners that failed
user.spawners[''].reused = True
assert r.status_code == 500
assert app.users.count_active_users()['pending'] == 0
r = await api_request(app, 'users', name, 'server', method='post')
# check that we don't re-use spawners that failed
spawner = user.spawners['']
assert not getattr(spawner, 'reused', False)
async def test_spawn_nosuch_user(app):
r = await api_request(app, 'users', "nosuchuser", 'server', method='post')
@@ -1735,6 +1813,38 @@ async def test_group_add_delete_users(app):
assert sorted(u.name for u in group.users) == sorted(names[2:])
@mark.group
async def test_auth_managed_groups(request, app, group, user):
group.users.append(user)
app.db.commit()
app.authenticator.manage_groups = True
request.addfinalizer(lambda: setattr(app.authenticator, "manage_groups", False))
# create groups
r = await api_request(app, 'groups', method='post')
assert r.status_code == 400
r = await api_request(app, 'groups/newgroup', method='post')
assert r.status_code == 400
# delete groups
r = await api_request(app, f'groups/{group.name}', method='delete')
assert r.status_code == 400
# add users to group
r = await api_request(
app,
f'groups/{group.name}/users',
method='post',
data=json.dumps({"users": [user.name]}),
)
assert r.status_code == 400
# remove users from group
r = await api_request(
app,
f'groups/{group.name}/users',
method='delete',
data=json.dumps({"users": [user.name]}),
)
assert r.status_code == 400
# -----------------
# Service API tests
# -----------------

View File

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

View File

@@ -7,6 +7,7 @@ from urllib.parse import urlparse
import pytest
from requests import HTTPError
from traitlets import Any
from traitlets.config import Config
from .mocking import MockPAMAuthenticator
@@ -14,6 +15,7 @@ from .mocking import MockStructGroup
from .mocking import MockStructPasswd
from .utils import add_user
from .utils import async_requests
from .utils import get_page
from .utils import public_url
from jupyterhub import auth
from jupyterhub import crypto
@@ -527,3 +529,71 @@ async def test_nullauthenticator(app):
r = await async_requests.get(public_url(app))
assert urlparse(r.url).path.endswith("/hub/login")
assert r.status_code == 403
class MockGroupsAuthenticator(auth.Authenticator):
authenticated_groups = Any()
refresh_groups = Any()
manage_groups = True
def authenticate(self, handler, data):
return {
"name": data["username"],
"groups": self.authenticated_groups,
}
async def refresh_user(self, user, handler):
return {
"name": user.name,
"groups": self.refresh_groups,
}
@pytest.mark.parametrize(
"authenticated_groups, refresh_groups",
[
(None, None),
(["auth1"], None),
(None, ["auth1"]),
(["auth1"], ["auth1", "auth2"]),
(["auth1", "auth2"], ["auth1"]),
(["auth1", "auth2"], ["auth3"]),
(["auth1", "auth2"], ["auth3"]),
],
)
async def test_auth_managed_groups(
app, user, group, authenticated_groups, refresh_groups
):
authenticator = MockGroupsAuthenticator(
parent=app,
authenticated_groups=authenticated_groups,
refresh_groups=refresh_groups,
)
user.groups.append(group)
app.db.commit()
before_groups = [group.name]
if authenticated_groups is None:
expected_authenticated_groups = before_groups
else:
expected_authenticated_groups = authenticated_groups
if refresh_groups is None:
expected_refresh_groups = expected_authenticated_groups
else:
expected_refresh_groups = refresh_groups
with mock.patch.dict(app.tornado_settings, {"authenticator": authenticator}):
cookies = await app.login_user(user.name)
assert not app.db.dirty
groups = sorted(g.name for g in user.groups)
assert groups == expected_authenticated_groups
# force refresh_user on next request
user._auth_refreshed -= 10 + app.authenticator.auth_refresh_age
r = await get_page('home', app, cookies=cookies, allow_redirects=False)
assert r.status_code == 200
assert not app.db.dirty
groups = sorted(g.name for g in user.groups)
assert groups == expected_refresh_groups

View File

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

View File

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

View File

@@ -443,7 +443,14 @@ async def test_scope_existence(tmpdir, request, role, response):
@mark.role
async def test_load_roles_users(tmpdir, request):
@mark.parametrize(
"explicit_allowed_users",
[
(True,),
(False,),
],
)
async def test_load_roles_users(tmpdir, request, explicit_allowed_users):
"""Test loading predefined roles for users in app.py"""
roles_to_load = [
{
@@ -461,7 +468,8 @@ async def test_load_roles_users(tmpdir, request):
hub.init_db()
db = hub.db
hub.authenticator.admin_users = ['admin']
hub.authenticator.allowed_users = ['cyclops', 'gandalf', 'bilbo', 'gargamel']
if explicit_allowed_users:
hub.authenticator.allowed_users = ['cyclops', 'gandalf', 'bilbo', 'gargamel']
await hub.init_role_creation()
await hub.init_users()
await hub.init_role_assignment()
@@ -665,7 +673,11 @@ async def test_load_roles_user_tokens(tmpdir, request):
# no role requested - gets default 'token' role
({}, None, None, 201),
# role scopes within the user's default 'user' role
({}, 'self-reader', ['read:users'], 201),
({}, 'self-reader', ['read:users!user'], 201),
# role scopes within the user's default 'user' role, but with disjoint filter
({}, 'other-reader', ['read:users!user=other'], 403),
# role scopes within the user's default 'user' role, without filter
({}, 'other-reader', ['read:users'], 403),
# role scopes outside of the user's role but within the group's role scopes of which the user is a member
({}, 'groups-reader', ['read:groups'], 201),
# non-existing role request
@@ -1183,14 +1195,47 @@ async def test_no_admin_role_change():
await hub.init_role_creation()
async def test_user_config_respects_memberships():
@pytest.mark.parametrize(
"in_db, role_users, allowed_users, expected_members",
[
# users in the db, not specified in custom user role
# no change to membership
(["alpha", "beta"], None, None, ["alpha", "beta"]),
# allowed_users is additive, not strict
(["alpha", "beta"], None, {"gamma"}, ["alpha", "beta", "gamma"]),
# explicit empty revokes all assignments
(["alpha", "beta"], [], None, []),
# explicit value is respected exactly
(["alpha", "beta"], ["alpha", "gamma"], None, ["alpha", "gamma"]),
],
)
async def test_user_role_from_config(
in_db, role_users, allowed_users, expected_members
):
role_spec = {
'name': 'user',
'scopes': ['self', 'shutdown'],
}
if role_users is not None:
role_spec['users'] = role_users
hub = MockHub(load_roles=[role_spec])
hub.init_db()
db = hub.db
hub.authenticator.admin_users = set()
if allowed_users:
hub.authenticator.allowed_users = allowed_users
await hub.init_role_creation()
async def test_user_config_creates_default_role():
role_spec = [
{
'name': 'user',
'scopes': ['self', 'shutdown'],
'name': 'new-role',
'scopes': ['read:users'],
'users': ['not-yet-created-user'],
}
]
user_names = ['eddy', 'carol']
user_names = []
hub = MockHub(load_roles=role_spec)
hub.init_db()
hub.authenticator.allowed_users = user_names
@@ -1198,9 +1243,9 @@ async def test_user_config_respects_memberships():
await hub.init_users()
await hub.init_role_assignment()
user_role = orm.Role.find(hub.db, 'user')
for user_name in user_names:
user = orm.User.find(hub.db, user_name)
assert user in user_role.users
new_role = orm.Role.find(hub.db, 'new-role')
assert orm.User.find(hub.db, 'not-yet-created-user') in new_role.users
assert orm.User.find(hub.db, 'not-yet-created-user') in user_role.users
async def test_admin_role_respects_config():
@@ -1222,16 +1267,45 @@ async def test_admin_role_respects_config():
assert user in admin_role.users
async def test_empty_admin_spec():
role_spec = [{'name': 'admin', 'users': []}]
hub = MockHub(load_roles=role_spec)
@pytest.mark.parametrize(
"in_db, role_users, admin_users, expected_members",
[
# users in the db, not specified in custom user role
# no change to membership
(["alpha", "beta"], None, None, ["alpha", "beta"]),
# admin_users is additive, not strict
(["alpha", "beta"], None, {"gamma"}, ["alpha", "beta", "gamma"]),
# explicit empty revokes all assignments
(["alpha", "beta"], [], None, []),
# explicit value is respected exactly
(["alpha", "beta"], ["alpha", "gamma"], None, ["alpha", "gamma"]),
],
)
async def test_admin_role_membership(in_db, role_users, admin_users, expected_members):
load_roles = []
if role_users is not None:
load_roles.append({"name": "admin", "users": role_users})
if not admin_users:
admin_users = set()
hub = MockHub(load_roles=load_roles, db_url="sqlite:///:memory:")
hub.init_db()
hub.authenticator.admin_users = []
await hub.init_role_creation()
db = hub.db
hub.authenticator.admin_users = admin_users
# add in_db users to the database
# this is the 'before' state of who had the role before startup
for username in in_db or []:
user = orm.User(name=username)
db.add(user)
db.commit()
roles.grant_role(db, user, "admin")
db.commit()
await hub.init_users()
await hub.init_role_assignment()
admin_role = orm.Role.find(hub.db, 'admin')
assert not admin_role.users
admin_role = orm.Role.find(db, 'admin')
role_members = sorted(user.name for user in admin_role.users)
assert role_members == expected_members
async def test_no_default_service_role():
@@ -1344,7 +1418,8 @@ async def test_login_default_role(app, username):
user.roles = []
app.db.commit()
# login *again*; user exists, shouldn't trigger change in roles
# login *again*; user exists,
# login should always trigger "user" role assignment
cookies = await app.login_user(username)
user = app.users[username]
assert user.roles == []
assert [role.name for role in user.roles] == ["user"]

View File

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

View File

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

View File

@@ -81,6 +81,18 @@ async def test_spawner(db, request):
assert isinstance(status, int)
def test_spawner_from_db(app, user):
spawner = user.spawners['name']
user_options = {"test": "value"}
spawner.orm_spawner.user_options = user_options
app.db.commit()
# delete and recreate the spawner from the db
user.spawners.pop('name')
new_spawner = user.spawners['name']
assert new_spawner.orm_spawner.user_options == user_options
assert new_spawner.user_options == user_options
async def wait_for_spawner(spawner, timeout=10):
"""Wait for an http server to show up
@@ -428,7 +440,99 @@ async def test_hub_connect_url(db):
)
async def test_spawner_oauth_roles(app):
allowed_roles = ['lotsa', 'roles']
spawner = new_spawner(app.db, oauth_roles=allowed_roles)
assert spawner.oauth_roles == allowed_roles
async def test_spawner_oauth_roles(app, user):
allowed_roles = ["admin", "user"]
spawner = user.spawners['']
spawner.oauth_roles = allowed_roles
# exercise start/stop which assign roles to oauth client
await spawner.user.spawn()
oauth_client = spawner.orm_spawner.oauth_client
assert sorted(role.name for role in oauth_client.allowed_roles) == allowed_roles
await spawner.user.stop()
async def test_spawner_oauth_roles_bad(app, user):
allowed_roles = ["user", "nosuchrole"]
spawner = user.spawners['']
spawner.oauth_roles = allowed_roles
# exercise start/stop which assign roles
# raises ValueError if we try to assign a role that doesn't exist
with pytest.raises(ValueError):
await spawner.user.spawn()
async def test_spawner_options_from_form(db):
def options_from_form(form_data):
return form_data
spawner = new_spawner(db, options_from_form=options_from_form)
form_data = {"key": ["value"]}
result = spawner.run_options_from_form(form_data)
for key, value in form_data.items():
assert key in result
assert result[key] == value
async def test_spawner_options_from_form_with_spawner(db):
def options_from_form(form_data, spawner):
return form_data
spawner = new_spawner(db, options_from_form=options_from_form)
form_data = {"key": ["value"]}
result = spawner.run_options_from_form(form_data)
for key, value in form_data.items():
assert key in result
assert result[key] == value
def test_spawner_server(db):
spawner = new_spawner(db)
spawner.orm_spawner = None
orm_spawner = orm.Spawner()
orm_server = orm.Server(base_url="/1/")
orm_spawner.server = orm_server
db.add(orm_spawner)
db.add(orm_server)
db.commit()
# initial: no orm_spawner
assert spawner.server is None
# assigning spawner.orm_spawner updates spawner.server
spawner.orm_spawner = orm_spawner
assert spawner.server is not None
assert spawner.server.orm_server is orm_server
# update orm_spawner.server without direct access on Spawner
orm_spawner.server = new_server = orm.Server(base_url="/2/")
db.commit()
assert spawner.server is not None
assert spawner.server.orm_server is not orm_server
assert spawner.server.orm_server is new_server
# clear orm_server via orm_spawner clears spawner.server
orm_spawner.server = None
db.commit()
assert spawner.server is None
# assigning spawner.server updates orm_spawner.server
orm_server = orm.Server(base_url="/3/")
db.add(orm_server)
db.commit()
spawner.server = server = Server(orm_server=orm_server)
db.commit()
assert spawner.server is server
assert spawner.orm_spawner.server is orm_server
# change orm spawner.server
orm_server = orm.Server(base_url="/4/")
db.add(orm_server)
db.commit()
spawner.server = server2 = Server(orm_server=orm_server)
assert spawner.server is server2
assert spawner.orm_spawner.server is orm_server
# clear server via spawner.server
spawner.server = None
db.commit()
assert spawner.orm_spawner.server is None
# test with no underlying orm.Spawner
# (only relevant for mocking, never true for actual Spawners)
spawner = Spawner()
spawner.server = Server.from_url("http://1.2.3.4")
assert spawner.server is not None
assert spawner.server.ip == "1.2.3.4"

View File

@@ -1,5 +1,6 @@
import pytest
from .. import orm
from ..user import UserDict
from .utils import add_user
@@ -20,3 +21,35 @@ async def test_userdict_get(db, attr):
assert userdict.get(key).id == u.id
# `in` should find it now
assert key in userdict
@pytest.mark.parametrize(
"group_names",
[
["isin1", "isin2"],
["isin1"],
["notin", "isin1"],
["new-group", "isin1"],
[],
],
)
def test_sync_groups(app, user, group_names):
expected = sorted(group_names)
db = app.db
db.add(orm.Group(name="notin"))
in_groups = [orm.Group(name="isin1"), orm.Group(name="isin2")]
for group in in_groups:
db.add(group)
db.commit()
user.groups = in_groups
db.commit()
user.sync_groups(group_names)
assert not app.db.dirty
after_groups = sorted(g.name for g in user.groups)
assert after_groups == expected
# double-check backref
for group in db.query(orm.Group):
if group.name in expected:
assert user.orm_user in group.users
else:
assert user.orm_user not in group.users

View File

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

View File

@@ -253,6 +253,58 @@ class User:
def spawner_class(self):
return self.settings.get('spawner_class', LocalProcessSpawner)
def get_spawner(self, server_name="", replace_failed=False):
"""Get a spawner by name
replace_failed governs whether a failed spawner should be replaced
or returned (default: returned).
.. versionadded:: 2.2
"""
spawner = self.spawners[server_name]
if replace_failed and spawner._failed:
self.log.debug(f"Discarding failed spawner {spawner._log_name}")
# remove failed spawner, create a new one
self.spawners.pop(server_name)
spawner = self.spawners[server_name]
return spawner
def sync_groups(self, group_names):
"""Synchronize groups with database"""
current_groups = {g.name for g in self.orm_user.groups}
new_groups = set(group_names)
if current_groups == new_groups:
# no change, nothing to do
return
# log group changes
new_groups = set(group_names).difference(current_groups)
removed_groups = current_groups.difference(group_names)
if new_groups:
self.log.info("Adding user {self.name} to group(s): {new_groups}")
if removed_groups:
self.log.info("Removing user {self.name} from group(s): {removed_groups}")
if group_names:
groups = (
self.db.query(orm.Group).filter(orm.Group.name.in_(group_names)).all()
)
existing_groups = {g.name for g in groups}
for group_name in group_names:
if group_name not in existing_groups:
# create groups that don't exist yet
self.log.info(
f"Creating new group {group_name} for user {self.name}"
)
group = orm.Group(name=group_name)
self.db.add(group)
groups.append(group)
self.groups = groups
else:
self.groups = []
self.db.commit()
async def save_auth_state(self, auth_state):
"""Encrypt and store auth_state"""
if auth_state is None:
@@ -376,6 +428,7 @@ class User:
oauth_client_id=client_id,
cookie_options=self.settings.get('cookie_options', {}),
trusted_alt_names=trusted_alt_names,
user_options=orm_spawner.user_options or {},
)
if self.settings.get('internal_ssl'):
@@ -591,7 +644,7 @@ class User:
api_token = self.new_api_token(note=note, roles=['server'])
db.commit()
spawner = self.spawners[server_name]
spawner = self.get_spawner(server_name, replace_failed=True)
spawner.server = server = Server(orm_server=orm_server)
assert spawner.orm_spawner.server is orm_server
@@ -622,6 +675,19 @@ class User:
if callable(allowed_roles):
allowed_roles = allowed_roles(spawner)
# allowed_roles config is a list of strings
# oauth provider.allowed_roles is a list of orm.Roles
if allowed_roles:
allowed_role_names = allowed_roles
allowed_roles = list(
self.db.query(orm.Role).filter(orm.Role.name.in_(allowed_roles))
)
if len(allowed_roles) != len(allowed_role_names):
missing_roles = set(allowed_role_names).difference(
{role.name for role in allowed_roles}
)
raise ValueError(f"No such role(s): {', '.join(missing_roles)}")
oauth_client = oauth_provider.add_client(
client_id,
api_token,

View File

@@ -320,9 +320,11 @@ def admin_only(f):
@auth_decorator
def metrics_authentication(self):
"""Decorator for restricting access to metrics"""
user = self.current_user
if user is None and self.authenticate_prometheus:
raise web.HTTPError(403)
if not self.authenticate_prometheus:
return
scope = 'read:metrics'
if scope not in self.parsed_scopes:
raise web.HTTPError(403, f"Access to metrics requires scope '{scope}'")
# Token utilities
@@ -355,7 +357,7 @@ def hash_token(token, salt=8, rounds=16384, algorithm='sha512'):
h.update(btoken)
digest = h.hexdigest()
return "{algorithm}:{rounds}:{salt}:{digest}".format(**locals())
return f"{algorithm}:{rounds}:{salt}:{digest}"
def compare_token(compare, token):
@@ -683,3 +685,44 @@ def catch_db_error(f):
return r
return catching
def get_browser_protocol(request):
"""Get the _protocol_ seen by the browser
Like tornado's _apply_xheaders,
but in the case of multiple proxy hops,
use the outermost value (what the browser likely sees)
instead of the innermost value,
which is the most trustworthy.
We care about what the browser sees,
not where the request actually came from,
so trusting possible spoofs is the right thing to do.
"""
headers = request.headers
# first choice: Forwarded header
forwarded_header = headers.get("Forwarded")
if forwarded_header:
first_forwarded = forwarded_header.split(",", 1)[0].strip()
fields = {}
forwarded_dict = {}
for field in first_forwarded.split(";"):
key, _, value = field.partition("=")
fields[key.strip().lower()] = value.strip()
if "proto" in fields and fields["proto"].lower() in {"http", "https"}:
return fields["proto"].lower()
else:
app_log.warning(
f"Forwarded header present without protocol: {forwarded_header}"
)
# second choice: X-Scheme or X-Forwarded-Proto
proto_header = headers.get("X-Scheme", headers.get("X-Forwarded-Proto", None))
if proto_header:
proto_header = proto_header.split(",")[0].strip().lower()
if proto_header in {"http", "https"}:
return proto_header
# no forwarded headers
return request.protocol

View File

@@ -11,7 +11,7 @@ target_version = [
github_url = "https://github.com/jupyterhub/jupyterhub"
[tool.tbump.version]
current = "2.0.0rc3"
current = "2.2.2"
# Example of a semver regexp.
# Make sure this matches current_version before

View File

@@ -46,10 +46,9 @@ def get_data_files():
"""Get data files in share/jupyter"""
data_files = []
ntrim = len(here + os.path.sep)
for (d, dirs, filenames) in os.walk(share_jupyterhub):
data_files.append((d[ntrim:], [pjoin(d, f) for f in filenames]))
rel_d = os.path.relpath(d, here)
data_files.append((rel_d, [os.path.join(rel_d, f) for f in filenames]))
return data_files

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -15,12 +15,17 @@
{{ custom_html | safe }}
{% elif login_service %}
<div class="service-login">
<p id='insecure-login-warning' class='hidden'>
Warning: JupyterHub seems to be served over an unsecured HTTP connection.
We strongly recommend enabling HTTPS for JupyterHub.
</p>
<a role="button" class='btn btn-jupyter btn-lg' href='{{authenticator_login_url}}'>
Sign in with {{login_service}}
</a>
</div>
{% else %}
<form action="{{login_url}}?next={{next}}" method="post" role="form">
<form action="{{authenticator_login_url}}" method="post" role="form">
<div class="auth-form-header">
Sign in
</div>

View File

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

View File

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

View File

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

View File

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

View File

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