Repository: box/box-python-sdk
Pull request #54 (2015-07-02 22:07:10)

Add support for As-User and shared items headers.
The Box API supports enterprise admins making requests as users via the As-User header.
It also supports gaining access to items via a shared link via the BoxApi: shared_link= header.

This commit adds an `as_user` and `with_shared_link` method to the SDK client, session, and endpoint objects
to support a fluent interface for adding the appropriate headers to requests.

For example, to get user with id 1234's root folder info:
    client.as_user(User('1234')).folder('0').get()


Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 9e88701..f69c880 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -3,6 +3,14 @@
 Release History
 ---------------
 
+Upcoming
+++++++++
+
+- Added support for 'As-User' requests. See https://box-content.readme.io/#as-user-1
+- Improved support for accessing shared items. Items returned from the `client.get_shared_item` method will
+  remember the shared link (and the optionally provided shared link password) so methods called on the returned
+  items will be properly authorized.
+
 1.1.7 (2015-05-28)
 ++++++++++++++++++
 
@@ -12,7 +20,7 @@ Release History
 
 - ``Item.remove_shared_link()`` was trying to return an incorrect (according to its own documentation) value, and was
   also attempting to calculate that value in a way that made an incorrect assumption about the API response. The latter
-  problem caused invokations of the method to raise TypeError. The method now handles the response correctly, and
+  problem caused invocations of the method to raise TypeError. The method now handles the response correctly, and
   correctly returns type ``bool``.
 
 1.1.6 (2015-04-17)
diff --git a/README.rst b/README.rst
index 3796249..805f73e 100644
--- a/README.rst
+++ b/README.rst
@@ -205,6 +205,26 @@ Metadata
     update.add('/key', 'new_value')
     metadata.update(update)
 
+As-User
+~~~~~~~
+
+The `Client` class and all Box objects also have an `as_user` method.
+
+`as-user` returns a copy of the object on which it was called that will make Box API requests
+as though the specified user was making it.
+
+See https://box-content.readme.io/#as-user-1 for more information about how this works via the Box API.
+
+.. code-block:: python
+
+    # Logged in as admin, but rename a file as SOME USER
+    user = client.user(user_id='SOME_USER_ID')
+    client.as_user(user).file(file_id='SOME_FILE_ID').rename('bar-2.txt')
+
+
+    # Same thing, but using file's as_user method
+    client.file(file_id='SOME_FILE_ID').as_user(user).rename('bar-2.txt')
+
 
 Contributing
 ------------
diff --git a/boxsdk/client.py b/boxsdk/client.py
index b76481c..bf7a5ec 100644
--- a/boxsdk/client.py
+++ b/boxsdk/client.py
@@ -13,12 +13,13 @@
 from .object.file import File
 from .object.group import Group
 from .object.group_membership import GroupMembership
+from .util.shared_link import get_shared_link_header
 from .util.translator import Translator
 
 
 class Client(object):
 
-    def __init__(self, oauth, network_layer=None):
+    def __init__(self, oauth, network_layer=None, session=None):
         """
         :param oauth:
             OAuth2 object used by the session to authorize requests.
@@ -28,9 +29,15 @@ def __init__(self, oauth, network_layer=None):
             The Network layer to use. If none is provided then an instance of :class:`DefaultNetwork` will be used.
         :type network_layer:
             :class:`Network`
+        :param session:
+            The session object to use. If None is provided then an instance of :class:`BoxSession` will be used.
+        :type session:
+            :class:`BoxSession`
         """
         network_layer = network_layer or DefaultNetwork()
-        self._session = BoxSession(oauth=oauth, network_layer=network_layer)
+        self._oauth = oauth
+        self._network = network_layer
+        self._session = session or BoxSession(oauth=oauth, network_layer=network_layer)
 
     def folder(self, folder_id):
         """
@@ -223,14 +230,16 @@ def get_shared_item(self, shared_link, password=None):
         :raises:
             :class:`BoxAPIException` if current user doesn't have permissions to view the shared link.
         """
-        shared_link_password = '&shared_link_password={0}'.format(password) if password is not None else ''
-        box_api_header = 'shared_link={0}{1}'.format(shared_link, shared_link_password)
         response = self.make_request(
             'GET',
             '{0}/shared_items'.format(API.BASE_API_URL),
-            headers={'BoxApi': box_api_header},
+            headers=get_shared_link_header(shared_link, password),
         ).json()
-        return Translator().translate(response['type'])(self._session, response['id'], response)
+        return Translator().translate(response['type'])(
+            self._session.with_shared_link(shared_link, password),
+            response['id'],
+            response,
+        )
 
     def make_request(self, method, url, **kwargs):
         """
@@ -252,3 +261,33 @@ def make_request(self, method, url, **kwargs):
             :class:`BoxAPIException`
         """
         return self._session.request(method, url, **kwargs)
+
+    def as_user(self, user):
+        """
+        Returns a new client object with default headers set up to make requests as the specified user.
+
+        :param user:
+            The user to impersonate when making API requests.
+        :type user:
+            :class:`User`
+        """
+        return self.__class__(self._oauth, self._network, self._session.as_user(user))
+
+    def with_shared_link(self, shared_link, shared_link_password):
+        """
+        Returns a new client object with default headers set up to make requests using the shared link for auth.
+
+        :param shared_link:
+            The shared link.
+        :type shared_link:
+            `unicode`
+        :param shared_link_password:
+            The password for the shared link.
+        :type shared_link_password:
+            `unicode`
+        """
+        return self.__class__(
+            self._oauth,
+            self._network,
+            self._session.with_shared_link(shared_link, shared_link_password),
+        )
diff --git a/boxsdk/object/base_endpoint.py b/boxsdk/object/base_endpoint.py
index fdab902..f168c03 100644
--- a/boxsdk/object/base_endpoint.py
+++ b/boxsdk/object/base_endpoint.py
@@ -36,3 +36,29 @@ def get_url(self, endpoint, *args):
         url = ['{0}/{1}'.format(API.BASE_API_URL, endpoint)]
         url.extend(['/{0}'.format(x) for x in args])
         return ''.join(url)
+
+    def as_user(self, user):
+        """
+        Returns a new endpoint object with default headers set up to make requests as the specified user.
+
+        :param user:
+            The user to impersonate when making API requests.
+        :type user:
+            :class:`User`
+        """
+        return self.__class__(self._session.as_user(user))
+
+    def with_shared_link(self, shared_link, shared_link_password):
+        """
+        Returns a new endpoint object with default headers set up to make requests using the shared link for auth.
+
+        :param shared_link:
+            The shared link.
+        :type shared_link:
+            `unicode`
+        :param shared_link_password:
+            The password for the shared link.
+        :type shared_link_password:
+            `unicode`
+        """
+        return self.__class__(self._session.with_shared_link(shared_link, shared_link_password))
diff --git a/boxsdk/object/base_object.py b/boxsdk/object/base_object.py
index b7e97c1..04636e7 100644
--- a/boxsdk/object/base_object.py
+++ b/boxsdk/object/base_object.py
@@ -232,3 +232,15 @@ def _paging_wrapper(self, url, starting_index, limit, factory=None):
             current_index += limit
             if current_index >= response['total_count']:
                 break
+
+    def as_user(self, user):
+        """ Base class override. """
+        return self.__class__(self._session.as_user(user), self._object_id, self._response_object)
+
+    def with_shared_link(self, shared_link, shared_link_password):
+        """ Base class override. """
+        return self.__class__(
+            self._session.with_shared_link(shared_link, shared_link_password),
+            self._object_id,
+            self._response_object,
+        )
diff --git a/boxsdk/object/group_membership.py b/boxsdk/object/group_membership.py
index 4b7e54b..5ee6792 100644
--- a/boxsdk/object/group_membership.py
+++ b/boxsdk/object/group_membership.py
@@ -74,3 +74,23 @@ def _init_user_and_group_instances(session, response_object, user, group):
         group = group or Translator().translate(group_info['type'])(session, group_info['id'], group_info)
 
         return user, group
+
+    def as_user(self, user):
+        """ Base class override. """
+        return self.__class__(
+            self._session.as_user(user),
+            self._object_id,
+            self._response_object,
+            self.user,
+            self.group,
+        )
+
+    def with_shared_link(self, shared_link, shared_link_password):
+        """ Base class override. """
+        return self.__class__(
+            self._session.with_shared_link(shared_link, shared_link_password),
+            self._object_id,
+            self._response_object,
+            self.user,
+            self.group,
+        )
diff --git a/boxsdk/object/metadata.py b/boxsdk/object/metadata.py
index 7f708e9..791b9f2 100644
--- a/boxsdk/object/metadata.py
+++ b/boxsdk/object/metadata.py
@@ -123,6 +123,7 @@ def __init__(self, session, box_object, scope, template):
         self._template = template
 
     def get_url(self, *args):
+        """ Base class override. """
         return self._object.get_url('metadata', self._scope, self._template)
 
     @staticmethod
@@ -197,3 +198,16 @@ def create(self, metadata):
             data=json.dumps(metadata),
             headers={b'Content-Type': b'application/json'},
         ).json()
+
+    def as_user(self, user):
+        """ Base class override. """
+        return self.__class__(self._session.as_user(user), self._object, self._scope, self._template)
+
+    def with_shared_link(self, shared_link, shared_link_password):
+        """ Base class override. """
+        return self.__class__(
+            self._session.with_shared_link(shared_link, shared_link_password),
+            self._object,
+            self._scope,
+            self._template
+        )
diff --git a/boxsdk/session/box_session.py b/boxsdk/session/box_session.py
index cfe50b7..dc28229 100644
--- a/boxsdk/session/box_session.py
+++ b/boxsdk/session/box_session.py
@@ -3,6 +3,7 @@
 from __future__ import unicode_literals
 
 from boxsdk.exception import BoxAPIException
+from boxsdk.util.shared_link import get_shared_link_header
 
 
 class BoxResponse(object):
@@ -62,7 +63,7 @@ class BoxSession(object):
     Box API session. Provides auth, automatic retry of failed requests, and session renewal.
     """
 
-    def __init__(self, oauth, network_layer):
+    def __init__(self, oauth, network_layer, default_headers=None):
         """
         :param oauth:
             OAuth2 object used by the session to authorize requests.
@@ -72,9 +73,44 @@ def __init__(self, oauth, network_layer):
             Network implementation used by the session to make requests.
         :type network_layer:
             :class:`Network`
+        :param default_headers:
+            A dictionary containing default values to be used as headers when this session makes an API request.
+        :type default_headers:
+            `dict` or None
         """
         self._oauth = oauth
         self._network_layer = network_layer
+        self._default_headers = default_headers or {}
+
+    def as_user(self, user):
+        """
+        Returns a new session object with default headers set up to make requests as the specified user.
+
+        :param user:
+            The user to impersonate when making API requests.
+        :type user:
+            :class:`User`
+        """
+        headers = self._default_headers.copy()
+        headers['As-User'] = user.object_id
+        return self.__class__(self._oauth, self._network_layer, headers)
+
+    def with_shared_link(self, shared_link, shared_link_password=None):
+        """
+        Returns a new session object with default headers set up to make requests using the shared link for auth.
+
+        :param shared_link:
+            The shared link.
+        :type shared_link:
+            `unicode`
+        :param shared_link_password:
+            The password for the shared link.
+        :type shared_link_password:
+            `unicode`
+        """
+        headers = self._default_headers.copy()
+        headers.update(get_shared_link_header(shared_link, shared_link_password))
+        return self.__class__(self._oauth, self._network_layer, headers)
 
     def _renew_session(self, access_token_used):
         """
@@ -282,7 +318,7 @@ def _make_request(
         access_token_will_be_used = self._oauth.access_token
         authorization_header = {'Authorization': 'Bearer {0}'.format(access_token_will_be_used)}
         if headers is None:
-            headers = {}
+            headers = self._default_headers.copy()
         headers.update(authorization_header)
 
         # Reset stream positions to what they were when the request was made so the same data is sent even if this
diff --git a/boxsdk/util/shared_link.py b/boxsdk/util/shared_link.py
new file mode 100644
index 0000000..696a714
--- /dev/null
+++ b/boxsdk/util/shared_link.py
@@ -0,0 +1,25 @@
+# coding: utf-8
+
+from __future__ import unicode_literals
+
+
+def get_shared_link_header(shared_link, password=None):
+    """
+    Gets the HTTP header required to use a shared link to grant access to a shared item.
+
+    :param shared_link:
+        The shared link.
+    :type shared_link:
+        `unicode`
+    :param password:
+        The password for the shared link.
+    :type password:
+        `unicode`
+    :return:
+        The item referred to by the shared link.
+    :rtype:
+        :class:`Item`
+    """
+    shared_link_password = '&shared_link_password={0}'.format(password) if password is not None else ''
+    box_api_header = 'shared_link={0}{1}'.format(shared_link, shared_link_password)
+    return {'BoxApi': box_api_header}
diff --git a/docs/source/boxsdk.util.rst b/docs/source/boxsdk.util.rst
index e8b16cf..ab30132 100644
--- a/docs/source/boxsdk.util.rst
+++ b/docs/source/boxsdk.util.rst
@@ -20,6 +20,14 @@ boxsdk.util.ordered_dict module
     :undoc-members:
     :show-inheritance:
 
+boxsdk.util.shared_link module
+------------------------------
+
+.. automodule:: boxsdk.util.shared_link
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
 boxsdk.util.singleton module
 ----------------------------
 
diff --git a/tox.ini b/tox.ini
index 211ff3d..11679d3 100644
--- a/tox.ini
+++ b/tox.ini
@@ -43,7 +43,7 @@ commands =
 deps = -rrequirements-dev.txt
 
 [testenv:coverage]
-commands = py.test --cov boxsdk test/unit
+commands = py.test --cov boxsdk test/unit test/integration
 deps = -rrequirements-dev.txt
 
 [testenv:docs]


Test patch:
diff --git a/test/conftest.py b/test/conftest.py
index 72ca5a8..97ac30c 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -144,3 +144,18 @@ def update_file_content(test_file_content):
 @pytest.fixture()
 def test_file_path():
     return 'path/to/file'
+
+
+@pytest.fixture(scope='module')
+def mock_object_id():
+    return '42'
+
+
+@pytest.fixture(scope='module')
+def mock_user_id():
+    return 'fake-user-100'
+
+
+@pytest.fixture(scope='module')
+def mock_group_id():
+    return 'fake-group-99'
diff --git a/test/integration/conftest.py b/test/integration/conftest.py
index 6371ab9..bf44eba 100644
--- a/test/integration/conftest.py
+++ b/test/integration/conftest.py
@@ -28,7 +28,7 @@ def box_oauth(mock_box_network, client_id, client_secret, access_token, refresh_
     )
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture()
 def mock_box_network():
     return MockNetwork()
 
diff --git a/test/integration/test_as_user.py b/test/integration/test_as_user.py
new file mode 100644
index 0000000..15c1f84
--- /dev/null
+++ b/test/integration/test_as_user.py
@@ -0,0 +1,125 @@
+# coding: utf-8
+
+from __future__ import unicode_literals
+from mock import call, patch
+import pytest
+from boxsdk.config import API
+from boxsdk.object.group_membership import GroupMembership
+from boxsdk.object.user import User
+
+
+@pytest.fixture
+def as_user_headers(mock_user_id, access_token):
+    return {'Authorization': 'Bearer {0}'.format(access_token), 'As-User': mock_user_id}
+
+
+def test_client_as_user_causes_as_user_header_to_be_added(
+        box_client,
+        mock_box_network,
+        generic_successful_response,
+        mock_user_id,
+        as_user_headers,
+):
+    # pylint:disable=redefined-outer-name
+    mock_box_network.session.request.side_effect = [generic_successful_response]
+    box_client.as_user(User(None, mock_user_id)).folder('0').get()
+    assert mock_box_network.session.request.mock_calls == [
+        call(
+            'GET',
+            '{0}/folders/0'.format(API.BASE_API_URL),
+            headers=as_user_headers,
+            params=None,
+        ),
+    ]
+
+
+def test_folder_object_as_user_causes_as_user_header_to_be_added(
+        box_client,
+        mock_box_network,
+        generic_successful_response,
+        mock_user_id,
+        as_user_headers,
+):
+    # pylint:disable=redefined-outer-name
+    mock_box_network.session.request.side_effect = [
+        generic_successful_response,
+    ]
+    box_client.folder('0').as_user(User(None, mock_user_id)).get()
+    assert mock_box_network.session.request.mock_calls == [
+        call(
+            'GET',
+            '{0}/folders/0'.format(API.BASE_API_URL),
+            headers=as_user_headers,
+            params=None,
+        ),
+    ]
+
+
+def test_group_membership_object_as_user_causes_as_user_header_to_be_added(
+        box_client,
+        mock_box_network,
+        generic_successful_response,
+        mock_user_id,
+        as_user_headers,
+):
+    # pylint:disable=redefined-outer-name
+    mock_box_network.session.request.side_effect = [
+        generic_successful_response,
+    ]
+    with patch.object(GroupMembership, '_init_user_and_group_instances') as init:
+        init.return_value = None, None
+        box_client.group_membership('0').as_user(User(None, mock_user_id)).get()
+    assert mock_box_network.session.request.mock_calls == [
+        call(
+            'GET',
+            '{0}/group_memberships/0'.format(API.BASE_API_URL),
+            headers=as_user_headers,
+            params=None,
+        ),
+    ]
+
+
+def test_events_endpoint_as_user_causes_as_user_header_to_be_added(
+        box_client,
+        mock_box_network,
+        generic_successful_response,
+        mock_user_id,
+        as_user_headers,
+):
+    # pylint:disable=redefined-outer-name
+    mock_box_network.session.request.side_effect = [
+        generic_successful_response,
+    ]
+    stream_position = 1348790499819
+    options = {'url': '{0}/events'.format(API.BASE_API_URL), 'retry_timeout': 60}
+    box_client.events().as_user(User(None, mock_user_id)).long_poll(options, stream_position)
+    assert mock_box_network.session.request.mock_calls == [
+        call(
+            'GET',
+            options['url'],
+            headers=as_user_headers,
+            timeout=options['retry_timeout'],
+            params={'stream_position': stream_position},
+        ),
+    ]
+
+
+def test_metadata_endpoint_as_user_causes_as_user_header_to_be_added(
+        box_client,
+        mock_box_network,
+        generic_successful_response,
+        mock_user_id,
+        as_user_headers,
+):
+    # pylint:disable=redefined-outer-name
+    mock_box_network.session.request.side_effect = [
+        generic_successful_response,
+    ]
+    box_client.file('0').metadata().as_user(User(None, mock_user_id)).get()
+    assert mock_box_network.session.request.mock_calls == [
+        call(
+            'GET',
+            '{0}/files/0/metadata/global/properties'.format(API.BASE_API_URL),
+            headers=as_user_headers,
+        ),
+    ]
diff --git a/test/integration/test_with_shared_link.py b/test/integration/test_with_shared_link.py
new file mode 100644
index 0000000..5fe6333
--- /dev/null
+++ b/test/integration/test_with_shared_link.py
@@ -0,0 +1,144 @@
+# coding: utf-8
+
+from __future__ import unicode_literals
+from mock import call, patch
+import pytest
+from boxsdk.config import API
+from boxsdk.object.group_membership import GroupMembership
+from boxsdk.util.shared_link import get_shared_link_header
+
+
+@pytest.fixture
+def shared_link():
+    return 'https://app.box.com/s/q2i1024dvguiads6mzj2avsq9hmz43du'
+
+
+@pytest.fixture(params=(None, 'shared_link_password'))
+def shared_link_password(request):
+    return request.param
+
+
+@pytest.fixture
+def box_api_headers(shared_link, shared_link_password, access_token):
+    # pylint:disable=redefined-outer-name
+    box_api_header = get_shared_link_header(shared_link, shared_link_password)['BoxApi']
+    return {'Authorization': 'Bearer {0}'.format(access_token), 'BoxApi': box_api_header}
+
+
+def test_client_with_shared_link_causes_box_api_header_to_be_added(
+        box_client,
+        mock_box_network,
+        generic_successful_response,
+        shared_link,
+        shared_link_password,
+        box_api_headers,
+):
+    # pylint:disable=redefined-outer-name
+    mock_box_network.session.request.side_effect = [
+        generic_successful_response,
+    ]
+    box_client.with_shared_link(shared_link, shared_link_password).folder('0').get()
+    assert mock_box_network.session.request.mock_calls == [
+        call(
+            'GET',
+            '{0}/folders/0'.format(API.BASE_API_URL),
+            headers=box_api_headers,
+            params=None,
+        ),
+    ]
+
+
+def test_folder_object_with_shared_link_causes_box_api_header_to_be_added(
+        box_client,
+        mock_box_network,
+        generic_successful_response,
+        shared_link,
+        shared_link_password,
+        box_api_headers,
+):
+    # pylint:disable=redefined-outer-name
+    mock_box_network.session.request.side_effect = [
+        generic_successful_response,
+    ]
+    box_client.folder('0').with_shared_link(shared_link, shared_link_password).get()
+    assert mock_box_network.session.request.mock_calls == [
+        call(
+            'GET',
+            '{0}/folders/0'.format(API.BASE_API_URL),
+            headers=box_api_headers,
+            params=None,
+        ),
+    ]
+
+
+def test_group_membership_object_with_shared_link_causes_box_api_header_to_be_added(
+        box_client,
+        mock_box_network,
+        generic_successful_response,
+        shared_link,
+        shared_link_password,
+        box_api_headers,
+):
+    # pylint:disable=redefined-outer-name
+    mock_box_network.session.request.side_effect = [
+        generic_successful_response,
+    ]
+    with patch.object(GroupMembership, '_init_user_and_group_instances') as init:
+        init.return_value = None, None
+        box_client.group_membership('0').with_shared_link(shared_link, shared_link_password).get()
+    assert mock_box_network.session.request.mock_calls == [
+        call(
+            'GET',
+            '{0}/group_memberships/0'.format(API.BASE_API_URL),
+            headers=box_api_headers,
+            params=None,
+        ),
+    ]
+
+
+def test_events_endpoint_with_shared_link_causes_box_api_header_to_be_added(
+        box_client,
+        mock_box_network,
+        generic_successful_response,
+        shared_link,
+        shared_link_password,
+        box_api_headers,
+):
+    # pylint:disable=redefined-outer-name
+    mock_box_network.session.request.side_effect = [
+        generic_successful_response,
+    ]
+    stream_position = 1348790499819
+    options = {'url': '{0}/events'.format(API.BASE_API_URL), 'retry_timeout': 60}
+    box_client.events().with_shared_link(shared_link, shared_link_password).long_poll(options, stream_position)
+    assert mock_box_network.session.request.mock_calls == [
+        call(
+            'GET',
+            options['url'],
+            headers=box_api_headers,
+            timeout=options['retry_timeout'],
+            params={'stream_position': stream_position},
+        ),
+    ]
+
+
+def test_metadata_endpoint_with_shared_link_causes_box_api_header_to_be_added(
+        box_client,
+        mock_box_network,
+        generic_successful_response,
+        shared_link,
+        shared_link_password,
+        box_api_headers,
+):
+    # pylint:disable=redefined-outer-name
+    mock_box_network.session.request.side_effect = [
+        generic_successful_response,
+    ]
+    box_client.file('0').metadata().with_shared_link(shared_link, shared_link_password).get()
+    assert mock_box_network.session.request.mock_calls == [
+        call(
+            'GET',
+            '{0}/files/0/metadata/global/properties'.format(API.BASE_API_URL),
+            headers=box_api_headers,
+        ),
+    ]
diff --git a/test/unit/conftest.py b/test/unit/conftest.py
index 42301f4..04e65a8 100644
--- a/test/unit/conftest.py
+++ b/test/unit/conftest.py
@@ -44,21 +44,6 @@ def mock_group_response(mock_group_id, make_mock_box_request):
     return mock_box_response
 
 
-@pytest.fixture(scope='module')
-def mock_object_id():
-    return '42'
-
-
-@pytest.fixture(scope='module')
-def mock_user_id():
-    return 'fake-user-100'
-
-
-@pytest.fixture(scope='module')
-def mock_group_id():
-    return 'fake-group-99'
-
-
 @pytest.fixture()
 def make_mock_box_request():
     def inner(status_code=200, response_ok=True, response=None, content=None):
diff --git a/test/unit/object/test_metadata.py b/test/unit/object/test_metadata.py
index cf4b491..409914b 100644
--- a/test/unit/object/test_metadata.py
+++ b/test/unit/object/test_metadata.py
@@ -105,3 +105,8 @@ def test_update(
         data=json.dumps(metadata_update.ops),
         headers={b'Content-Type': b'application/json-patch+json'},
     )
+
+
+def test_start_update(test_file):
+    update = test_file.metadata().start_update()
+    assert isinstance(update, MetadataUpdate)
diff --git a/test/unit/object/test_search.py b/test/unit/object/test_search.py
index 0345108..4051258 100644
--- a/test/unit/object/test_search.py
+++ b/test/unit/object/test_search.py
@@ -179,3 +179,18 @@ def test_range_filter_without_gt_and_lt_will_fail_validation():
     metadata_filter = MetadataSearchFilter(template_key='mytemplate', scope='enterprise')
     with pytest.raises(ValueError):
         metadata_filter.add_range_filter(field_key='mykey')
+
+
+def test_start_search_filters(test_search):
+    filters = test_search.start_metadata_filters()
+    assert isinstance(filters, MetadataSearchFilters)
+
+
+def test_make_single_metadata_filter(test_search):
+    template_key = 'mytemplate'
+    scope = 'myscope'
+    metadata_filter = test_search.make_single_metadata_filter(template_key, scope)
+    assert isinstance(metadata_filter, MetadataSearchFilter)
+    filter_as_dict = metadata_filter.as_dict()
+    assert filter_as_dict['templateKey'] == template_key
+    assert filter_as_dict['scope'] == scope
diff --git a/test/unit/test_exception.py b/test/unit/test_exception.py
index 6539f84..e0eb730 100644
--- a/test/unit/test_exception.py
+++ b/test/unit/test_exception.py
@@ -30,6 +30,7 @@ def test_box_api_exception():
     assert box_exception._headers == headers  # pylint:disable=protected-access
     assert box_exception.url == url
     assert box_exception.method == method
+    assert box_exception.context_info == context_info
     assert str(box_exception) == '''
 Message: {0}
 Status: {1}
diff --git a/test/unit/util/test_shared_link.py b/test/unit/util/test_shared_link.py
new file mode 100644
index 0000000..3a5b848
--- /dev/null
+++ b/test/unit/util/test_shared_link.py
@@ -0,0 +1,24 @@
+# coding: utf-8
+
+from __future__ import unicode_literals
+import pytest
+from boxsdk.util.shared_link import get_shared_link_header
+
+
+@pytest.fixture(params=('mock_shared_link', 'https://app.box.com/s/q2i1024dvguiads6mzj2avsq9hmz43du'))
+def shared_link(request):
+    return request.param
+
+
+@pytest.fixture(params=(None, 'shared_link_password'))
+def password(request):
+    return request.param
+
+
+def test_get_shared_link_header(shared_link, password):
+    # pylint:disable=redefined-outer-name
+    header = get_shared_link_header(shared_link, password)
+    assert 'BoxApi' in header
+    assert shared_link in header['BoxApi']
+    if password is not None:
+        assert password in header['BoxApi']