Revert "Remove vendored libjuju"

This reverts commit 9d18c22a0dc9e295adda50601fc5e2f45d2c9b8a.

Change-Id: I7dbf291ccd750c5f836ff80c642be492434ab3ac
Signed-off-by: Adam Israel <adam.israel@canonical.com>
diff --git a/modules/libjuju/tests/integration/__init__.py b/modules/libjuju/tests/integration/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/modules/libjuju/tests/integration/__init__.py
diff --git a/modules/libjuju/tests/integration/bundle/bundle-resource-rev.yaml b/modules/libjuju/tests/integration/bundle/bundle-resource-rev.yaml
new file mode 100644
index 0000000..c8feb83
--- /dev/null
+++ b/modules/libjuju/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/modules/libjuju/tests/integration/bundle/bundle.yaml b/modules/libjuju/tests/integration/bundle/bundle.yaml
new file mode 100644
index 0000000..d0245c5
--- /dev/null
+++ b/modules/libjuju/tests/integration/bundle/bundle.yaml
@@ -0,0 +1,12 @@
+series: xenial
+services:
+  ghost:
+    charm: "cs:ghost-19"
+    num_units: 1
+  mysql:
+    charm: "cs:trusty/mysql-57"
+    num_units: 1
+  test:
+    charm: "./tests/integration/charm"
+relations:
+  - ["ghost", "mysql"]
diff --git a/modules/libjuju/tests/integration/cert.pem b/modules/libjuju/tests/integration/cert.pem
new file mode 100644
index 0000000..684029e
--- /dev/null
+++ b/modules/libjuju/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/modules/libjuju/tests/integration/charm/metadata.yaml b/modules/libjuju/tests/integration/charm/metadata.yaml
new file mode 100644
index 0000000..74eab3d
--- /dev/null
+++ b/modules/libjuju/tests/integration/charm/metadata.yaml
@@ -0,0 +1,5 @@
+name: charm
+series: ["xenial"]
+summary: "test"
+description: "test"
+maintainers: ["test"]
diff --git a/modules/libjuju/tests/integration/key.pem b/modules/libjuju/tests/integration/key.pem
new file mode 100644
index 0000000..664123e
--- /dev/null
+++ b/modules/libjuju/tests/integration/key.pem
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+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
+hg+Fb1z8af0WL9PCAwsWQMTaSx88tQ==
+-----END PRIVATE KEY-----
diff --git a/modules/libjuju/tests/integration/test_application.py b/modules/libjuju/tests/integration/test_application.py
new file mode 100644
index 0000000..b705832
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_application.py
@@ -0,0 +1,134 @@
+import asyncio
+
+import pytest
+
+from .. import base
+
+MB = 1
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_action(event_loop):
+    async with base.CleanModel() as model:
+        ubuntu_app = await model.deploy(
+            'percona-cluster',
+            application_name='mysql',
+            series='xenial',
+            channel='stable',
+            config={
+                'tuning-level': 'safest',
+            },
+            constraints={
+                'mem': 256 * MB,
+            },
+        )
+
+        # update and check app config
+        await ubuntu_app.set_config({'tuning-level': 'fast'})
+        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
+async def test_add_units(event_loop):
+    from juju.unit import Unit
+
+    async with base.CleanModel() as model:
+        app = await model.deploy(
+            'ubuntu-0',
+            application_name='ubuntu',
+            series='trusty',
+            channel='stable',
+        )
+        units = await app.add_units(count=2)
+
+        assert len(units) == 2
+        for unit in units:
+            assert isinstance(unit, Unit)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_upgrade_charm(event_loop):
+    async with base.CleanModel() as model:
+        app = await model.deploy('ubuntu-0')
+        assert app.data['charm-url'] == 'cs:ubuntu-0'
+        await app.upgrade_charm()
+        assert app.data['charm-url'].startswith('cs:ubuntu-')
+        assert app.data['charm-url'] != 'cs:ubuntu-0'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_upgrade_charm_channel(event_loop):
+    async with base.CleanModel() as model:
+        app = await model.deploy('ubuntu-0')
+        assert app.data['charm-url'] == 'cs:ubuntu-0'
+        await app.upgrade_charm(channel='stable')
+        assert app.data['charm-url'].startswith('cs:ubuntu-')
+        assert app.data['charm-url'] != 'cs:ubuntu-0'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_upgrade_charm_revision(event_loop):
+    async with base.CleanModel() as model:
+        app = await model.deploy('ubuntu-0')
+        assert app.data['charm-url'] == 'cs:ubuntu-0'
+        await app.upgrade_charm(revision=8)
+        assert app.data['charm-url'] == 'cs:ubuntu-8'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_upgrade_charm_switch(event_loop):
+    async with base.CleanModel() as model:
+        app = await model.deploy('ubuntu-0')
+        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/modules/libjuju/tests/integration/test_client.py b/modules/libjuju/tests/integration/test_client.py
new file mode 100644
index 0000000..240c471
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_client.py
@@ -0,0 +1,21 @@
+from juju.client import client
+
+import pytest
+
+from .. import base
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_user_info(event_loop):
+    async with base.CleanModel() as model:
+        controller_conn = await model.connection().controller()
+
+        um = client.UserManagerFacade.from_connection(controller_conn)
+        result = await um.UserInfo(
+            [client.Entity('user-admin')], True)
+        await controller_conn.close()
+
+        assert isinstance(result, client.UserInfoResults)
+        for r in result.results:
+            assert isinstance(r, client.UserInfoResult)
diff --git a/modules/libjuju/tests/integration/test_connection.py b/modules/libjuju/tests/integration/test_connection.py
new file mode 100644
index 0000000..6647a03
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_connection.py
@@ -0,0 +1,236 @@
+import asyncio
+import http
+import logging
+import socket
+import ssl
+from contextlib import closing
+from pathlib import Path
+
+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
+
+
+logger = logging.getLogger(__name__)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_monitor(event_loop):
+    async with base.CleanModel() as model:
+        conn = model.connection()
+        assert conn.monitor.status == 'connected'
+        await conn.close()
+
+        assert conn.monitor.status == 'disconnected'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_monitor_catches_error(event_loop):
+
+    async with base.CleanModel() as model:
+        conn = model.connection()
+
+        assert conn.monitor.status == 'connected'
+        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
+@pytest.mark.asyncio
+async def test_full_status(event_loop):
+    async with base.CleanModel() as model:
+        await model.deploy(
+            'ubuntu-0',
+            application_name='ubuntu',
+            series='trusty',
+            channel='stable',
+        )
+
+        c = client.ClientFacade.from_connection(model.connection())
+
+        await c.FullStatus(None)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_reconnect(event_loop):
+    async with base.CleanModel() as model:
+        kwargs = model.connection().connect_params()
+        conn = await Connection.connect(**kwargs)
+        try:
+            await asyncio.sleep(0.1)
+            assert conn.is_open
+            await conn.ws.close()
+            assert not conn.is_open
+            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/modules/libjuju/tests/integration/test_controller.py b/modules/libjuju/tests/integration/test_controller.py
new file mode 100644
index 0000000..6423a98
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_controller.py
@@ -0,0 +1,228 @@
+import asyncio
+import subprocess
+import uuid
+
+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_remove_user(event_loop):
+    async with base.CleanController() as controller:
+        username = 'test{}'.format(uuid.uuid4())
+        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
+@pytest.mark.asyncio
+async def test_disable_enable_user(event_loop):
+    async with base.CleanController() as controller:
+        username = 'test-disable{}'.format(uuid.uuid4())
+        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
+@pytest.mark.asyncio
+async def test_change_user_password(event_loop):
+    async with base.CleanController() as controller:
+        username = 'test-password{}'.format(uuid.uuid4())
+        user = await controller.add_user(username)
+        await user.set_password('password')
+        # Check that we can connect with the new password.
+        new_connection = None
+        try:
+            kwargs = controller.connection().connect_params()
+            kwargs['username'] = username
+            kwargs['password'] = 'password'
+            new_connection = await Connection.connect(**kwargs)
+        except JujuAPIError:
+            raise AssertionError('Unable to connect with new password')
+        finally:
+            if new_connection:
+                await new_connection.close()
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+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())
+        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_list_models(event_loop):
+    async with base.CleanController() as controller:
+        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/modules/libjuju/tests/integration/test_errors.py b/modules/libjuju/tests/integration/test_errors.py
new file mode 100644
index 0000000..b10dd06
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_errors.py
@@ -0,0 +1,68 @@
+import pytest
+
+from .. import base
+
+MB = 1
+GB = 1024
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_juju_api_error(event_loop):
+    '''
+    Verify that we raise a JujuAPIError for responses with an error in
+    a top level key (for completely invalid requests).
+
+    '''
+    from juju.errors import JujuAPIError
+
+    async with base.CleanModel() as model:
+        with pytest.raises(JujuAPIError):
+            await model.add_machine(constraints={'mem': 'foo'})
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_juju_error_in_results_list(event_loop):
+    '''
+    Replicate the code that caused
+    https://github.com/juju/python-libjuju/issues/67, and verify that
+    we get a JujuError instead of passing silently by the failure.
+
+    (We don't raise a JujuAPIError, because the request isn't
+    completely invalid -- it's just passing a tag that doesn't exist.)
+
+    This also verifies that we will raise a JujuError any time there
+    is an error in one of a list of results.
+
+    '''
+    from juju.errors import JujuError
+    from juju.client import client
+
+    async with base.CleanModel() as model:
+        ann_facade = client.AnnotationsFacade.from_connection(model.connection())
+
+        ann = client.EntityAnnotations(
+            entity='badtag',
+            annotations={'gui-x': '1', 'gui-y': '1'},
+        )
+        with pytest.raises(JujuError):
+            return await ann_facade.Set([ann])
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_juju_error_in_result(event_loop):
+    '''
+    Verify that we raise a JujuError when appropraite when we are
+    looking at a single result coming back.
+
+    '''
+    from juju.errors import JujuError
+    from juju.client import client
+
+    async with base.CleanModel() as model:
+        app_facade = client.ApplicationFacade.from_connection(model.connection())
+
+        with pytest.raises(JujuError):
+            return await app_facade.GetCharmURL('foo')
diff --git a/modules/libjuju/tests/integration/test_macaroon_auth.py b/modules/libjuju/tests/integration/test_macaroon_auth.py
new file mode 100644
index 0000000..9911c41
--- /dev/null
+++ b/modules/libjuju/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/modules/libjuju/tests/integration/test_machine.py b/modules/libjuju/tests/integration/test_machine.py
new file mode 100644
index 0000000..070208a
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_machine.py
@@ -0,0 +1,65 @@
+import asyncio
+from tempfile import NamedTemporaryFile
+
+import pytest
+
+from .. import base
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_status(event_loop):
+    async with base.CleanModel() as model:
+        await model.deploy(
+            'ubuntu-0',
+            application_name='ubuntu',
+            series='trusty',
+            channel='stable',
+        )
+
+        await asyncio.wait_for(
+            model.block_until(lambda: len(model.machines)),
+            timeout=240)
+        machine = model.machines['0']
+
+        assert machine.status in ('allocating', 'pending')
+        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.status_message.lower() == 'running' and
+                         machine.agent_status == 'started')),
+            timeout=480)
+
+
+@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(
+            model.block_until(lambda: model.machines),
+            timeout=240)
+        machine = model.machines['0']
+        await asyncio.wait_for(
+            model.block_until(lambda: (machine.status == 'running' and
+                                       machine.agent_status == 'started')),
+            timeout=480)
+
+        with NamedTemporaryFile() as f:
+            f.write(b'testcontents')
+            f.flush()
+            await machine.scp_to(f.name, 'testfile', scp_opts='-p')
+
+        with NamedTemporaryFile() as f:
+            await machine.scp_from('testfile', f.name, scp_opts='-p')
+            assert f.read() == b'testcontents'
diff --git a/modules/libjuju/tests/integration/test_model.py b/modules/libjuju/tests/integration/test_model.py
new file mode 100644
index 0000000..58766b4
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_model.py
@@ -0,0 +1,485 @@
+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
+
+
+MB = 1
+GB = 1024
+SSH_KEY = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsYMJGNGG74HAJha3n2CFmWYsOOaORnJK6VqNy86pj0MIpvRXBzFzVy09uPQ66GOQhTEoJHEqE77VMui7+62AcMXT+GG7cFHcnU8XVQsGM6UirCcNyWNysfiEMoAdZScJf/GvoY87tMEszhZIUV37z8PUBx6twIqMdr31W1J0IaPa+sV6FEDadeLaNTvancDcHK1zuKsL39jzAg7+LYjKJfEfrsQP+lj/EQcjtKqlhVS5kzsJVfx8ZEd0xhW5G7N6bCdKNalS8mKCMaBXJpijNQ82AiyqCIDCRrre2To0/i7pTjRiL0U9f9mV3S4NJaQaokR050w/ZLySFf6F7joJT mathijs@Qrama-Mathijs'  # noqa
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_deploy_local_bundle_dir(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(bundle_path))
+        await model.deploy(str(mini_bundle_file_path))
+
+        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
+@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
+@pytest.mark.asyncio
+async def test_deploy_bundle(event_loop):
+    async with base.CleanModel() as model:
+        await model.deploy('bundle/wiki-simple')
+
+        for app in ('wiki', 'mysql'):
+            assert app in model.applications
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_deploy_channels_revs(event_loop):
+    async with base.CleanModel() as model:
+        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')
+
+        assert [a.charm_url for a in (stable, edge, rev)] == [
+            'cs:~johnsca/libjuju-test-1',
+            'cs:~johnsca/libjuju-test-2',
+            'cs:~johnsca/libjuju-test-2',
+        ]
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_add_machine(event_loop):
+    from juju.machine import Machine
+
+    async with base.CleanModel() as model:
+        # add a new default machine
+        machine1 = await model.add_machine()
+
+        # add a machine with constraints, disks, and series
+        machine2 = await model.add_machine(
+            constraints={
+                'mem': 256 * MB,
+            },
+            disks=[{
+                'pool': 'rootfs',
+                'size': 10 * GB,
+                'count': 1,
+            }],
+            series='xenial',
+        )
+
+        # add a lxd container to machine2
+        machine3 = await model.add_machine(
+            'lxd:{}'.format(machine2.id))
+
+        for m in (machine1, machine2, machine3):
+            assert isinstance(m, Machine)
+
+        assert len(model.machines) == 3
+
+        await machine3.destroy(force=True)
+        await machine2.destroy(force=True)
+        res = await machine1.destroy(force=True)
+
+        assert res is None
+        assert len(model.machines) == 0
+
+
+@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
+
+    async with base.CleanModel() as model:
+        await model.deploy(
+            'ubuntu',
+            application_name='ubuntu',
+            series='trusty',
+            channel='stable',
+        )
+        await model.deploy(
+            'nrpe',
+            application_name='nrpe',
+            series='trusty',
+            channel='stable',
+            # subordinates must be deployed without units
+            num_units=0,
+        )
+
+        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, 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
+    finally:
+        await new_model.disconnect()
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_explicit_loop_threaded(event_loop):
+    async with base.CleanModel() as model:
+        model_name = model.info.name
+        new_loop = asyncio.new_event_loop()
+        with ThreadPoolExecutor(1) as executor:
+            f = executor.submit(
+                new_loop.run_until_complete,
+                _deploy_in_loop(new_loop,
+                                model_name,
+                                model._connector.jujudata))
+            f.result()
+        await model._wait_for_new('application', 'ubuntu')
+        assert 'ubuntu' in model.applications
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_store_resources_charm(event_loop):
+    async with base.CleanModel() as model:
+        ghost = await model.deploy('cs:ghost-19')
+        assert 'ghost' in model.applications
+        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'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_store_resources_bundle(event_loop):
+    async with base.CleanModel() as model:
+        bundle = str(Path(__file__).parent / 'bundle')
+        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 >= 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
+@pytest.mark.asyncio
+async def test_ssh_key(event_loop):
+    async with base.CleanModel() as model:
+        await model.add_ssh_key('admin', SSH_KEY)
+        result = await model.get_ssh_key(True)
+        result = result.serialize()['results'][0].serialize()['result']
+        assert SSH_KEY in result
+        await model.remove_ssh_key('admin', SSH_KEY)
+        result = await model.get_ssh_key(True)
+        result = result.serialize()['results'][0].serialize()['result']
+        assert result is None
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_get_machines(event_loop):
+    async with base.CleanModel() as model:
+        result = await model.get_machines()
+        assert isinstance(result, list)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_watcher_reconnect(event_loop):
+    async with base.CleanModel() as model:
+        await model.connection().ws.close()
+        await block_until(model.is_connected, timeout=3)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_config(event_loop):
+    async with base.CleanModel() as model:
+        await model.set_config({
+            'extra-info': 'booyah',
+            'test-mode': ConfigValue(value=True),
+        })
+        result = await model.get_config()
+        assert 'extra-info' in result
+        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)
+#    async with base.CleanController() as controller:
+#        await controller.add_user('test-model-grant')
+#        await controller.grant('test-model-grant', 'superuser')
+#    async with base.CleanModel() as model:
+#        await model.grant('test-model-grant', 'admin')
+#        assert model.get_user('test-model-grant')['access'] == 'admin'
+#        await model.grant('test-model-grant', 'login')
+#        assert model.get_user('test-model-grant')['access'] == 'login'
diff --git a/modules/libjuju/tests/integration/test_unit.py b/modules/libjuju/tests/integration/test_unit.py
new file mode 100644
index 0000000..bb34969
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_unit.py
@@ -0,0 +1,99 @@
+import asyncio
+from tempfile import NamedTemporaryFile
+
+import pytest
+
+from .. import base
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_run(event_loop):
+    from juju.action import Action
+
+    async with base.CleanModel() as model:
+        app = await model.deploy(
+            'ubuntu-0',
+            application_name='ubuntu',
+            series='trusty',
+            channel='stable',
+        )
+
+        for unit in app.units:
+            action = await unit.run('unit-get public-address')
+            assert isinstance(action, Action)
+            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
+async def test_run_action(event_loop):
+    async def run_action(unit):
+        # unit.run() returns a juju.action.Action instance
+        action = await unit.run_action('add-repo', repo='myrepo')
+        # wait for the action to complete
+        return await action.wait()
+
+    async with base.CleanModel() as model:
+        app = await model.deploy(
+            'git',
+            application_name='git',
+            series='trusty',
+            channel='stable',
+        )
+
+        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')
+
+        await asyncio.wait_for(
+            model.block_until(lambda: app.units),
+            timeout=60)
+        unit = app.units[0]
+        await asyncio.wait_for(
+            model.block_until(lambda: unit.machine),
+            timeout=60)
+        machine = unit.machine
+        await asyncio.wait_for(
+            model.block_until(lambda: (machine.status == 'running' and
+                                       machine.agent_status == 'started')),
+            timeout=480)
+
+        with NamedTemporaryFile() as f:
+            f.write(b'testcontents')
+            f.flush()
+            await unit.scp_to(f.name, 'testfile')
+
+        with NamedTemporaryFile() as f:
+            await unit.scp_from('testfile', f.name)
+            assert f.read() == b'testcontents'