Squashed 'modules/libjuju/' changes from c50c361..c127833

c127833 Bump version and changelog for release
6aff679 k8s bundles no longer have application placement (#293)
1de9ad1 Add retry for connection if all endpoints fail (#288)
8cb8d75 Support generation of registration string for model sharing. (#279)
a9e2fd6 Add Twine for dist upload on release (#284)
407a6a6 Update and prepare for 0.11.2 release (#282)
d102620 call related update credential cloud facade methods based on facade version (#281)
2acbdc4 Add test case for redirect during connect (#275)
35fb43e Implement App.get_resources and pinned resources in bundles (#278)
b5ba51a Bump version and changelog for release
7a73a0a Fix bundles with subordinates for Juju <2.5 (#277)
a0f950f Bump version and changelog for release
01125e2 Updates for new Juju version (#274)
87d9388 Fix wrong variable name in revoke_model function (#271)
2b43065 Bump version and changelog for release
98ee524 set include_stats to false to reduce request time (#266)
61e1d69 Update version and changelog for 0.10.1
82f9968 Retry ssh in manual provision test (#265)
d64bfff Clean up lint and add lint coverage to travis config (#263)
c7c5c54 Increase the timeout for charmstore connections (#262)
4a6e398 Fix log level of `Driver connected to juju` message (#258)
514e479 Update version and changelog for 0.10.0
ec2c493 Reorder scp parameters (#259) (#260)
26c86c8 Implement set/get model constraints (#253)
c6b4ab4 Update version and changelog for 0.9.1
e863746 Update websockets to 6.0 (#254)
567bc1a Update version and changelog for 0.9.0
b275ced python3.7 compatibility updates (#251)
bc7336a Handle juju not installed in is_bootstrapped. (#250)
1ce8e0b Add app.reset_config(list). (#249)
c620d4f Implement model.get_action_status (#248)
96ea3c4 Fix `make client` in Python 3.6 (#247)
61969ea Update version and changelog for release
ebf6882 Add support for adding a manual (ssh) machine (#240)
18422f4 Backwards compatibility fixes (#213)
40c0211 Implement model.get_action_output (#242)
c6b8ac5 Fix JSON serialization error for bundle with lxd to unit placement (#243)
5014fc3 Fix reference in docs to connect_current (#239)
ebe0193 Wrap machine agent status workaround in version check (#238)
462989b Convert seconds to nanoseconds for juju.unit.run (#237)
0f413e6 Fix spurious intermittent failure in test_machines.py::test_status (#236)
ce36b60 Define an unused juju-zfs lxd storage pool (#235)
dfc2e8d Add support for Application get_actions (#234)
e7e8c13 Update version and changelog for release
499337b Surface errors from bundle plan (#233)
2d94186 Always send auth-tag even with macaroon auth (#217)
000355c Inline jsonfile credential when sending to controller (#231)
9805123 Bump VERSION and changelog for release
27d723b Always parse tags and spaces constraints to lists (#228)
668945a Doc index improvements (#211)
65e6b5e Add doc req to force newer pymacaroons to fix RTD builds
e2abd47 Fix dependency conflict for building docs
2907a6e Bump VERSION and changelog for 0.7.3 release
37a7500 Full macaroon bakery support (#206)
a06e313 Fix regression with deploying local charm, add test case (#209)
75e9a2b Expose a machines series (#208)
46c98f5 Revert non-functional switch to Py3.6, just specify Py3 instead (#205)
8a99ad1 Cherry-pick VERSION and changelog bump from 0.7.2 release branch
88121d6 Support deploying bundle YAML file directly (rather than just directory) (#202)
57c0dbf Cherry-pick #197 into master (#198)
0973edc Update VERSION and changelog for 0.7.0
f5a4108 Add deprecated placeholder for Controller.get_models
17dffa4 JujuData abstract base class (#194)
76f22cc Make Model and Controller connect methods backwardly compatible (#196)
19b5658 Fix race condition in adding relations (#192)
978f35c refactor connections prior to bakery authentication (#187)
77c0f04 sort all imports; lint tests (#188)
4740935 juju.client.gocookies: new module (#186)
2c4de22 all: use pyrfc3339 instead of dateutil (#185)
7133ffe juju/client: factor out JujuData class (#182)
476b832 Fix race condition in connection monitor test (#183)
e64a5d1 Fix example in README (#178)
97355cc Fix rare hang during Unit.run (#177)
ae0b091 #176: Fix licensing quirks
c0d001b Refactor model handling (#171)
ab807c8 Refactor users handling, add get_users (#170)
5270db5 Upload credential to controller when adding model (#168)
16d8390 Support 'applications' key in bundles (#165)
2de3eed Improve handling of thread error handling for loop.run() (#169)
7807023 Fix encoding when using to_json() (#166)
73effb1 Fix intermittent test failures (#167)
46da148 Update VERSION and changelog for release
3dda1dc Fix test failures (#163)
14392af removing cli command to add ssh keys (#161)
ce68170 Make Application.upgrade_charm upgrade resources (#158)

git-subtree-dir: modules/libjuju
git-subtree-split: c12783304945fdff5c28397b82b535a9cc065ca3
diff --git a/tests/integration/bundle/bundle-resource-rev.yaml b/tests/integration/bundle/bundle-resource-rev.yaml
new file mode 100644
index 0000000..c8feb83
--- /dev/null
+++ b/tests/integration/bundle/bundle-resource-rev.yaml
@@ -0,0 +1,7 @@
+series: xenial
+services:
+  ghost:
+    charm: "cs:ghost-19"
+    num_units: 1
+    resources:
+      ghost-stable: 11
diff --git a/tests/integration/cert.pem b/tests/integration/cert.pem
new file mode 100644
index 0000000..684029e
--- /dev/null
+++ b/tests/integration/cert.pem
@@ -0,0 +1,33 @@
+-----BEGIN CERTIFICATE-----
+MIIFxDCCA6ygAwIBAgIJAPBHQW1VVLhYMA0GCSqGSIb3DQEBCwUAMHcxCzAJBgNV
+BAYTAlVTMRcwFQYDVQQIDA5Ob3J0aCBDYXJvbGluYTEQMA4GA1UEBwwHUmFsZWln
+aDESMBAGA1UECgwJQ2Fub25pY2FsMSkwJwYDVQQDDCBweXRob24tbGlianVqdS5p
+bnRlZ3JhdGlvbi50ZXN0czAeFw0xODEyMTEyMDQyMThaFw0xOTEyMTEyMDQyMTha
+MHcxCzAJBgNVBAYTAlVTMRcwFQYDVQQIDA5Ob3J0aCBDYXJvbGluYTEQMA4GA1UE
+BwwHUmFsZWlnaDESMBAGA1UECgwJQ2Fub25pY2FsMSkwJwYDVQQDDCBweXRob24t
+bGlianVqdS5pbnRlZ3JhdGlvbi50ZXN0czCCAiIwDQYJKoZIhvcNAQEBBQADggIP
+ADCCAgoCggIBANkkfNI4Q3eIIrXZF3xxnD9Bbp06jPeoEokUSrC98L5XwCtxs9Ma
+fWPqiSukPq3A0pPcRobFOu5o+pu6+1pFjcmtKICsp+CwHV52+IfozvNlhRMUFPb2
+IxU86tgpMcBAM5wLz85DoNSu4EjOku0rB8Z0GMfvvuc7aYtDQoKJSScHGWWFTaJT
+mtXmmKKSPtyPZ5PCQOv2Qmg5ujs4RS3R8dq4WRAd5F8YzRBgmtPYRrjwgEjD7GTq
+Xlt+COqn5+RgWFIH0pQ1xHkslBzBByzsyjLJPdV/ugNIZk6GNOzLAw3ynl/9xGGh
+RAOrBsKVQPs3liZoGXVqKxHyNgWh8V58TgiFDuRZiZwQDV6cRkxOkGQIXohkS7uC
+5OXrAsyyVPrRoWfQMggxM1jH3H+93PoRqEsLypLxw53h9Seep4tOggw/hgOolLFg
+diAT/IU3yhESXnXpAA8G2rDKO32hw+vWuaGCbH6bShb+9ZfkAmLUiSVm9ClbkZRQ
+IaXmtBwsLS2IBxW51jrjSuXvqlBZc01pIUC2CT7aTrbftpA+w6i1bZpqjCukUSAx
+t4dRjzL/h/drp5xxlqXOZL9yjYl8vFul4LFKkpkJGIKESEksxULZR/3b+WA7oyiA
+Ph9UgnKU730dKVSfWbi/0tsCA/4HKIJ9qucCrfxyL8hizJ+1w9NZJ3ZbAgMBAAGj
+UzBRMB0GA1UdDgQWBBSv/7Fraw7XviWwlnPGc+CTJA0rKjAfBgNVHSMEGDAWgBSv
+/7Fraw7XviWwlnPGc+CTJA0rKjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEB
+CwUAA4ICAQCluQ7qFgNoGPkAkdoDXN26OhQcfrCmjvPufurs8Fc4Yxumupz01Df+
+zu3J2pG+tfThi37sRZsxN/jhtZOVgaXPSgpgB8Tz16YCK3KTEHXil/i6wp4JGmnQ
+VsOo8cGLEvcmFg98/l2Tu31IXaSm7Fc/VxVB0fhzvIa0xG+osU3Dy4GWhDQrkkJ8
+MErTvEAb8QnY1y20ThPAZ5StBZ/ZLwPksuzkvJwGN8FI00k1RCznDNArzKouuZZt
+JdfjnFtT+nrP0XSAlphfI0o7Gbm6jqVLPIekp8iPJuAmk55biZ9rAJBu8DsHtBpQ
+cbpH+84y/xaC9svPgD9Un6dDDYXQ4QZL393oZW1RM4apR9gOz/8NxW2bJyPVxA3M
+nmHphZjyGrOLN/QjrZV93Dv65SIivzT2zYEztvK4rjui31J3VDeNEe7uMFGq5Pmq
+7HRrcPY2sMSOHkzOTFmd5qwaPD9EzkM5UT5YJDEE7YUjkR8LlxP5Xvo82HXxKl8D
+8Tk09HuBMWg69QjuVThBNJSWpIxTbQEfm/2j+fm1L1kE9N7+97y69eDTNdOxJK5D
+zaKoAjgoneZZzZN6fblfbQuAsDdJ2xLR0DTBlxA9Gv7OYbUpXm3eAyfYL4AmqvHI
+HtfgPeSH2+wQVteiVa7q0BIto75XE3m8+xIOQsAUEb04fcacEpKZlw==
+-----END CERTIFICATE-----
diff --git a/tests/integration/key.pem b/tests/integration/key.pem
new file mode 100644
index 0000000..664123e
--- /dev/null
+++ b/tests/integration/key.pem
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+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
+hg+Fb1z8af0WL9PCAwsWQMTaSx88tQ==
+-----END PRIVATE KEY-----
diff --git a/tests/integration/test_application.py b/tests/integration/test_application.py
index 1a4fcaa..b705832 100644
--- a/tests/integration/test_application.py
+++ b/tests/integration/test_application.py
@@ -1,3 +1,5 @@
+import asyncio
+
 import pytest
 
 from .. import base
@@ -10,9 +12,9 @@
 async def test_action(event_loop):
     async with base.CleanModel() as model:
         ubuntu_app = await model.deploy(
-            'mysql',
+            'percona-cluster',
             application_name='mysql',
-            series='trusty',
+            series='xenial',
             channel='stable',
             config={
                 'tuning-level': 'safest',
@@ -27,11 +29,20 @@
         config = await ubuntu_app.get_config()
         assert config['tuning-level']['value'] == 'fast'
 
+        # Restore config back to default
+        await ubuntu_app.reset_config(['tuning-level'])
+        config = await ubuntu_app.get_config()
+        assert config['tuning-level']['value'] == 'safest'
+
         # update and check app constraints
         await ubuntu_app.set_constraints({'mem': 512 * MB})
         constraints = await ubuntu_app.get_constraints()
         assert constraints['mem'] == 512 * MB
 
+        # check action definitions
+        actions = await ubuntu_app.get_actions()
+        assert 'backup' in actions.keys()
+
 
 @base.bootstrapped
 @pytest.mark.asyncio
@@ -92,3 +103,32 @@
         assert app.data['charm-url'] == 'cs:ubuntu-0'
         await app.upgrade_charm(switch='ubuntu-8')
         assert app.data['charm-url'] == 'cs:ubuntu-8'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_upgrade_charm_resource(event_loop):
+    async with base.CleanModel() as model:
+        app = await model.deploy('cs:~cynerva/upgrade-charm-resource-test-1')
+
+        def units_ready():
+            if not app.units:
+                return False
+            unit = app.units[0]
+            return unit.workload_status == 'active' and \
+                unit.agent_status == 'idle'
+
+        await asyncio.wait_for(model.block_until(units_ready), timeout=480)
+        unit = app.units[0]
+        expected_message = 'I have no resource.'
+        assert unit.workload_status_message == expected_message
+
+        await app.upgrade_charm(revision=2)
+        await asyncio.wait_for(
+            model.block_until(
+                lambda: unit.workload_status_message != 'I have no resource.'
+            ),
+            timeout=60
+        )
+        expected_message = 'My resource: I am the resource.'
+        assert app.units[0].workload_status_message == expected_message
diff --git a/tests/integration/test_client.py b/tests/integration/test_client.py
index e4c9c92..240c471 100644
--- a/tests/integration/test_client.py
+++ b/tests/integration/test_client.py
@@ -1,7 +1,7 @@
-import pytest
-
 from juju.client import client
 
+import pytest
+
 from .. import base
 
 
@@ -9,7 +9,7 @@
 @pytest.mark.asyncio
 async def test_user_info(event_loop):
     async with base.CleanModel() as model:
-        controller_conn = await model.connection.controller()
+        controller_conn = await model.connection().controller()
 
         um = client.UserManagerFacade.from_connection(controller_conn)
         result = await um.UserInfo(
diff --git a/tests/integration/test_connection.py b/tests/integration/test_connection.py
index 290203d..6647a03 100644
--- a/tests/integration/test_connection.py
+++ b/tests/integration/test_connection.py
@@ -1,28 +1,30 @@
 import asyncio
-import pytest
+import http
+import logging
+import socket
+import ssl
+from contextlib import closing
+from pathlib import Path
 
-from juju.client.connection import Connection
 from juju.client import client
+from juju.client.connection import Connection
+from juju.controller import Controller
+from juju.utils import run_with_interrupt
+
+import pytest
+import websockets
+
 from .. import base
 
 
-@base.bootstrapped
-@pytest.mark.asyncio
-async def test_connect_current(event_loop):
-    async with base.CleanModel():
-        conn = await Connection.connect_current()
-
-        assert isinstance(conn, Connection)
-        await conn.close()
+logger = logging.getLogger(__name__)
 
 
 @base.bootstrapped
 @pytest.mark.asyncio
 async def test_monitor(event_loop):
-
-    async with base.CleanModel():
-        conn = await Connection.connect_current()
-
+    async with base.CleanModel() as model:
+        conn = model.connection()
         assert conn.monitor.status == 'connected'
         await conn.close()
 
@@ -33,15 +35,17 @@
 @pytest.mark.asyncio
 async def test_monitor_catches_error(event_loop):
 
-    async with base.CleanModel():
-        conn = await Connection.connect_current()
+    async with base.CleanModel() as model:
+        conn = model.connection()
 
         assert conn.monitor.status == 'connected'
-        await conn.ws.close()
-
-        assert conn.monitor.status == 'error'
-
-        await conn.close()
+        try:
+            async with conn.monitor.reconnecting:
+                await conn.ws.close()
+                await asyncio.sleep(1)
+                assert conn.monitor.status == 'error'
+        finally:
+            await conn.close()
 
 
 @base.bootstrapped
@@ -55,7 +59,7 @@
             channel='stable',
         )
 
-        c = client.ClientFacade.from_connection(model.connection)
+        c = client.ClientFacade.from_connection(model.connection())
 
         await c.FullStatus(None)
 
@@ -64,15 +68,8 @@
 @pytest.mark.asyncio
 async def test_reconnect(event_loop):
     async with base.CleanModel() as model:
-        conn = await Connection.connect(
-            model.connection.endpoint,
-            model.connection.uuid,
-            model.connection.username,
-            model.connection.password,
-            model.connection.cacert,
-            model.connection.macaroons,
-            model.connection.loop,
-            model.connection.max_frame_size)
+        kwargs = model.connection().connect_params()
+        conn = await Connection.connect(**kwargs)
         try:
             await asyncio.sleep(0.1)
             assert conn.is_open
@@ -81,3 +78,159 @@
             await model.block_until(lambda: conn.is_open, timeout=3)
         finally:
             await conn.close()
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_redirect(event_loop):
+    controller = Controller()
+    await controller.connect()
+    kwargs = controller.connection().connect_params()
+    await controller.disconnect()
+
+    # websockets.server.logger.setLevel(logging.DEBUG)
+    # websockets.client.logger.setLevel(logging.DEBUG)
+    # # websockets.protocol.logger.setLevel(logging.DEBUG)
+    # logger.setLevel(logging.DEBUG)
+
+    destination = 'wss://{}/api'.format(kwargs['endpoint'])
+    redirect_statuses = [
+        http.HTTPStatus.MOVED_PERMANENTLY,
+        http.HTTPStatus.FOUND,
+        http.HTTPStatus.SEE_OTHER,
+        http.HTTPStatus.TEMPORARY_REDIRECT,
+        http.HTTPStatus.PERMANENT_REDIRECT,
+    ]
+    test_server_cert = Path(__file__).with_name('cert.pem')
+    kwargs['cacert'] += '\n' + test_server_cert.read_text()
+    server = RedirectServer(destination, event_loop)
+    try:
+        for status in redirect_statuses:
+            logger.debug('test: starting {}'.format(status))
+            server.start(status)
+            await run_with_interrupt(server.running.wait(),
+                                     server.terminated)
+            if server.exception:
+                raise server.exception
+            assert not server.terminated.is_set()
+            logger.debug('test: started')
+            kwargs_copy = dict(kwargs,
+                               endpoint='localhost:{}'.format(server.port))
+            logger.debug('test: connecting')
+            conn = await Connection.connect(**kwargs_copy)
+            logger.debug('test: connected')
+            await conn.close()
+            logger.debug('test: stopping')
+            server.stop()
+            await server.stopped.wait()
+            logger.debug('test: stopped')
+    finally:
+        server.terminate()
+        await server.terminated.wait()
+
+
+class RedirectServer:
+    def __init__(self, destination, loop):
+        self.destination = destination
+        self.loop = loop
+        self._start = asyncio.Event()
+        self._stop = asyncio.Event()
+        self._terminate = asyncio.Event()
+        self.running = asyncio.Event()
+        self.stopped = asyncio.Event()
+        self.terminated = asyncio.Event()
+        if hasattr(ssl, 'PROTOCOL_TLS_SERVER'):
+            # python 3.6+
+            protocol = ssl.PROTOCOL_TLS_SERVER
+        elif hasattr(ssl, 'PROTOCOL_TLS'):
+            # python 3.5.3+
+            protocol = ssl.PROTOCOL_TLS
+        else:
+            # python 3.5.2
+            protocol = ssl.PROTOCOL_TLSv1_2
+        self.ssl_context = ssl.SSLContext(protocol)
+        crt_file = Path(__file__).with_name('cert.pem')
+        key_file = Path(__file__).with_name('key.pem')
+        self.ssl_context.load_cert_chain(str(crt_file), str(key_file))
+        self.status = None
+        self.port = None
+        self._task = self.loop.create_task(self.run())
+
+    def start(self, status):
+        self.status = status
+        self.port = self._find_free_port()
+        self._start.set()
+
+    def stop(self):
+        self._stop.set()
+
+    def terminate(self):
+        self._terminate.set()
+        self.stop()
+
+    @property
+    def exception(self):
+        try:
+            return self._task.exception()
+        except (asyncio.CancelledError, asyncio.InvalidStateError):
+            return None
+
+    async def run(self):
+        logger.debug('server: active')
+
+        async def hello(websocket, path):
+            await websocket.send('hello')
+
+        async def redirect(path, request_headers):
+            return self.status, {'Location': self.destination}, b""
+
+        try:
+            while not self._terminate.is_set():
+                await run_with_interrupt(self._start.wait(),
+                                         self._terminate,
+                                         loop=self.loop)
+                if self._terminate.is_set():
+                    break
+                self._start.clear()
+                logger.debug('server: starting {}'.format(self.status))
+                try:
+                    async with websockets.serve(ws_handler=hello,
+                                                process_request=redirect,
+                                                host='localhost',
+                                                port=self.port,
+                                                ssl=self.ssl_context,
+                                                loop=self.loop):
+                        self.stopped.clear()
+                        self.running.set()
+                        logger.debug('server: started')
+                        while not self._stop.is_set():
+                            await run_with_interrupt(
+                                asyncio.sleep(1, loop=self.loop),
+                                self._stop,
+                                loop=self.loop)
+                            logger.debug('server: tick')
+                        logger.debug('server: stopping')
+                except asyncio.CancelledError:
+                    break
+                finally:
+                    self.stopped.set()
+                    self._stop.clear()
+                    self.running.clear()
+                    logger.debug('server: stopped')
+            logger.debug('server: terminating')
+        except asyncio.CancelledError:
+            pass
+        finally:
+            self._start.clear()
+            self._stop.clear()
+            self._terminate.clear()
+            self.stopped.set()
+            self.running.clear()
+            self.terminated.set()
+            logger.debug('server: terminated')
+
+    def _find_free_port(self):
+        with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
+            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+            s.bind(('', 0))
+            return s.getsockname()[1]
diff --git a/tests/integration/test_controller.py b/tests/integration/test_controller.py
index f3840cc..6423a98 100644
--- a/tests/integration/test_controller.py
+++ b/tests/integration/test_controller.py
@@ -1,20 +1,35 @@
-import pytest
+import asyncio
+import subprocess
 import uuid
 
-from .. import base
+from juju.client.connection import Connection
+from juju.client.jujudata import FileJujuData
 from juju.controller import Controller
 from juju.errors import JujuAPIError
 
+import pytest
+
+from .. import base
+
 
 @base.bootstrapped
 @pytest.mark.asyncio
-async def test_add_user(event_loop):
+async def test_add_remove_user(event_loop):
     async with base.CleanController() as controller:
         username = 'test{}'.format(uuid.uuid4())
-        await controller.add_user(username)
-        result = await controller.get_user(username)
-        res_ser = result.serialize()['results'][0].serialize()
-        assert res_ser['result'] is not None
+        user = await controller.get_user(username)
+        assert user is None
+        user = await controller.add_user(username)
+        assert user is not None
+        assert user.secret_key is not None
+        assert user.username == username
+        users = await controller.get_users()
+        assert any(u.username == username for u in users)
+        await controller.remove_user(username)
+        user = await controller.get_user(username)
+        assert user is None
+        users = await controller.get_users()
+        assert not any(u.username == username for u in users)
 
 
 @base.bootstrapped
@@ -22,15 +37,23 @@
 async def test_disable_enable_user(event_loop):
     async with base.CleanController() as controller:
         username = 'test-disable{}'.format(uuid.uuid4())
-        await controller.add_user(username)
-        await controller.disable_user(username)
-        result = await controller.get_user(username)
-        res_ser = result.serialize()['results'][0].serialize()
-        assert res_ser['result'].serialize()['disabled'] is True
-        await controller.enable_user(username)
-        result = await controller.get_user(username)
-        res_ser = result.serialize()['results'][0].serialize()
-        assert res_ser['result'].serialize()['disabled'] is False
+        user = await controller.add_user(username)
+
+        await user.disable()
+        assert not user.enabled
+        assert user.disabled
+
+        fresh = await controller.get_user(username)  # fetch fresh copy
+        assert not fresh.enabled
+        assert fresh.disabled
+
+        await user.enable()
+        assert user.enabled
+        assert not user.disabled
+
+        fresh = await controller.get_user(username)  # fetch fresh copy
+        assert fresh.enabled
+        assert not fresh.disabled
 
 
 @base.bootstrapped
@@ -38,40 +61,168 @@
 async def test_change_user_password(event_loop):
     async with base.CleanController() as controller:
         username = 'test-password{}'.format(uuid.uuid4())
-        await controller.add_user(username)
-        await controller.change_user_password(username, 'password')
+        user = await controller.add_user(username)
+        await user.set_password('password')
+        # Check that we can connect with the new password.
+        new_connection = None
         try:
-            new_controller = Controller()
-            await new_controller.connect(
-                controller.connection.endpoint, username, 'password')
-            result = True
-            await new_controller.disconnect()
+            kwargs = controller.connection().connect_params()
+            kwargs['username'] = username
+            kwargs['password'] = 'password'
+            new_connection = await Connection.connect(**kwargs)
         except JujuAPIError:
-            result = False
-        assert result is True
+            raise AssertionError('Unable to connect with new password')
+        finally:
+            if new_connection:
+                await new_connection.close()
 
 
 @base.bootstrapped
 @pytest.mark.asyncio
-async def test_grant(event_loop):
+async def test_reset_user_password(event_loop):
+    async with base.CleanController() as controller:
+        username = 'test{}'.format(uuid.uuid4())
+        user = await controller.add_user(username)
+        origin_secret_key = user.secret_key
+        await user.set_password('password')
+        await controller.reset_user_password(username)
+        user = await controller.get_user(username)
+        new_secret_key = user.secret_key
+        # Check secret key is different after the reset.
+        assert origin_secret_key != new_secret_key
+        # Check that we can't connect with the old password.
+        new_connection = None
+        try:
+            kwargs = controller.connection().connect_params()
+            kwargs['username'] = username
+            kwargs['password'] = 'password'
+            new_connection = await Connection.connect(**kwargs)
+        except JujuAPIError:
+            pass
+        finally:
+            # No connection with old password
+            assert new_connection is None
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_grant_revoke(event_loop):
     async with base.CleanController() as controller:
         username = 'test-grant{}'.format(uuid.uuid4())
-        await controller.add_user(username)
-        await controller.grant(username, 'superuser')
-        result = await controller.get_user(username)
-        result = result.serialize()['results'][0].serialize()['result']\
-            .serialize()
-        assert result['access'] == 'superuser'
-        await controller.grant(username, 'login')
-        result = await controller.get_user(username)
-        result = result.serialize()['results'][0].serialize()['result']\
-            .serialize()
-        assert result['access'] == 'login'
+        user = await controller.add_user(username)
+        await user.grant('superuser')
+        assert user.access == 'superuser'
+        fresh = await controller.get_user(username)  # fetch fresh copy
+        assert fresh.access == 'superuser'
+        await user.grant('login')  # already has 'superuser', so no-op
+        assert user.access == 'superuser'
+        fresh = await controller.get_user(username)  # fetch fresh copy
+        assert fresh.access == 'superuser'
+        await user.revoke()
+        assert user.access == ''
+        fresh = await controller.get_user(username)  # fetch fresh copy
+        assert fresh.access == ''
 
 
 @base.bootstrapped
 @pytest.mark.asyncio
-async def test_get_models(event_loop):
+async def test_list_models(event_loop):
     async with base.CleanController() as controller:
-        result = await controller.get_models()
-        assert isinstance(result.serialize()['user-models'], list)
+        async with base.CleanModel() as model:
+            result = await controller.list_models()
+            assert model.info.name in result
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_get_model(event_loop):
+    async with base.CleanController() as controller:
+        by_name, by_uuid = None, None
+        model_name = 'test-{}'.format(uuid.uuid4())
+        model = await controller.add_model(model_name)
+        model_uuid = model.info.uuid
+        await model.disconnect()
+        try:
+            by_name = await controller.get_model(model_name)
+            by_uuid = await controller.get_model(model_uuid)
+            assert by_name.info.name == model_name
+            assert by_name.info.uuid == model_uuid
+            assert by_uuid.info.name == model_name
+            assert by_uuid.info.uuid == model_uuid
+        finally:
+            if by_name:
+                await by_name.disconnect()
+            if by_uuid:
+                await by_uuid.disconnect()
+            await controller.destroy_model(model_name)
+
+
+async def _wait_for_model(controller, model_name):
+    while model_name not in await controller.list_models():
+        await asyncio.sleep(0.5, loop=controller.loop)
+
+
+async def _wait_for_model_gone(controller, model_name):
+    while model_name in await controller.list_models():
+        await asyncio.sleep(0.5, loop=controller.loop)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_destroy_model_by_name(event_loop):
+    async with base.CleanController() as controller:
+        model_name = 'test-{}'.format(uuid.uuid4())
+        model = await controller.add_model(model_name)
+        await model.disconnect()
+        await asyncio.wait_for(_wait_for_model(controller,
+                                               model_name),
+                               timeout=60)
+        await controller.destroy_model(model_name)
+        await asyncio.wait_for(_wait_for_model_gone(controller,
+                                                    model_name),
+                               timeout=60)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_add_destroy_model_by_uuid(event_loop):
+    async with base.CleanController() as controller:
+        model_name = 'test-{}'.format(uuid.uuid4())
+        model = await controller.add_model(model_name)
+        model_uuid = model.info.uuid
+        await model.disconnect()
+        await asyncio.wait_for(_wait_for_model(controller,
+                                               model_name),
+                               timeout=60)
+        await controller.destroy_model(model_uuid)
+        await asyncio.wait_for(_wait_for_model_gone(controller,
+                                                    model_name),
+                               timeout=60)
+
+
+# this test must be run serially because it modifies the login password
+@pytest.mark.serial
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_macaroon_auth(event_loop):
+    jujudata = FileJujuData()
+    account = jujudata.accounts()[jujudata.current_controller()]
+    with base.patch_file('~/.local/share/juju/accounts.yaml'):
+        if 'password' in account:
+            # force macaroon auth by "changing" password to current password
+            result = subprocess.run(
+                ['juju', 'change-user-password'],
+                input='{0}\n{0}\n'.format(account['password']),
+                universal_newlines=True,
+                stderr=subprocess.PIPE)
+            assert result.returncode == 0, ('Failed to change password: '
+                                            '{}'.format(result.stderr))
+        controller = Controller()
+        try:
+            await controller.connect()
+            assert controller.is_connected()
+        finally:
+            if controller.is_connected():
+                await controller.disconnect()
+        async with base.CleanModel():
+            pass  # create and login to model works
diff --git a/tests/integration/test_errors.py b/tests/integration/test_errors.py
index 06b3826..b10dd06 100644
--- a/tests/integration/test_errors.py
+++ b/tests/integration/test_errors.py
@@ -40,7 +40,7 @@
     from juju.client import client
 
     async with base.CleanModel() as model:
-        ann_facade = client.AnnotationsFacade.from_connection(model.connection)
+        ann_facade = client.AnnotationsFacade.from_connection(model.connection())
 
         ann = client.EntityAnnotations(
             entity='badtag',
@@ -58,11 +58,11 @@
     looking at a single result coming back.
 
     '''
-    from juju.errors import JujuError    
+    from juju.errors import JujuError
     from juju.client import client
 
     async with base.CleanModel() as model:
-        app_facade = client.ApplicationFacade.from_connection(model.connection)
+        app_facade = client.ApplicationFacade.from_connection(model.connection())
 
         with pytest.raises(JujuError):
             return await app_facade.GetCharmURL('foo')
diff --git a/tests/integration/test_macaroon_auth.py b/tests/integration/test_macaroon_auth.py
new file mode 100644
index 0000000..9911c41
--- /dev/null
+++ b/tests/integration/test_macaroon_auth.py
@@ -0,0 +1,108 @@
+import logging
+import os
+
+import macaroonbakery.bakery as bakery
+import macaroonbakery.httpbakery as httpbakery
+import macaroonbakery.httpbakery.agent as agent
+from juju.errors import JujuAPIError
+from juju.model import Model
+
+import pytest
+
+from .. import base
+
+log = logging.getLogger(__name__)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+@pytest.mark.xfail
+async def test_macaroon_auth(event_loop):
+    auth_info, username = agent_auth_info()
+    # Create a bakery client that can do agent authentication.
+    client = httpbakery.Client(
+        key=auth_info.key,
+        interaction_methods=[agent.AgentInteractor(auth_info)],
+    )
+
+    async with base.CleanModel(bakery_client=client) as m:
+        async with await m.get_controller() as c:
+            await c.grant_model(username, m.info.uuid, 'admin')
+        async with Model(
+            jujudata=NoAccountsJujuData(m._connector.jujudata),
+            bakery_client=client,
+        ):
+            pass
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+@pytest.mark.xfail
+async def test_macaroon_auth_with_bad_key(event_loop):
+    auth_info, username = agent_auth_info()
+    # Use a random key rather than the correct key.
+    auth_info = auth_info._replace(key=bakery.generate_key())
+    # Create a bakery client can do agent authentication.
+    client = httpbakery.Client(
+        key=auth_info.key,
+        interaction_methods=[agent.AgentInteractor(auth_info)],
+    )
+
+    async with base.CleanModel(bakery_client=client) as m:
+        async with await m.get_controller() as c:
+            await c.grant_model(username, m.info.uuid, 'admin')
+        try:
+            async with Model(
+                jujudata=NoAccountsJujuData(m._connector.jujudata),
+                bakery_client=client,
+            ):
+                pytest.fail('Should not be able to connect with invalid key')
+        except httpbakery.BakeryException:
+            # We're expecting this because we're using the
+            # wrong key.
+            pass
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_macaroon_auth_with_unauthorized_user(event_loop):
+    auth_info, username = agent_auth_info()
+    # Create a bakery client can do agent authentication.
+    client = httpbakery.Client(
+        key=auth_info.key,
+        interaction_methods=[agent.AgentInteractor(auth_info)],
+    )
+    async with base.CleanModel(bakery_client=client) as m:
+        # Note: no grant of rights to the agent user.
+        try:
+            async with Model(
+                jujudata=NoAccountsJujuData(m._connector.jujudata),
+                bakery_client=client,
+            ):
+                pytest.fail('Should not be able to connect without grant')
+        except (JujuAPIError, httpbakery.DischargeError):
+            # We're expecting this because we're using the
+            # wrong user name.
+            pass
+
+
+def agent_auth_info():
+    agent_data = os.environ.get('TEST_AGENTS')
+    if agent_data is None:
+        pytest.skip('skipping macaroon_auth because no TEST_AGENTS '
+                    'environment variable is set')
+    auth_info = agent.read_auth_info(agent_data)
+    if len(auth_info.agents) != 1:
+        raise Exception('TEST_AGENTS agent data requires exactly one agent')
+    return auth_info, auth_info.agents[0].username
+
+
+class NoAccountsJujuData:
+    def __init__(self, jujudata):
+        self.__jujudata = jujudata
+
+    def __getattr__(self, name):
+        return getattr(self.__jujudata, name)
+
+    def accounts(self):
+        return {}
diff --git a/tests/integration/test_machine.py b/tests/integration/test_machine.py
index 60de035..070208a 100644
--- a/tests/integration/test_machine.py
+++ b/tests/integration/test_machine.py
@@ -1,8 +1,8 @@
 import asyncio
-import pytest
-
 from tempfile import NamedTemporaryFile
 
+import pytest
+
 from .. import base
 
 
@@ -26,21 +26,24 @@
         assert machine.agent_status == 'pending'
         assert not machine.agent_version
 
+        # there is some inconsistency in the capitalization of status_message
+        # between different providers
         await asyncio.wait_for(
-            model.block_until(lambda: (machine.status == 'running' and
-                                       machine.agent_status == 'started')),
+            model.block_until(
+                lambda: (machine.status == 'running' and
+                         machine.status_message.lower() == 'running' and
+                         machine.agent_status == 'started')),
             timeout=480)
 
-        assert machine.status == 'running'
-        # there is some inconsistency in the message case between providers
-        assert machine.status_message.lower() == 'running'
-        assert machine.agent_status == 'started'
-        assert machine.agent_version.major >= 2
-
 
 @base.bootstrapped
 @pytest.mark.asyncio
 async def test_scp(event_loop):
+    # ensure that asyncio.subprocess will work;
+    try:
+        asyncio.get_child_watcher().attach_loop(event_loop)
+    except RuntimeError:
+        pytest.skip('test_scp will always fail outside of MainThread')
     async with base.CleanModel() as model:
         await model.add_machine()
         await asyncio.wait_for(
@@ -55,8 +58,8 @@
         with NamedTemporaryFile() as f:
             f.write(b'testcontents')
             f.flush()
-            await machine.scp_to(f.name, 'testfile')
+            await machine.scp_to(f.name, 'testfile', scp_opts='-p')
 
         with NamedTemporaryFile() as f:
-            await machine.scp_from('testfile', f.name)
+            await machine.scp_from('testfile', f.name, scp_opts='-p')
             assert f.read() == b'testcontents'
diff --git a/tests/integration/test_model.py b/tests/integration/test_model.py
index 8506786..93695b5 100644
--- a/tests/integration/test_model.py
+++ b/tests/integration/test_model.py
@@ -1,11 +1,23 @@
 import asyncio
+import mock
 from concurrent.futures import ThreadPoolExecutor
 from pathlib import Path
+import paramiko
+
+from juju.client.client import ConfigValue, ApplicationFacade
+from juju.model import Model, ModelObserver
+from juju.utils import block_until, run_with_interrupt
+from juju.errors import JujuError
+
+import os
+import pylxd
+import time
+import uuid
+
 import pytest
 
 from .. import base
-from juju.model import Model
-from juju.client.client import ConfigValue
+
 
 MB = 1
 GB = 1024
@@ -14,16 +26,59 @@
 
 @base.bootstrapped
 @pytest.mark.asyncio
-async def test_deploy_local_bundle(event_loop):
-    from pathlib import Path
+async def test_deploy_local_bundle_dir(event_loop):
     tests_dir = Path(__file__).absolute().parent.parent
     bundle_path = tests_dir / 'bundle'
 
     async with base.CleanModel() as model:
         await model.deploy(str(bundle_path))
 
-        for app in ('wordpress', 'mysql'):
-            assert app in model.applications
+        wordpress = model.applications.get('wordpress')
+        mysql = model.applications.get('mysql')
+        assert wordpress and mysql
+        await block_until(lambda: (len(wordpress.units) == 1 and
+                                   len(mysql.units) == 1),
+                          timeout=60 * 4)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_deploy_local_bundle_file(event_loop):
+    tests_dir = Path(__file__).absolute().parent.parent
+    bundle_path = tests_dir / 'bundle'
+    mini_bundle_file_path = bundle_path / 'mini-bundle.yaml'
+
+    async with base.CleanModel() as model:
+        await model.deploy(str(mini_bundle_file_path))
+
+        dummy_sink = model.applications.get('dummy-sink')
+        dummy_subordinate = model.applications.get('dummy-subordinate')
+        assert dummy_sink and dummy_subordinate
+        await block_until(lambda: (len(dummy_sink.units) == 1 and
+                                   len(dummy_subordinate.units) == 1),
+                          timeout=60 * 4)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_deploy_invalid_bundle(event_loop):
+    tests_dir = Path(__file__).absolute().parent.parent
+    bundle_path = tests_dir / 'bundle' / 'invalid.yaml'
+    async with base.CleanModel() as model:
+        with pytest.raises(JujuError):
+            await model.deploy(str(bundle_path))
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_deploy_local_charm(event_loop):
+    from pathlib import Path
+    tests_dir = Path(__file__).absolute().parent.parent
+    charm_path = tests_dir / 'charm'
+
+    async with base.CleanModel() as model:
+        await model.deploy(str(charm_path))
+        assert 'charm' in model.applications
 
 
 @base.bootstrapped
@@ -43,7 +98,7 @@
         charm = 'cs:~johnsca/libjuju-test'
         stable = await model.deploy(charm, 'a1')
         edge = await model.deploy(charm, 'a2', channel='edge')
-        rev = await model.deploy(charm+'-2', 'a3')
+        rev = await model.deploy(charm + '-2', 'a3')
 
         assert [a.charm_url for a in (stable, edge, rev)] == [
             'cs:~johnsca/libjuju-test-1',
@@ -93,6 +148,115 @@
 
 @base.bootstrapped
 @pytest.mark.asyncio
+async def test_add_manual_machine_ssh(event_loop):
+
+    # Verify controller is localhost
+    async with base.CleanController() as controller:
+        cloud = await controller.get_cloud()
+        if cloud != "localhost":
+            pytest.skip('Skipping because test requires lxd.')
+
+    async with base.CleanModel() as model:
+        private_key_path = os.path.expanduser(
+            "~/.local/share/juju/ssh/juju_id_rsa"
+        )
+        public_key_path = os.path.expanduser(
+            "~/.local/share/juju/ssh/juju_id_rsa.pub"
+        )
+
+        # connect using the local unix socket
+        client = pylxd.Client()
+
+        test_name = "test-{}-add-manual-machine-ssh".format(
+            uuid.uuid4().hex[-4:]
+        )
+
+        # create profile w/cloud-init and juju ssh key
+        public_key = ""
+        with open(public_key_path, "r") as f:
+            public_key = f.readline()
+
+        profile = client.profiles.create(
+            test_name,
+            config={'user.user-data': '#cloud-config\n'
+                                      'ssh_authorized_keys:\n'
+                                      '- {}'.format(public_key)},
+            devices={
+                'root': {'path': '/', 'pool': 'default', 'type': 'disk'},
+                'eth0': {
+                    'nictype': 'bridged',
+                    'parent': 'lxdbr0',
+                    'type': 'nic'
+                }
+            }
+        )
+
+        # create lxc machine
+        config = {
+            'name': test_name,
+            'source': {
+                'type': 'image',
+                'alias': 'xenial',
+                'mode': 'pull',
+                'protocol': 'simplestreams',
+                'server': 'https://cloud-images.ubuntu.com/releases',
+            },
+            'profiles': [test_name],
+        }
+        container = client.containers.create(config, wait=True)
+        container.start(wait=True)
+
+        def wait_for_network(container, timeout=30):
+            """Wait for eth0 to have an ipv4 address."""
+            starttime = time.time()
+            while(time.time() < starttime + timeout):
+                time.sleep(1)
+                if 'eth0' in container.state().network:
+                    addresses = container.state().network['eth0']['addresses']
+                    if len(addresses) > 0:
+                        if addresses[0]['family'] == 'inet':
+                            return addresses[0]
+            return None
+
+        host = wait_for_network(container)
+        assert host, 'Failed to get address for machine'
+
+        # HACK: We need to give sshd a chance to bind to the interface,
+        # and pylxd's container.execute seems to be broken and fails and/or
+        # hangs trying to properly check if the service is up.
+        time.sleep(5)
+
+        for attempt in range(1, 4):
+            try:
+                # add a new manual machine
+                machine1 = await model.add_machine(spec='ssh:{}@{}:{}'.format(
+                    "ubuntu",
+                    host['address'],
+                    private_key_path,
+                ))
+            except paramiko.ssh_exception.NoValidConnectionsError:
+                if attempt == 3:
+                    raise
+                # retry the ssh connection a few times if it fails
+                time.sleep(attempt * 5)
+            else:
+                break
+
+            assert len(model.machines) == 1
+
+            res = await machine1.destroy(force=True)
+
+            assert res is None
+            assert len(model.machines) == 0
+
+        container.stop(wait=True)
+        container.delete(wait=True)
+
+        profile.delete()
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
 async def test_relate(event_loop):
     from juju.relation import Relation
 
@@ -111,17 +275,44 @@
             # subordinates must be deployed without units
             num_units=0,
         )
-        my_relation = await model.add_relation(
-            'ubuntu',
-            'nrpe',
-        )
+
+        relation_added = asyncio.Event()
+        timeout = asyncio.Event()
+
+        class TestObserver(ModelObserver):
+            async def on_relation_add(self, delta, old, new, model):
+                if set(new.key.split()) == {'nrpe:general-info',
+                                            'ubuntu:juju-info'}:
+                    relation_added.set()
+                    event_loop.call_later(2, timeout.set)
+
+        model.add_observer(TestObserver())
+
+        real_app_facade = ApplicationFacade.from_connection(model.connection())
+        mock_app_facade = mock.MagicMock()
+
+        async def mock_AddRelation(*args):
+            # force response delay from AddRelation to test race condition
+            # (see https://github.com/juju/python-libjuju/issues/191)
+            result = await real_app_facade.AddRelation(*args)
+            await relation_added.wait()
+            return result
+
+        mock_app_facade.AddRelation = mock_AddRelation
+
+        with mock.patch.object(ApplicationFacade, 'from_connection',
+                               return_value=mock_app_facade):
+            my_relation = await run_with_interrupt(model.add_relation(
+                'ubuntu',
+                'nrpe',
+            ), timeout, loop=event_loop)
 
         assert isinstance(my_relation, Relation)
 
 
-async def _deploy_in_loop(new_loop, model_name):
-    new_model = Model(new_loop)
-    await new_model.connect_model(model_name)
+async def _deploy_in_loop(new_loop, model_name, jujudata):
+    new_model = Model(new_loop, jujudata=jujudata)
+    await new_model.connect(model_name)
     try:
         await new_model.deploy('cs:xenial/ubuntu')
         assert 'ubuntu' in new_model.applications
@@ -131,18 +322,6 @@
 
 @base.bootstrapped
 @pytest.mark.asyncio
-async def test_explicit_loop(event_loop):
-    async with base.CleanModel() as model:
-        model_name = model.info.name
-        new_loop = asyncio.new_event_loop()
-        new_loop.run_until_complete(
-            _deploy_in_loop(new_loop, model_name))
-        await model._wait_for_new('application', 'ubuntu')
-        assert 'ubuntu' in model.applications
-
-
-@base.bootstrapped
-@pytest.mark.asyncio
 async def test_explicit_loop_threaded(event_loop):
     async with base.CleanModel() as model:
         model_name = model.info.name
@@ -150,7 +329,9 @@
         with ThreadPoolExecutor(1) as executor:
             f = executor.submit(
                 new_loop.run_until_complete,
-                _deploy_in_loop(new_loop, model_name))
+                _deploy_in_loop(new_loop,
+                                model_name,
+                                model._connector.jujudata))
             f.result()
         await model._wait_for_new('application', 'ubuntu')
         assert 'ubuntu' in model.applications
@@ -167,7 +348,7 @@
             lambda: (
                 len(ghost.units) > 0 and
                 ghost.units[0].workload_status in terminal_statuses)
-            )
+        )
         # ghost will go in to blocked (or error, for older
         # charm revs) if the resource is missing
         assert ghost.units[0].workload_status == 'active'
@@ -186,10 +367,33 @@
             lambda: (
                 len(ghost.units) > 0 and
                 ghost.units[0].workload_status in terminal_statuses)
-            )
+        )
         # ghost will go in to blocked (or error, for older
         # charm revs) if the resource is missing
         assert ghost.units[0].workload_status == 'active'
+        resources = await ghost.get_resources()
+        assert resources['ghost-stable'].revision >= 12
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_store_resources_bundle_revs(event_loop):
+    async with base.CleanModel() as model:
+        bundle = str(Path(__file__).parent / 'bundle/bundle-resource-rev.yaml')
+        await model.deploy(bundle)
+        assert 'ghost' in model.applications
+        ghost = model.applications['ghost']
+        terminal_statuses = ('active', 'error', 'blocked')
+        await model.block_until(
+            lambda: (
+                len(ghost.units) > 0 and
+                ghost.units[0].workload_status in terminal_statuses)
+        )
+        # ghost will go in to blocked (or error, for older
+        # charm revs) if the resource is missing
+        assert ghost.units[0].workload_status == 'active'
+        resources = await ghost.get_resources()
+        assert resources['ghost-stable'].revision == 11
 
 
 @base.bootstrapped
@@ -218,9 +422,8 @@
 @pytest.mark.asyncio
 async def test_watcher_reconnect(event_loop):
     async with base.CleanModel() as model:
-        await model.connection.ws.close()
-        await asyncio.sleep(0.1)
-        assert model.connection.is_open
+        await model.connection().ws.close()
+        await block_until(model.is_connected, timeout=3)
 
 
 @base.bootstrapped
@@ -236,6 +439,15 @@
         assert result['extra-info'].source == 'model'
         assert result['extra-info'].value == 'booyah'
 
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_set_constraints(event_loop):
+    async with base.CleanModel() as model:
+        await model.set_constraints({'cpu-power': 1})
+        cons = await model.get_constraints()
+        assert cons['cpu_power'] == 1
+
 # @base.bootstrapped
 # @pytest.mark.asyncio
 # async def test_grant(event_loop)
diff --git a/tests/integration/test_unit.py b/tests/integration/test_unit.py
index 1604c31..bb34969 100644
--- a/tests/integration/test_unit.py
+++ b/tests/integration/test_unit.py
@@ -1,8 +1,8 @@
 import asyncio
-import pytest
-
 from tempfile import NamedTemporaryFile
 
+import pytest
+
 from .. import base
 
 
@@ -25,6 +25,18 @@
             assert 'Stdout' in action.results
             break
 
+        for unit in app.units:
+            action = await unit.run('sleep 1', timeout=0.5)
+            assert isinstance(action, Action)
+            assert action.status == 'failed'
+            break
+
+        for unit in app.units:
+            action = await unit.run('sleep 0.5', timeout=2)
+            assert isinstance(action, Action)
+            assert action.status == 'completed'
+            break
+
 
 @base.bootstrapped
 @pytest.mark.asyncio
@@ -46,12 +58,21 @@
         for unit in app.units:
             action = await run_action(unit)
             assert action.results == {'dir': '/var/git/myrepo.git'}
+            out = await model.get_action_output(action.entity_id, wait=5)
+            assert out == {'dir': '/var/git/myrepo.git'}
+            status = await model.get_action_status(uuid_or_prefix=action.entity_id)
+            assert status[action.entity_id] == 'completed'
             break
 
 
 @base.bootstrapped
 @pytest.mark.asyncio
 async def test_scp(event_loop):
+    # ensure that asyncio.subprocess will work;
+    try:
+        asyncio.get_child_watcher().attach_loop(event_loop)
+    except RuntimeError:
+        pytest.skip('test_scp will always fail outside of MainThread')
     async with base.CleanModel() as model:
         app = await model.deploy('ubuntu')