Repository: jupyterhub/jupyterhub

Issue:
Spawner.options_form is not shown when servers are started from the admin panel
which prevents servers from spawning, because the `user_options` is empty and throws `KeyError` when the spawner tries to get values from it.

**How to reproduce the issue**

1. Enable `admin_access` and add (an) admin user(s)
2. Add an `options_form` to spawner and use some field value from the form in the spawner by accessing `user_options`.
3. Log in as admin and go to admin panel
4. Push the `start server` button.

**What you expected to happen**

The form is shown.

**What actually happens**

An `API request failed (500): error` error raised, because an attempt to use the `user_options` in the Spawner ended with the `KeyError` exception.

**Share what version of JupyterHub you are using**

From the `jupyterhub/jupyterhub:latest` docker image.


Discussion:
I suspect I am seeing the same issue but in different scenario.

I am using ``ProfilesSpawner`` with JupyterHub. So:

```
c.JupyterHub.spawner_class = 'wrapspawner.ProfilesSpawner'
```

Then something like:

```
c.ProfilesSpawner.profiles = [
    (
        "Jupyter Project - Minimal Notebook",
        'minimal-notebook',
        'kubespawner.KubeSpawner',
        dict(singleuser_image_spec='minimal-notebook:latest',
             singleuser_supplemental_gids=[100])
    ),
    (
        "Jupyter Project - SciPy Notebook",
        'scipy-notebook',
        'kubespawner.KubeSpawner',
        dict(singleuser_image_spec='scipy-notebook:latest',
             singleuser_supplemental_gids=[100])
    ),
    ...
]
```

When you are on the Admin dashboard and select start server for a different user, it results in an error:

```
Traceback (most recent call last):
--
  | File "/opt/app-root/lib/python3.5/site-packages/tornado/web.py", line 1512, in _execute
  | result = yield result
  | File "/opt/app-root/lib64/python3.5/types.py", line 179, in throw
  | return self.__wrapped.throw(tp, *rest)
  | File "/opt/app-root/lib/python3.5/site-packages/jupyterhub/apihandlers/users.py", line 209, in post
  | yield self.spawn_single_user(user, server_name, options=options)
  | File "/opt/app-root/lib/python3.5/site-packages/jupyterhub/handlers/base.py", line 475, in spawn_single_user
  | yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future)
  | File "/opt/app-root/lib/python3.5/site-packages/jupyterhub/handlers/base.py", line 445, in finish_user_spawn
  | yield spawn_future
  | File "/opt/app-root/lib/python3.5/site-packages/jupyterhub/user.py", line 439, in spawn
  | raise e
  | File "/opt/app-root/lib/python3.5/site-packages/jupyterhub/user.py", line 378, in spawn
  | ip_port = yield gen.with_timeout(timedelta(seconds=spawner.start_timeout), f)
  | File "/opt/app-root/lib64/python3.5/types.py", line 243, in wrapped
  | coro = func(*args, **kwargs)
  | File "/opt/app-root/lib/python3.5/site-packages/jupyterhub/spawner.py", line 968, in start
  | env = self.get_env()
  | File "/opt/app-root/lib/python3.5/site-packages/jupyterhub/spawner.py", line 960, in get_env
  | env = self.user_env(env)
  | File "/opt/app-root/lib/python3.5/site-packages/jupyterhub/spawner.py", line 947, in user_env
  | home = pwd.getpwnam(self.user.name).pw_dir
  | KeyError: 'getpwnam(): name not found: ncoghlan'
```

My guess is that is caused by same issue as here. The form is never presented to provide the option of selecting from profiles defined by ``ProfilesSpawner`` configuration. The result is that it goes into ``ProfilesSpawner`` and triggers the code:

```
    def select_profile(self, profile):
        # Select matching profile, or do nothing (leaving previous or default config in place)
        for p in self.profiles:
            if p[1] == profile:
                self.child_class = p[2]
                self.child_config = p[3]
                break

    def construct_child(self):
        self.child_profile = self.user_options.get('profile', "")
        self.select_profile(self.child_profile)
        super().construct_child()
```

The ``self.user_options.get('profile', "")`` can't find a ``profile`` option and so results in empty string. When this is used to ``select_profile`` it doesn't find a match and thus doesn't overrwrite ``self.child_class`` with anything from the profile list. The default ``child_class`` from ``WrapSpawner`` base class is:

```
    child_class = Type(LocalProcessSpawner, Spawner,
        config=True,
        help="""The class to wrap for spawning single-user servers.
                Should be a subclass of Spawner.
                """
        )
```

So rather than creating a ``KubeSpawner`` as ultimately wanted, it creates instance of ``LocalProcessSpawner`` which then fires off the code:

```
    def user_env(self, env):
        """Augment environment of spawned process with user specific env variables."""
        import pwd
        env['USER'] = self.user.name
        home = pwd.getpwnam(self.user.name).pw_dir
        shell = pwd.getpwnam(self.user.name).pw_shell
        # These will be empty if undefined,
        # in which case don't set the env:
        if home:
            env['HOME'] = home
        if shell:
            env['SHELL'] = shell
        return env
```

which fails, because ``self.user.name`` in my case is going to be a GitHub username as I am using OAuth against GitHub.

I don't know what code to start looking at as to why it doesn't flow into display form for user options. So if you can point me to the correct spot I can dig further.
FWIW, I am using:

```
jupyterhub==0.8.1
#jupyterhub-kubespawner==0.8.1
git+https://github.com/jupyter-on-openshift/kubespawner#egg=jupyterhub-kubespawner
git+https://github.com/jupyterhub/wrapspawner
jupyterhub-tmpauthenticator
oauthenticator
psycopg2
```

The ``KubeSpawner`` fork is because of pending PR:

* https://github.com/jupyterhub/kubespawner/pull/134

So using from Git repo rather than ``jupyterhub-kubespawner==0.8.1``.

There are two things here. First, I agree that launching from the admin panel ought to show the options form. That's going to be a *little* tricky, as we need to make sure that we serve a page that's launching for one user while authenticating as another, which the current spawn page doesn't handle.

Second, though, is that is should be a best practice for any Spawner that supports options_form to accept sensible defaults for missing fields in user_options. Launching Spawners can be done via the API (as is done with the admin panel), and it should generally be possible to omit user_options unless there really are no defaults that make sense, which should be rare.
I can possibly solve the default option by adding configuration:

```
c.WrapSpawner.child_class = 'kubespawner.KubeSpawner'
c.KubeSpawner.singleuser_image_spec = 'something'
```

so it doesn't at least crash, but doesn't really get the desired result, because in the case you are using ``ProfilesSpawner`` to select the image to deploy, as well as possibly other per image settings relates to persistent volumes, working directories for image etc.

Anyway, it seems you answer the question at least to say that right now one can't expect to get the form as the code isn't there to do that.
Okay. I used:

```
c.WrapSpawner.child_class = 'kubespawner.KubeSpawner'
```

and did not set ``singleuser_image_spec``.

It didn't error this time and started up the default image of ``jupyterhub/singleuser:latest``.

When you click on access server it brings up a new tab which gives error:

```
403 : Forbidden
The error was:

user grahamdumpleton is not allowed.
```

So this probably also relates to what you said about launching for one user while authenticating as another.

One other strange thing did happen. The first time I tried, because that image will not run in this environment without the:

```
c.KubeSpawner.singleuser_supplemental_gids = [100]
```

the image was failing to start. I didn't realise it was failing. In that case when I clicked on access server, it showed the user options for selection based on profiles in ``ProfilesSpawner``.

So definitely some weird flow happening in all this.


Getting a 403 suggests to me that admin-access isn't properly implemented, and that the container was launched without admin-access enabled. Do you see JUPYTERHUB_ADMIN_ACCESS=1 in the pod environment?
As for the strange failure, here's my guess of the sequence of events:

1. request spawn from API
2. request succeeds, but spawn ultimately fails (can happen if failure occurs after slow_spawn_timeout)
3. access server link sends you to `/user/:name` URL, but that server isn't running, so it serves the page `/hub/user/:name`. This is the page that normally serves the spawn form.
4. This is probably where it's not doing what you expect/desire. When visiting `/hub/user/:name`, if you are not authenticated as that user, you are redirected to `/hub/user/:yourname`.
5. At this point, you are actually served the options form, but it is *for your own server*, not the server for which you requested access.


I think the fixes are mostly in step 4, which is:

1. when requesting `/hub/user/:name`, rather than checking if you *are* `:name`, check if you should have access to their server (i.e. same_user or (is_admin and admin_access)). Only redirect away if you don't have access to the given server.
2. allow serving the spawn form if you are admin (same as above, minus the requirement for admin_access)

Resolving patch:
diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py
--- a/jupyterhub/handlers/base.py
+++ b/jupyterhub/handlers/base.py
@@ -702,9 +702,11 @@ def get_template(self, name):
         return self.settings['jinja2_env'].get_template(name)
 
     def render_template(self, name, **ns):
-        ns.update(self.template_namespace)
+        template_ns = {}
+        template_ns.update(self.template_namespace)
+        template_ns.update(ns)
         template = self.get_template(name)
-        return template.render(**ns)
+        return template.render(**template_ns)
 
     @property
     def template_namespace(self):
@@ -796,8 +798,30 @@ def get(self, name, user_path):
         if not user_path:
             user_path = '/'
         current_user = self.get_current_user()
+        if (
+            current_user
+            and current_user.name != name
+            and current_user.admin
+            and self.settings.get('admin_access', False)
+        ):
+            # allow admins to spawn on behalf of users
+            user = self.find_user(name)
+            if user is None:
+                # no such user
+                raise web.HTTPError(404, "No such user %s" % name)
+            self.log.info("Admin %s requesting spawn on behalf of %s",
+                          current_user.name, user.name)
+            admin_spawn = True
+            should_spawn = True
+        else:
+            user = current_user
+            admin_spawn = False
+            # For non-admins, we should spawn if the user matches
+            # otherwise redirect users to their own server
+            should_spawn = (current_user and current_user.name == name)
+
 
-        if current_user and current_user.name == name:
+        if should_spawn:
             # if spawning fails for any reason, point users to /hub/home to retry
             self.extra_error_html = self.spawn_home_error
 
@@ -816,8 +840,8 @@ def get(self, name, user_path):
                     Make sure to connect to the proxied public URL %s
                     """, self.request.full_url(), self.proxy.public_url)
 
-            # logged in as correct user, check for pending spawn
-            spawner = current_user.spawner
+            # logged in as valid user, check for pending spawn
+            spawner = user.spawner
 
             # First, check for previous failure.
             if (
@@ -850,7 +874,7 @@ def get(self, name, user_path):
                 self.log.info("%s is pending %s", spawner._log_name, spawner.pending)
                 # spawn has started, but not finished
                 self.statsd.incr('redirects.user_spawn_pending', 1)
-                html = self.render_template("spawn_pending.html", user=current_user)
+                html = self.render_template("spawn_pending.html", user=user)
                 self.finish(html)
                 return
 
@@ -863,24 +887,28 @@ def get(self, name, user_path):
             # server is not running, trigger spawn
             if status is not None:
                 if spawner.options_form:
-                    self.redirect(url_concat(url_path_join(self.hub.base_url, 'spawn'),
+                    url_parts = [self.hub.base_url, 'spawn']
+                    if current_user.name != user.name:
+                        # spawning on behalf of another user
+                        url_parts.append(user.name)
+                    self.redirect(url_concat(url_path_join(*url_parts),
                                              {'next': self.request.uri}))
                     return
                 else:
-                    yield self.spawn_single_user(current_user)
+                    yield self.spawn_single_user(user)
 
             # spawn didn't finish, show pending page
             if spawner.pending:
                 self.log.info("%s is pending %s", spawner._log_name, spawner.pending)
                 # spawn has started, but not finished
                 self.statsd.incr('redirects.user_spawn_pending', 1)
-                html = self.render_template("spawn_pending.html", user=current_user)
+                html = self.render_template("spawn_pending.html", user=user)
                 self.finish(html)
                 return
 
             # We do exponential backoff here - since otherwise we can get stuck in a redirect loop!
             # This is important in many distributed proxy implementations - those are often eventually
-            # consistent and can take upto a couple of seconds to actually apply throughout the cluster.
+            # consistent and can take up to a couple of seconds to actually apply throughout the cluster.
             try:
                 redirects = int(self.get_argument('redirects', 0))
             except ValueError:
@@ -905,12 +933,10 @@ def get(self, name, user_path):
                     )
                 raise web.HTTPError(500, msg)
 
-            # set login cookie anew
-            self.set_login_cookie(current_user)
             without_prefix = self.request.uri[len(self.hub.base_url):]
             target = url_path_join(self.base_url, without_prefix)
             if self.subdomain_host:
-                target = current_user.host + target
+                target = user.host + target
 
             # record redirect count in query parameter
             if redirects:
@@ -945,13 +971,13 @@ def get(self, name, user_path):
 
 class UserRedirectHandler(BaseHandler):
     """Redirect requests to user servers.
-    
+
     Allows public linking to "this file on your server".
-    
+
     /user-redirect/path/to/foo will redirect to /user/:name/path/to/foo
-    
+
     If the user is not logged in, send to login URL, redirecting back here.
-    
+
     .. versionadded:: 0.7
     """
     @web.authenticated
diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py
--- a/jupyterhub/handlers/pages.py
+++ b/jupyterhub/handlers/pages.py
@@ -34,7 +34,7 @@ def get(self):
             next_url = ''
         if next_url and next_url.startswith(url_path_join(self.base_url, 'user/')):
             # add /hub/ prefix, to ensure we redirect to the right user's server.
-            # The next request will be handled by UserSpawnHandler,
+            # The next request will be handled by SpawnHandler,
             # ultimately redirecting to the logged-in user's server.
             without_prefix = next_url[len(self.base_url):]
             next_url = url_path_join(self.hub.base_url, without_prefix)
@@ -88,11 +88,11 @@ class SpawnHandler(BaseHandler):
     Only enabled when Spawner.options_form is defined.
     """
     @gen.coroutine
-    def _render_form(self, message=''):
-        user = self.get_current_user()
+    def _render_form(self, message='', for_user=None):
+        user = for_user or self.get_current_user()
         spawner_options_form = yield user.spawner.get_options_form()
         return self.render_template('spawn.html',
-            user=user,
+            for_user=for_user,
             spawner_options_form=spawner_options_form,
             error_message=message,
             url=self.request.uri,
@@ -100,19 +100,27 @@ def _render_form(self, message=''):
 
     @web.authenticated
     @gen.coroutine
-    def get(self):
+    def get(self, for_user=None):
         """GET renders form for spawning with user-specified options
 
         or triggers spawn via redirect if there is no form.
         """
-        user = self.get_current_user()
+        user = current_user = self.get_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")
+
+            user = self.find_user(for_user)
+            if user is None:
+                raise web.HTTPError(404, "No such user: %s" % for_user)
+
         if not self.allow_named_servers and user.running:
             url = user.url
             self.log.debug("User is running: %s", url)
             self.redirect(url)
             return
         if user.spawner.options_form:
-            form = yield self._render_form()
+            form = yield self._render_form(for_user=user)
             self.finish(form)
         else:
             # Explicit spawn request: clear _spawn_future
@@ -125,9 +133,15 @@ def get(self):
 
     @web.authenticated
     @gen.coroutine
-    def post(self):
+    def post(self, for_user=None):
         """POST spawns with user-specified options"""
-        user = self.get_current_user()
+        user = current_user = self.get_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")
+            user = self.find_user(for_user)
+            if user is None:
+                raise web.HTTPError(404, "No such user: %s" % for_user)
         if not self.allow_named_servers and user.running:
             url = user.url
             self.log.warning("User is already running: %s", url)
@@ -147,9 +161,11 @@ def post(self):
             yield self.spawn_single_user(user, options=options)
         except Exception as e:
             self.log.error("Failed to spawn single-user server with form", exc_info=True)
-            self.finish(self._render_form(str(e)))
+            form = yield self._render_form(message=str(e), for_usr=user)
+            self.finish(form)
             return
-        self.set_login_cookie(user)
+        if current_user is user:
+            self.set_login_cookie(user)
         url = user.url
 
         next_url = self.get_argument('next', '')
@@ -160,6 +176,7 @@ def post(self):
 
         self.redirect(url)
 
+
 class AdminHandler(BaseHandler):
     """Render the admin page."""
 
@@ -268,6 +285,7 @@ def get(self, status_code_s):
     (r'/home', HomeHandler),
     (r'/admin', AdminHandler),
     (r'/spawn', SpawnHandler),
+    (r'/spawn/([^/]+)', SpawnHandler),
     (r'/token', TokenPageHandler),
     (r'/error/(\d+)', ProxyErrorHandler),
 ]


Test patch:
diff --git a/jupyterhub/tests/conftest.py b/jupyterhub/tests/conftest.py
--- a/jupyterhub/tests/conftest.py
+++ b/jupyterhub/tests/conftest.py
@@ -50,6 +50,7 @@ def _close():
     request.addfinalizer(_close)
     return io_loop
 
+
 @fixture(scope='module')
 def app(request, io_loop):
     """Mock a jupyterhub app for testing"""
@@ -129,7 +130,7 @@ def mockservice_url(request, app):
 @fixture
 def no_patience(app):
     """Set slow-spawning timeouts to zero"""
-    with mock.patch.dict(app.tornado_application.settings,
+    with mock.patch.dict(app.tornado_settings,
                          {'slow_spawn_timeout': 0,
                           'slow_stop_timeout': 0}):
         yield
@@ -166,3 +167,10 @@ def slow_bad_spawn(app):
                          {'spawner_class': mocking.SlowBadSpawner}):
         yield
 
+
+@fixture
+def admin_access(app):
+    """Grant admin-access with this fixture"""
+    with mock.patch.dict(app.tornado_settings,
+                         {'admin_access': True}):
+        yield
diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py
--- a/jupyterhub/tests/mocking.py
+++ b/jupyterhub/tests/mocking.py
@@ -214,6 +214,11 @@ def init_signal(self):
 
     def load_config_file(self, *args, **kwargs):
         pass
+    def init_tornado_application(self):
+        """Instantiate the tornado Application object"""
+        super().init_tornado_application()
+        # reconnect tornado_settings so that mocks can update the real thing
+        self.tornado_settings = self.users.settings = self.tornado_application.settings
 
     @gen.coroutine
     def initialize(self, argv=None):
diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py
--- a/jupyterhub/tests/test_api.py
+++ b/jupyterhub/tests/test_api.py
@@ -67,8 +67,7 @@ def add_user(db, app=None, **kwargs):
             setattr(orm_user, attr, value)
     db.commit()
     if app:
-        user = app.users[orm_user.id] = User(orm_user, app.tornado_settings)
-        return user
+        return app.users[orm_user.id]
     else:
         return orm_user
 
@@ -519,7 +518,6 @@ def test_never_spawn(app, no_patience, never_spawn):
 
 @mark.gen_test
 def test_bad_spawn(app, no_patience, bad_spawn):
-    settings = app.tornado_application.settings
     db = app.db
     name = 'prim'
     user = add_user(db, app=app, name=name)
@@ -530,7 +528,6 @@ def test_bad_spawn(app, no_patience, bad_spawn):
 
 @mark.gen_test
 def test_slow_bad_spawn(app, no_patience, slow_bad_spawn):
-    settings = app.tornado_application.settings
     db = app.db
     name = 'zaphod'
     user = add_user(db, app=app, name=name)
@@ -546,11 +543,10 @@ def test_slow_bad_spawn(app, no_patience, slow_bad_spawn):
 @mark.gen_test
 def test_spawn_limit(app, no_patience, slow_spawn, request):
     db = app.db
-    settings = app.tornado_application.settings
-    settings['concurrent_spawn_limit'] = 2
-    def _restore_limit():
-        settings['concurrent_spawn_limit'] = 100
-    request.addfinalizer(_restore_limit)
+    p = mock.patch.dict(app.tornado_settings,
+                   {'concurrent_spawn_limit': 2})
+    p.start()
+    request.addfinalizer(p.stop)
 
     # start two pending spawns
     names = ['ykka', 'hjarka']
@@ -598,11 +594,10 @@ def _restore_limit():
 @mark.gen_test
 def test_active_server_limit(app, request):
     db = app.db
-    settings = app.tornado_application.settings
-    settings['active_server_limit'] = 2
-    def _restore_limit():
-        settings['active_server_limit'] = 0
-    request.addfinalizer(_restore_limit)
+    p = mock.patch.dict(app.tornado_settings,
+                   {'active_server_limit': 2})
+    p.start()
+    request.addfinalizer(p.stop)
 
     # start two pending spawns
     names = ['ykka', 'hjarka']
diff --git a/jupyterhub/tests/test_named_servers.py b/jupyterhub/tests/test_named_servers.py
--- a/jupyterhub/tests/test_named_servers.py
+++ b/jupyterhub/tests/test_named_servers.py
@@ -1,4 +1,6 @@
 """Tests for named servers"""
+from unittest import mock
+
 import pytest
 
 from ..utils import url_path_join
@@ -9,12 +11,9 @@
 
 @pytest.fixture
 def named_servers(app):
-    key = 'allow_named_servers'
-    app.tornado_application.settings[key] = app.tornado_settings[key] = True
-    try:
-        yield True
-    finally:
-        app.tornado_application.settings[key] = app.tornado_settings[key] = False
+    with mock.patch.dict(app.tornado_settings,
+                         {'allow_named_servers': True}):
+        yield
 
 
 @pytest.mark.gen_test
diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py
--- a/jupyterhub/tests/test_pages.py
+++ b/jupyterhub/tests/test_pages.py
@@ -13,7 +13,7 @@
 import pytest
 
 from .mocking import FormSpawner, public_url, public_host
-from .test_api import api_request
+from .test_api import api_request, add_user
 from .utils import async_requests
 
 def get_page(path, app, hub=True, **kw):
@@ -109,20 +109,20 @@ def test_spawn_redirect(app):
     name = 'wash'
     cookies = yield app.login_user(name)
     u = app.users[orm.User.find(app.db, name)]
-    
+
     # ensure wash's server isn't running:
     r = yield api_request(app, 'users', name, 'server', method='delete', cookies=cookies)
     r.raise_for_status()
     status = yield u.spawner.poll()
     assert status is not None
-    
+
     # test spawn page when no server is running
     r = yield get_page('spawn', app, cookies=cookies)
     r.raise_for_status()
     print(urlparse(r.url))
     path = urlparse(r.url).path
     assert path == ujoin(app.base_url, 'user/%s/' % name)
-    
+
     # should have started server
     status = yield u.spawner.poll()
     assert status is None
@@ -145,6 +145,22 @@ def test_spawn_redirect(app):
     assert path == ujoin(app.base_url, '/user/%s/' % name)
 
 
+@pytest.mark.gen_test
+def test_spawn_admin_access(app, admin_access):
+    """GET /user/:name as admin with admin-access spawns user's server"""
+    cookies = yield app.login_user('admin')
+    name = 'mariel'
+    user = add_user(app.db, app=app, name=name)
+    app.db.commit()
+    r = yield get_page('user/' + name, app, cookies=cookies)
+    r.raise_for_status()
+    assert (r.url.split('?')[0] + '/').startswith(public_url(app, user))
+    r = yield get_page('user/{}/env'.format(name), app, hub=False, cookies=cookies)
+    r.raise_for_status()
+    env = r.json()
+    assert env['JUPYTERHUB_USER'] == name
+
+
 @pytest.mark.gen_test
 def test_spawn_page(app):
     with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
@@ -158,6 +174,17 @@ def test_spawn_page(app):
         assert FormSpawner.options_form in r.text
 
 
+@pytest.mark.gen_test
+def test_spawn_page_admin(app, admin_access):
+    with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
+        cookies = yield app.login_user('admin')
+        u = add_user(app.db, app=app, name='melanie')
+        r = yield get_page('spawn/' + u.name, app, cookies=cookies)
+        assert r.url.endswith('/spawn/' + u.name)
+        assert FormSpawner.options_form in r.text
+        assert "Spawning server for {}".format(u.name) in r.text
+
+
 @pytest.mark.gen_test
 def test_spawn_form(app):
     with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
@@ -166,15 +193,13 @@ def test_spawn_form(app):
         orm_u = orm.User.find(app.db, 'jones')
         u = app.users[orm_u]
         yield u.stop()
-    
+
         r = yield async_requests.post(ujoin(base_url, 'spawn?next=/user/jones/tree'), cookies=cookies, data={
             'bounds': ['-1', '1'],
             'energy': '511keV',
         })
         r.raise_for_status()
         assert r.history
-        print(u.spawner)
-        print(u.spawner.user_options)
         assert u.spawner.user_options == {
             'energy': '511keV',
             'bounds': [-1, 1],
@@ -182,9 +207,32 @@ def test_spawn_form(app):
         }
 
 
+@pytest.mark.gen_test
+def test_spawn_form_admin_access(app, admin_access):
+    with mock.patch.dict(app.tornado_settings, {'spawner_class': FormSpawner}):
+        base_url = ujoin(public_host(app), app.hub.base_url)
+        cookies = yield app.login_user('admin')
+        u = add_user(app.db, app=app, name='martha')
+
+        r = yield async_requests.post(
+            ujoin(base_url, 'spawn/{0}?next=/user/{0}/tree'.format(u.name)),
+            cookies=cookies, data={
+            'bounds': ['-3', '3'],
+            'energy': '938MeV',
+        })
+        r.raise_for_status()
+        assert r.history
+        assert r.url.startswith(public_url(app, u))
+        assert u.spawner.user_options == {
+            'energy': '938MeV',
+            'bounds': [-3, 3],
+            'notspecified': 5,
+        }
+
+
 @pytest.mark.gen_test
 def test_spawn_form_with_file(app):
-    with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
+    with mock.patch.dict(app.tornado_settings, {'spawner_class': FormSpawner}):
         base_url = ujoin(public_host(app), app.hub.base_url)
         cookies = yield app.login_user('jones')
         orm_u = orm.User.find(app.db, 'jones')
@@ -338,7 +386,7 @@ def get(self):
     authenticator = Authenticator(auto_login=True)
     authenticator.login_url = lambda base_url: ujoin(base_url, 'dummy')
 
-    with mock.patch.dict(app.tornado_application.settings, {
+    with mock.patch.dict(app.tornado_settings, {
         'authenticator': authenticator,
     }):
         r = yield async_requests.get(base_url)
@@ -349,7 +397,7 @@ def test_auto_login_logout(app):
     name = 'burnham'
     cookies = yield app.login_user(name)
 
-    with mock.patch.dict(app.tornado_application.settings, {
+    with mock.patch.dict(app.tornado_settings, {
         'authenticator': Authenticator(auto_login=True),
     }):
         r = yield async_requests.get(public_host(app) + app.tornado_settings['logout_url'], cookies=cookies)