Repository: robocorp/rpaframework

Issue:
Library to support Microsoft Graph and Office 365 API
Probably extending functionalities of the O365 package.

https://pypi.org/project/O365/
https://github.com/O365/python-o365


Discussion:
And I think the MS Graph API is REST API so then it could be implemented without external dependencies.

Resolving patch:
diff --git a/packages/main/src/RPA/MSGraph.py b/packages/main/src/RPA/MSGraph.py
new file mode 100644
--- /dev/null
+++ b/packages/main/src/RPA/MSGraph.py
@@ -0,0 +1,1050 @@
+import base64
+import logging
+from typing import List, Optional, Union
+from pathlib import Path
+from O365 import (
+    Account,
+    MSGraphProtocol,
+    FileSystemTokenBackend,
+    directory,
+    drive as drive_module,
+    sharepoint,
+)
+from O365.utils import Token, BaseTokenBackend
+from O365.utils.utils import (  # noqa: F401 pylint: disable=unused-import
+    ME_RESOURCE,
+    USERS_RESOURCE,
+    GROUPS_RESOURCE,
+    SITES_RESOURCE,
+)
+from robot.api.deco import keyword
+from RPA.Tables import Table
+
+
+DEFAULT_REDIRECT_URI = "https://login.microsoftonline.com/common/oauth2/nativeclient"
+DEFAULT_TOKEN_PATH = Path("/temp")
+DEFAULT_PROTOCOL = MSGraphProtocol()
+
+DriveType = Union[drive_module.Drive, str]
+PathType = Union[Path, str]
+
+
+class MSGraphAuthenticationError(Exception):
+    "Error when authentication fails."
+
+
+class MSGraphDownloadError(Exception):
+    "Error when download fails."
+
+
+class RobocorpVaultTokenBackend(BaseTokenBackend):
+    "A simple Token backend that saves to Robocorp vault"
+
+
+class SharedItem(drive_module.File):
+    """Simple class to add support for shared items.
+    Inherits File only to bypass checks in the library.
+    """  # pylint: disable=super-init-not-called
+
+    def __init__(self, data, url, con):
+        self.name = data["name"]
+        self.size = data["size"]
+        self.con = con
+        self.url = url
+        self.object_id = None
+
+    def build_url(self, *args):  # pylint: disable=unused-argument
+        """Discard arguments since we already have the correct URL."""
+        return self.url
+
+
+class MSGraph:
+    """
+    The *MSGraph* library wraps the `O365 package`_, giving robots
+    the ability to access the Microsoft Graph API programmatically.
+
+    OAuth Configuration
+    -------------------
+
+    Graph's API primarily authenticates via the OAuth 2.0 authorization code grant
+    flow or OpenID Connect. This library exposes the OAuth 2.0 flow for robots to
+    authenticate on behalf of users. A user must complete an initial authentication
+    flow with the help of our `OAuth Graph Example Bot`_.
+
+    For best results, `register an app`_ in Azure AD and configure it as so:
+
+    - The type is "Web App".
+    - Redirect URI should be ``https://login.microsoftonline.com/common/oauth2/nativeclient``
+    - The app should be a multi-tenant app.
+    - ``Accounts in any organizational directory`` is checked.
+    - Has relevant permissions enabled, check the `Microsoft Graph permissions reference`_
+      for a list of permissions available to MS Graph apps.
+
+    .. _O365 package: https://pypi.org/project/O365
+    .. _OAuth Graph Example Bot: https://robocorp.com/portal/
+    .. _register an app: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade
+    .. _Microsoft Graph permissions reference: https://docs.microsoft.com/en-us/graph/permissions-reference
+
+
+    """  # noqa: E501
+
+    ROBOT_LIBRARY_SCOPE = "GLOBAL"
+    ROBOT_LIBRARY_DOC_FORMAT = "REST"
+
+    def __init__(
+        self,
+        client_id: Optional[str] = None,
+        client_secret: Optional[str] = None,
+        token: Optional[Token] = None,  # pylint: disable=unused-argument
+        refresh_token: Optional[str] = None,
+        redirect_uri: Optional[str] = None,
+        vault_backend: bool = False,
+        vault_secret: Optional[str] = None,
+        file_backend_path: Optional[Path] = DEFAULT_TOKEN_PATH,
+    ) -> None:
+        """When importing the library to Robot Framework, you can set the
+        ``client_id`` and ``client_secret``.
+
+        :param client_id: Application client ID.
+        :param client_secret: Application client secret.
+        :param token: Not implemented. Will support providing the
+         authorization token object directly.
+        :param refresh_token: Authorization refresh token obtained from
+         prior OAuth flows.
+        :param redirect_uri: Must be provided with client ID and client
+         secret if not using the default.
+        :param vault_backend: Not implemented. Will support using the
+         Control Room vault directly for authorization token management.
+        :param vault_secret: Not implemented. Will support using the
+         Control Room vault directly for authorization token management.
+        :param file_backend_path: Indicate a path where the tokens will
+         be stored within the robot, the default is ``/temp``.
+        """
+        self.logger = logging.getLogger(__name__)
+        # TODO: Implement a `TokenBackend` that uses Robocorp vault,
+        #       if implemented, returned refresh tokens are unnecessary.
+        if not vault_backend:
+            self.token_backend = FileSystemTokenBackend(
+                file_backend_path, "auth_token.txt"
+            )
+        elif vault_backend and not vault_secret:
+            raise ValueError(
+                "Argument vault_secret cannot be blank if vault_backend set to True."
+            )
+        else:
+            raise NotImplementedError(
+                "Robocorp vault token backend not yet implemented."
+            )
+        if client_id and client_secret:
+            self.configure_msgraph_client(
+                client_id, client_secret, refresh_token, redirect_uri
+            )
+        else:
+            self.client = None
+            self.redirect_uri = redirect_uri or DEFAULT_REDIRECT_URI
+
+    def _require_client(self):
+        if self.client is None:
+            raise MSGraphAuthenticationError("The MSGraph client is not configured.")
+
+    def _require_authentication(self):
+        self._require_client()
+        if not self.client.is_authenticated:
+            raise MSGraphAuthenticationError(
+                "The MS Graph client is not authenticated."
+            )
+
+    def _get_refresh_token(self):
+        """Returns the refresh token using the backend if that backend
+        is not the Vault.
+        """
+        try:
+            if isinstance(self.token_backend, FileSystemTokenBackend):
+                return self.token_backend.load_token().get("refresh_token")
+            else:
+                return None
+        except AttributeError:
+            return None
+
+    @keyword
+    def get_drive_instance(
+        self, resource: Optional[str] = None, drive_id: Optional[str] = None
+    ) -> drive_module.Drive:
+        """Returns the specified drive from the specified resource. If
+        either is not provided, defaults are returned.
+
+        :param resource: Name of the resource if not using default.
+        :param drive_id: The Drive ID as a string.
+        :return: The requested ``Drive`` object.
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            Get default drive
+                ${default_drive}=    Get drive instance
+                Log    ${default_drive.Name}
+        """
+        storage = self.client.storage(resource=resource)
+        if drive_id:
+            return storage.get_drive(drive_id)
+        else:
+            return storage.get_default_drive()
+
+    def _parse_drive_param(self, drive: DriveType) -> drive_module.Drive:
+        """Helper function which parses the drive parameter given
+        from a robot.
+        """
+        if isinstance(drive, drive_module.Drive):
+            return drive
+        else:
+            return self.get_drive_instance(drive)
+
+    @keyword
+    def get_folder_instance(
+        self,
+        drive: Optional[DriveType] = None,
+        folder: Optional[Union[drive_module.Folder, str]] = None,
+    ) -> drive_module.Folder:
+        """Returns a folder object from the provided drive. If a folder
+        object is provided, it is reobtained from the API.
+
+        :param drive: A ``Drive`` object or Drive ID as a string. If not
+         provided, will use the default drive.
+        :param folder: A ``Folder`` object or folder path as a string.
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            Get folder from OneDrive
+                ${folder}=    Get folder instance    /path/to/folder
+        """
+        drive = self._parse_drive_param(drive)
+        if isinstance(folder, drive_module.Folder):
+            return drive.get_item(folder.object_id)
+        elif isinstance(folder, drive_module.DriveItem):
+            raise TypeError("The provided DriveItem is not a Folder")
+        elif folder in [None, "/", "\\", "root", "ROOT", ""]:
+            return drive.get_root_folder()
+        else:
+            return drive.get_item_by_path(folder)
+
+    @keyword
+    def get_file_instance(
+        self,
+        drive: DriveType,
+        file: Union[drive_module.File, str],
+    ) -> drive_module.File:
+        """Returns a ``File`` object from the provided drive. If a ``File``
+        object is provided, it is reobtained from the API.
+
+        :param drive: A ``Drive`` object or Drive ID as a string. If not
+         provided, will use the default drive.
+        :param file_path: A ``File`` object or path as a string.
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            Get file from OneDrive
+                ${folder}=    Get file instance    /path/to/file
+        """
+        drive = self._parse_drive_param(drive)
+        if isinstance(file, str):
+            return drive.get_item_by_path(file)
+        elif isinstance(file, drive_module.File):
+            return drive.get_item(file.object_id)
+        else:
+            raise TypeError("Target file is not any of the expected types.")
+
+    def _encode_url(self, file_url: str) -> str:
+        """Encodes a link to a format supported by the Graph API."""
+        base64_bytes = base64.b64encode(bytes(file_url, "utf-8"))
+        base64_string = (
+            base64_bytes.decode("utf-8")
+            .replace("=", "")
+            .replace("/", "_")
+            .replace("+", "-")
+        )
+        return "u!{}".format(base64_string)
+
+    def _download_file(
+        self,
+        file: drive_module.File,
+        to_path: Optional[PathType] = None,
+        name: Optional[str] = None,
+    ) -> Path:
+        """Downloads the file and returns the destination path.
+        The O365 library returns only a boolean, so it was necessary
+        to define the destination path the same way the library does it.
+        """
+        if not isinstance(file, drive_module.File):
+            raise MSGraphDownloadError("Drive item is not a file.")
+        if to_path is None:
+            to_path = Path()
+        else:
+            to_path = Path(to_path)
+        # TODO: Does this fail when we define a path we want?
+        if not to_path.exists():
+            raise FileNotFoundError("{} does not exist".format(to_path))
+        if name and not Path(name).suffix and file.name:
+            name = name + Path(file.name).suffix
+        name = name or file.name
+        downloaded_file = to_path / name
+        success = file.download(to_path=to_path, name=name)
+        if not success:
+            raise MSGraphDownloadError("Downloading file failed.")
+        return downloaded_file
+
+    def _download_folder(
+        self,
+        folder: drive_module.Folder,
+        to_folder: Optional[PathType] = None,
+    ) -> Path:
+        """Downloads the content of the folder recursively.
+        The O365 library returns only a boolean, so it was necessary to define
+        the destination path the same way the O365 library does it.
+        """
+        if not isinstance(folder, drive_module.Folder):
+            raise MSGraphDownloadError("Drive item is not a folder.")
+        if to_folder is None:
+            downloaded_folder = Path() / folder.name
+        else:
+            downloaded_folder = Path() / to_folder
+        # Method download_contents has no return value.
+        folder.download_contents(to_folder=downloaded_folder)
+        return downloaded_folder
+
+    def _get_sharepoint_drive(
+        self, site: sharepoint.Site, drive: Optional[DriveType] = None
+    ) -> drive_module.Drive:
+        """Returns the specified SharePoint drive if any or the default one if none."""
+        drive = self._parse_drive_param(drive)
+        if drive:
+            return site.get_document_library(drive.object_id)
+        else:
+            return site.get_default_document_library()
+
+    def _sharepoint_items_into_dict_list(
+        self, items_instance: List[sharepoint.SharepointListItem]
+    ) -> List[dict]:
+        """Turns a list of SharePointListItem into a list of dictionaries.
+        This improves the Robot Developer experience, since the inspection
+        of attributes isn't easy in Robot Framework.
+        """
+        items_list = []
+        for item in items_instance:
+            item_dict = {"object_id": item.object_id}
+            item_dict.update(item.fields)
+            items_list.append(item_dict)
+        return items_list
+
+    @keyword
+    def configure_msgraph_client(
+        self,
+        client_id: str,
+        client_secret: str,
+        refresh_token: Optional[str] = None,
+        redirect_uri: Optional[str] = None,
+    ) -> Union[str, None]:
+        """Configures the MS Graph client. If a refresh token is
+        known, it can be provided to obtain a current user token
+        to authenticate with. A new refresh token is returned
+        if one is provided.
+
+        :param client_id: Application client ID.
+        :param client_secret: Application client secret.
+        :param refresh_token: Authorization refresh token obtained from
+         prior OAuth flows.
+        :param redirect_uri: Must be provided with client ID and client
+         secret if not using the default.
+        :return: A new refresh token if one was provided, or ``None``.
+        """
+        credentials = (client_id, client_secret)
+        self.client = Account(credentials, token_backend=self.token_backend)
+        if redirect_uri is not None:
+            self.redirect_uri = redirect_uri
+        else:
+            self.redirect_uri = DEFAULT_REDIRECT_URI
+        if refresh_token:
+            return self.refresh_oauth_token(refresh_token)
+        return None
+
+    @keyword
+    def get_scopes(self, *scopes: str) -> list:
+        # pylint: disable=anomalous-backslash-in-string
+        """Returns the proper scope definitions based on the
+        provided "scope helpers", which are enumerated below.
+        You can pass none to get all scopes. Basic is included
+        in all other scopes. The provided object can be passed
+        to the ``scopes`` parameter when calling
+        \`Generate OAuth Authorization URL\`.
+
+        * ``basic``
+        * ``mailbox``
+        * ``mailbox_shared``
+        * ``message_send``
+        * ``message_send_shared``
+        * ``message_all``
+        * ``message_all_shared``
+        * ``address_book``
+        * ``address_book_shared``
+        * ``address_book_all``
+        * ``address_book_all_shared``
+        * ``calendar``
+        * ``calendar_shared``
+        * ``calendar_all``
+        * ``calendar_shared_all``
+        * ``users``
+        * ``onedrive``
+        * ``onedrive_all``
+        * ``sharepoint``
+        * ``sharepoint_dl``
+        * ``settings_all``
+        * ``tasks``
+        * ``tasks_all``
+        * ``presence``
+
+        :param scopes: Scopes requested. If left empty, all scopes
+         will be returned.
+        :return: A list of Scopes which can be passed to authorization
+         keywords.
+        """  # noqa: W605
+        if len(scopes) == 0:
+            return DEFAULT_PROTOCOL.get_scopes_for(None)
+        else:
+            scopes_to_get = [s.lower() for s in scopes]
+            if "basic" not in scopes_to_get:
+                scopes_to_get.append("basic")
+            return DEFAULT_PROTOCOL.get_scopes_for(scopes_to_get)
+
+    @keyword
+    def generate_oauth_authorization_url(
+        self,
+        client_id: Optional[str] = None,
+        client_secret: Optional[str] = None,
+        redirect_uri: Optional[str] = None,
+        scopes: Optional[list] = None,
+    ) -> str:
+        # pylint: disable=anomalous-backslash-in-string
+        """Generates an authorization URL which must be opened
+        by the user to complete the OAuth flow. If no scopes
+        are provided, the default scope is used which is all
+        scopes defined in the \`Get Scopes\` keyword.
+
+        :param client_id: Application client ID.
+        :param client_secret: Application client secret.
+        :param redirect_uri: Can be provided with client ID and client
+         secret if not using the default.
+        :param scopes: A list of scopes in the form required by the
+         API. Use the helper function \`Get scopes\`.
+        :return: The URL the user must follow to complete their portion
+         of the OAuth flow.
+        """  # noqa: W605
+        if redirect_uri is None:
+            redirect_uri = (
+                self.redirect_uri
+                if self.redirect_uri is not None
+                else DEFAULT_REDIRECT_URI
+            )
+        if not self.client:
+            self.configure_msgraph_client(
+                client_id, client_secret, redirect_uri=redirect_uri
+            )
+        if scopes is None:
+            scopes = self.get_scopes()
+        return self.client.connection.get_authorization_url(
+            scopes,
+            redirect_uri,
+        )[0]
+
+    @keyword
+    def authorize_and_get_token(self, authorization_url: str) -> str:
+        # pylint: disable=anomalous-backslash-in-string
+        """Exchanges the OAuth authorization URL obtained from
+        \`Generate OAuth Authorization URL\` for an access token. This
+        library maintains the user access token for current requests
+        and returns the refresh token to be stored in a secure location
+        (e.g., the Robocorp Control Room Vault).
+
+        :param authorization_url: The full URL retrieved by the user
+         after completing authorization to Microsoft.
+        :return: A refresh token which should be stored securely for
+         future sessions.
+        """  # noqa: W605
+        self._require_client()
+        if self.client.connection.request_token(
+            authorization_url, redirect_uri=self.redirect_uri
+        ):
+            self.logger.info("Authentication successful.")
+            return self._get_refresh_token()
+        else:
+            raise MSGraphAuthenticationError(
+                f"Authentication not successful using '{authorization_url}' as auth URL."  # noqa: E501
+            )
+
+    @keyword
+    def refresh_oauth_token(self, refresh_token: Optional[str] = None) -> str:
+        """Refreshes the user token using the provided ``refresh_token``.
+        The user token is retained in the library and a new
+        refresh token is returned. If no token is provided, this keyword
+        assumes the Robocorp Vault is being used as a backend and attempts
+        to refresh it based on that backend.
+
+        :param refresh_token: The refresh token to use to refresh the
+         session.
+        :return: A refresh token which should be stored securely for
+         future sessions.
+        """
+        self._require_client()
+        if refresh_token:
+            self.token_backend.token = Token(refresh_token=refresh_token)
+            self.token_backend.save_token()
+        if self.client.connection.refresh_token():
+            self.logger.info("Token successfully refreshed.")
+            return self._get_refresh_token()
+        else:
+            raise MSGraphAuthenticationError("Access token could not be refreshed.")
+
+    @keyword
+    def get_me(self) -> directory.User:
+        """Returns the MS Graph object representing the currently logged
+        in user. A ``User`` object is returned. Properties of the user can
+        be accessed like so:
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            Get the me user object
+                ${me}=    Get Me
+                Log    ${me.full_name}
+                Log    ${me.display_name}
+                Log    ${me.given_name}
+                Log    ${me.surname}
+                Log    ${me.full_name}
+                Log    ${me.mail}
+                Log    ${me.business_phones}
+                Log    ${me.mobile_phone}
+                Log    ${me.about_me}
+                Log    ${me.interests}
+                Log    ${me.job_title}
+                Log    ${me.object_id}
+                Log    ${me.user_principal_name}
+        """
+        self._require_authentication()
+        return self.client.get_current_user()
+
+    @keyword
+    def search_for_users(
+        self,
+        search_string: str,
+        search_field: str = "displayName",
+        resource: str = USERS_RESOURCE,
+    ) -> List[directory.User]:
+        # pylint: disable=anomalous-backslash-in-string
+        """Returns a list of ``User`` objects from the Active Directory
+        based on the provided search string.
+
+        ``User`` objects have additional properties that can be accessed
+        with dot-notation, see \`Get Me\` for additional details.
+
+        :param search_string: Text to search for.
+        :param search_field: Where to search. Defaults to display name.
+        :param resource: Name of the resource if not using default.
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            Search users
+                ${users}=    Search For Users    John
+        """  # noqa: W605
+        self._require_authentication()
+
+        # Get the session if not already defined, which is necessary because we
+        # need to modify the headers.
+        if self.client.con.session is None:
+            self.client.con.session = self.client.con.get_session(load_token=True)
+
+        # It is necessary to pass a specific header to use $search, as the error
+        # message instructs: "Request with $search query parameter only works through
+        # MSGraph with a special request header: 'ConsistencyLevel: eventual'".
+        active_directory = self.client.directory(resource)
+        query = active_directory.new_query().search(f"{search_field}:{search_string}")
+        self.client.con.session.headers["ConsistencyLevel"] = "eventual"
+        users = active_directory.get_users(query=query)
+        del self.client.con.session.headers["ConsistencyLevel"]
+        return users
+
+    @keyword
+    def list_files_in_onedrive_folder(
+        self,
+        target_folder: Union[drive_module.Folder, str, None] = None,
+        include_folders: Optional[bool] = False,
+        resource: Optional[str] = None,
+        drive_id: Optional[str] = None,
+    ) -> List[drive_module.DriveItem]:
+        """Returns a list of files from the specified OneDrive folder.
+
+        The files returned are ``DriveItem`` objects and they have additional
+        properties that can be accessed with dot-notation.
+
+        :param target_folder: Path of the folder in Onedrive_module.
+        :param include_folders: Boolean indicating if should return folders as well.
+        :param resource: Name of the resource if not using default.
+        :param drive_id: Drive ID if not using default.
+        :return: List of DriveItems in the folder.
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            List files
+                ${files}=    List Files In Onedrive Folder    /path/to/folder
+                FOR    ${file}    IN    @{files}
+                    Log    ${file.name}
+                    Log    ${file.extension}
+                    Log    ${file.description}
+                    Log    ${file.created_by}
+                    Log    ${file.created}
+                    Log    ${file.is_file}
+                    Log    ${file.is_image}
+                    Log    ${file.is_photo}
+                    Log    ${file.is_folder}
+                    Log    ${file.size}
+                    Log    ${file.web_url}
+                END
+        """
+        self._require_authentication()
+        drive_instance = self.get_drive_instance(resource, drive_id)
+        folder = self.get_folder_instance(drive_instance, target_folder)
+        items = folder.get_items()
+        if include_folders:
+            return items
+        return [item for item in items if not item.is_folder]
+
+    @keyword
+    def download_file_from_onedrive(
+        self,
+        target_file: Union[drive_module.File, str],
+        to_path: Optional[PathType] = None,
+        name: Optional[str] = None,
+        resource: Optional[str] = None,
+        drive_id: Optional[str] = None,
+    ) -> Path:
+        """Downloads a file from Onedrive_module.
+
+        The downloaded file will be saved to a local path.
+
+        :param target_file: ``DriveItem`` or file path of the desired file.
+        :param to_path: Destination folder of the downloaded file,
+         defaults to the current directory.
+        :param name: New name for the downloaded file, with or without extension.
+        :param resource: Name of the resource if not using default.
+        :param drive_id: Drive ID if not using default.
+        :return: Path to the downloaded file.
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            Download file with path
+                ${download_path}=    Download File From Onedrive
+                ...    /path/to/onedrive/file
+                ...    /path/to/local/folder
+                ...    Report.pdf
+
+            Download file with object
+                ${download_path}=    Download File From Onedrive
+                ...    ${drive_item}
+                ...    /path/to/local/folder
+                ...    Report.pdf
+        """
+        self._require_authentication()
+        drive_instance = self.get_drive_instance(resource, drive_id)
+        file_instance = self.get_file_instance(drive_instance, target_file)
+        return self._download_file(file_instance, to_path, name)
+
+    @keyword
+    def download_folder_from_onedrive(
+        self,
+        target_folder: Union[drive_module.Folder, str],
+        to_path: Optional[PathType] = None,
+        resource: Optional[str] = None,
+        drive_id: Optional[str] = None,
+    ) -> Path:
+        """Downloads a folder from OneDrive with all of its contents,
+        including subfolders.
+
+        Caution when downloading big folder structures. The downloaded
+        folder will be saved to a local path.
+
+        :param target_folder: ``DriveItem`` or path of the desired folder.
+        :param to_path: Destination folder where the download will be saved to,
+         defaults to the current directory.
+        :param resource: Name of the resource if not using default.
+        :param drive_id: Drive ID if not using default.
+        :return: Path to the downloaded folder.
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            Download folder with path
+                ${download_path}=    Download Folder From Onedrive
+                ...    /path/to/onedrive/folder
+                ...    /path/to/local/folder
+
+            Download folder with object
+                ${download_path}=    Download Folder From Onedrive
+                ...    ${drive_item}
+                ...    /path/to/local/folder
+        """
+        self._require_authentication()
+        drive_instance = self.get_drive_instance(resource, drive_id)
+        folder_instance = self.get_folder_instance(drive_instance, target_folder)
+        return self._download_folder(folder_instance, to_path)
+
+    @keyword
+    def find_onedrive_file(
+        self,
+        search_string: str,
+        target_folder: Optional[Union[drive_module.Folder, str]] = None,
+        include_folders: Optional[bool] = False,
+        resource: Optional[str] = None,
+        drive_id: Optional[str] = None,
+    ) -> List[drive_module.DriveItem]:
+        # pylint: disable=anomalous-backslash-in-string
+        """Returns a list of files found in OneDrive based on the search string.
+        If a folder is not specified, the search is done in the entire drive and
+        may include items that were shared with the user. It is possible to pass
+        \`root\` as the target folder in order to avoid this behavior.
+
+        The files returned are ``DriveItem`` objects and they have additional
+        properties that can be accessed with dot-notation, see
+        \`List Files In Onedrive Folder\` for details.
+
+        :param search_string: String used to search for file in Onedrive_module.
+         Values may be matched across several fields including filename,
+         metadata, and file content.
+        :param target_folder: Folder where to search for files.
+        :param include_folders: Boolean indicating if should return folders as well.
+        :param resource: Name of the resource if not using default.
+        :param drive_id: Drive ID if not using default.
+        :return: List of DriveItems found based on the search string.
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            Find file
+                ${files}=    Find Onedrive File    Report.xlsx
+        """  # noqa: W605
+        self._require_authentication()
+        drive_instance = self.get_drive_instance(resource, drive_id)
+        if target_folder:
+            folder = self.get_folder_instance(drive_instance, target_folder)
+            items = folder.search(search_string)
+        else:
+            items = drive_instance.search(search_string)
+        if include_folders:
+            return items
+        return [item for item in items if not item.is_folder]
+
+    @keyword
+    def download_file_from_share_link(
+        self,
+        share_url: str,
+        to_path: Optional[PathType] = None,
+        name: Optional[str] = None,
+    ) -> Path:
+        """Downloads file from the share link.
+
+        The downloaded file will be saved to a local path.
+
+        :param share_url: URL of the shared file
+        :param to_path: Destination folder of the downloaded file,
+         defaults to the current directory.
+        :param name: New name for the downloaded file, with or without extension.
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            Download file
+                ${download_path}=    Download File From Share Link
+                ...    https://...
+                ...    /path/to/local/folder
+                ...    Report.pdf
+        """
+        self._require_authentication()
+
+        # O365 doesn't support getting items from shared links yet
+        base_url = self.client.protocol.service_url
+        base_url = base_url[:-1] if base_url.endswith("/") else base_url
+        encoded_url = self._encode_url(share_url)
+        endpoint = "/shares/{id}/driveItem"
+        direct_url = "{}{}".format(base_url, endpoint.format(id=encoded_url))
+
+        response = self.client.con.get(direct_url)
+        if not response:
+            return None
+
+        data = response.json()
+        file_instance = SharedItem(data, direct_url, self.client.con)
+        return self._download_file(file_instance, to_path, name)
+
+    @keyword
+    def upload_file_to_onedrive(
+        self,
+        file_path: str,
+        target_folder: Optional[Union[drive_module.Folder, str]] = None,
+        resource: Optional[str] = None,
+        drive_id: Optional[str] = None,
+    ) -> drive_module.DriveItem:
+        # pylint: disable=anomalous-backslash-in-string
+        """Uploads a file to the specified OneDrive folder.
+
+        The uploaded file is returned as a ``DriveItem`` object and it has
+        additional properties that can be accessed with dot-notation, see
+        \`List Files In Onedrive Folder\` for details.
+
+        :param file_path: Path of the local file being uploaded.
+        :param target_folder: Path of the folder in Onedrive_module.
+        :param resource: Name of the resource if not using default.
+        :param drive_id: Drive ID if not using default.
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            Upload file
+                ${file}=    Upload File To Onedrive
+                ...    /path/to/file.txt
+                ...    /path/to/folder
+        """  # noqa: W605
+        self._require_authentication()
+        drive_instance = self.get_drive_instance(resource, drive_id)
+        folder = self.get_folder_instance(drive_instance, target_folder)
+        return folder.upload_file(item=file_path)
+
+    @keyword
+    def get_sharepoint_site(
+        self, *args: str, resource: Optional[str] = ""
+    ) -> sharepoint.Site:
+        """Returns a SharePoint site.
+
+        :param args: The SharePoint site to retrieve, see documentation
+         for different methods of defining the site.
+        :param resource: Name of the resource if not using default.
+        :return: SharePoint Site instance.
+
+        There are multiple ways of retrieving a SharePoint site:
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            Get SharePoint site with host name
+                [Documentnation]    Use the host_name e.g.
+                ...    'contoso.sharepoint.com' or 'root'.
+                Get SharePoint site    contoso.sharepoint.com
+
+            Get SharePoint site with site ID
+                [Documentnation]    the site_id is a comma separated string
+                ...    of (host_name, site_collection_id, site_id).
+                Get SharePoint site
+                ...    contoso.sharepoint.com, 1234-1234, 5678-5678
+
+            Get SharePoint site with path to Site
+                [Documentnation]    host_name e.g. 'contoso.sharepoint.com'
+                ...    and path_to_site is a url path (with a leading slash).
+                Get SharePoint site    contoso.sharepoint.com
+                ...    /path/to/site
+
+            Get SharePoint site with separate ID args
+                [Documentnation]    the site ID broken into separate args
+                Get SharePoint site    contoso.sharepoint.com
+                ...    1234-1234
+                ...    5678-5678
+
+        The return is of type Site and it has additional properties
+        that can be accessed with dot-notation. See examples below.
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            Get site
+                ${site}=    Get Sharepoint Site    contoso.sharepoint.com
+                Log    ${site.name}
+                Log    ${site.display_name}
+                Log    ${site.description}
+                Log    ${site.web_url}
+                Log    ${site.object_id}
+        """
+        self._require_authentication()
+        sp = self.client.sharepoint(resource=resource)
+        return sp.get_site(*args)
+
+    @keyword
+    def get_items_from_sharepoint_list(
+        self,
+        list_name: str,
+        site: sharepoint.Site,
+    ) -> Table:
+        # pylint: disable=anomalous-backslash-in-string
+        """Returns the items on a SharePoint list as a table. The list
+        is found by it's display name.
+
+        :param list_name: Display name of the SharePoint list.
+        :param site: Site instance obtained from \`Get Sharepoint Site\`.
+        :return: Table or list of dicts of the items.
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            Get List
+                ${table}=    Get Items From Sharepoint List    My List    ${site}
+        """  # noqa: W605
+        self._require_authentication()
+        sp_list = site.get_list_by_name(list_name)
+        sp_items = sp_list.get_items(expand_fields=True)
+        items = self._sharepoint_items_into_dict_list(sp_items)
+
+        return Table(items)
+
+    @keyword
+    def create_sharepoint_list(
+        self,
+        list_data: dict,
+        site: sharepoint.Site,
+    ) -> sharepoint.SharepointList:
+        # pylint: disable=anomalous-backslash-in-string
+        """Creates a sharepoint list and returns the instance.
+
+        :param list_data: Dictionary with the data for the new list.
+        :param site: Site instance obtained from \`Get Sharepoint Site\`.
+        :return: SharePoint List that was created.
+
+        ``SharepointList`` objects have additional properties that can be accessed
+        with dot-notation, see examples below.
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            Create list
+                ${list}=    Create Sharepoint List
+                ...    ${list_data}
+                ...    ${site}
+                Log    ${list.object_id}
+                Log    ${list.name}
+                Log    ${list.display_name}
+                Log    ${list.description}
+                Log    ${list.column_name_cw}
+                Log    ${list.created_by}
+                Log    ${list.created}
+                Log    ${list.last_modified_by}
+                Log    ${list.modified}
+                Log    ${list.web_url}
+        """  # noqa: W605
+        self._require_authentication()
+        return site.create_list(list_data)
+
+    @keyword
+    def list_sharepoint_site_drives(
+        self, site: sharepoint.Site
+    ) -> List[drive_module.Drive]:
+        # pylint: disable=anomalous-backslash-in-string
+        """Get a list of Drives available in the SharePoint Site.
+
+        :param site: Site instance obtained from \`Get Sharepoint Site\`.
+        :return: List of Drives present in the SharePoint Site.
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            List SharePoint drives
+                ${drives}    List Sharepoint Site Drives    ${site}
+                FOR    ${drive}    IN    @{drives}
+                    Log    ${drive.name}
+                    Log    ${drive.description}
+                    Log    ${drive.owner.display_name}
+                    Log    ${drive.web_url}
+                    Log    ${drive.object_id}
+                END
+        """  # noqa: W605
+        self._require_authentication()
+        return site.list_document_libraries()
+
+    @keyword
+    def list_files_in_sharepoint_site_drive(
+        self,
+        site: sharepoint.Site,
+        include_folders: Optional[bool] = False,
+        drive_id: Optional[str] = None,
+    ) -> List[drive_module.DriveItem]:
+        # pylint: disable=anomalous-backslash-in-string
+        """List files in the SharePoint Site drive_module.
+
+        If the ``drive_id`` is not set, the default Document Library
+        will be used. The ``drive_id`` can be obtained from the keyword
+        \`List Sharepoint Site Drives\`.
+
+        The files returned are ``DriveItem`` objects and they have additional
+        properties that can be accessed with dot-notation, see
+        \`List Files In Onedrive Folder\` for details.
+
+        :param site: Site instance obtained from \`Get Sharepoint Site\`.
+        :param include_folders: Boolean indicating if should return folders
+         as well.
+        :param drive_id: ID of the desired drive_module.
+        :return: List of DriveItems present in the Site drive_module.
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+
+            List files in SharePoint drive
+                ${files}    List Files In Sharepoint Site Drive    ${site}
+        """  # noqa: W605
+        self._require_authentication()
+        sp_drive = self._get_sharepoint_drive(site, drive_id)
+        folder = self.get_folder_instance(sp_drive)
+        items = folder.get_items()
+        if include_folders:
+            return items
+        return [item for item in items if not item.is_folder]
+
+    @keyword
+    def download_file_from_sharepoint(
+        self,
+        target_file: Union[drive_module.File, str],
+        site: sharepoint.Site,
+        to_path: Optional[PathType] = None,
+        name: Optional[str] = None,
+        drive_id: Optional[str] = None,
+    ) -> Path:
+        # pylint: disable=anomalous-backslash-in-string
+        """Downloads file from SharePoint.
+
+        The downloaded file will be saved to a local folder.
+
+        :param target_file: ``DriveItem`` or file path of the desired file.
+        :param site: Site instance obtained from \`Get Sharepoint Site\`.
+        :param to_path: Destination folder of the downloaded file,
+         defaults to the current directory.
+        :param name: New name for the downloaded file, with or without extension.
+        :param drive_id: Drive ID if not using default.
+        :return: Path to the downloaded file.
+
+        .. code-block: robotframework
+
+            *** Tasks ***
+            Download file
+                ${download_path}=    Download File From Sharepoint
+                ...    /path/to/sharepoint/file
+                ...    ${site}
+                ...    /path/to/local/folder
+                ...    Report.pdf
+
+            Download file with object
+                ${download_path}=    Download File From Onedrive
+                ...    ${drive_item}
+                ...    ${site}
+                ...    /path/to/local/folder
+                ...    Report.pdf
+        """  # noqa: W605
+        self._require_authentication()
+        sp_drive = self._get_sharepoint_drive(site, drive_id)
+        file_instance = self.get_file_instance(sp_drive, target_file)
+        return self._download_file(file_instance, to_path, name)


Test patch:
diff --git a/packages/main/tests/python/test_msgraph.py b/packages/main/tests/python/test_msgraph.py
new file mode 100644
--- /dev/null
+++ b/packages/main/tests/python/test_msgraph.py
@@ -0,0 +1,954 @@
+from json.encoder import JSONEncoder
+import time
+from typing import List
+from urllib.parse import parse_qs, urlencode, urlparse
+from mock import MagicMock, ANY
+import pytest
+from pytest_mock import MockerFixture
+from RPA.MSGraph import MSGraph, DEFAULT_REDIRECT_URI
+from O365.sharepoint import Site
+from pathlib import Path
+import re
+
+RESOURCE_DIR = Path(__file__).parent / "resources"
+TEMP_DIR = Path(__file__).parent.parent / "results"
+CONFIG_FILE = RESOURCE_DIR / "msgraph"
+SCOPES = MSGraph().get_scopes()
+
+DEFAULT_STATE = "123"
+MOCK_CLIENT_ID = "my-client-id"
+MOCK_CLIENT_SECRET = "my-client-secret"
+MOCK_AUTH_CODE = "https://localhost/myapp/?code=my-mock-auth-code-123&state={}&session_state=mock-session-state#"
+MOCK_ACCESS_TOKEN = "microsoft-access-token-{:0>2}"
+MOCK_REFRESH_TOKEN = "microsoft-refresh-token-{:0>2}"
+
+
+@pytest.fixture
+def library() -> MSGraph:
+    return MSGraph(file_backend_path=TEMP_DIR)
+
+
+@pytest.fixture
+def configured_lib(library: MSGraph) -> MSGraph:
+    library.configure_msgraph_client(MOCK_CLIENT_ID, MOCK_CLIENT_SECRET)
+    return library
+
+
+@pytest.fixture
+def init_auth(library: MSGraph, mocker: MockerFixture) -> str:
+    return library.generate_oauth_authorization_url(MOCK_CLIENT_ID, MOCK_CLIENT_SECRET)
+
+
+def _get_stateful_mock_auth_code(init_auth: str) -> str:
+    init_query = parse_qs(urlparse(init_auth).query)
+    return MOCK_AUTH_CODE.format(init_query["state"][0])
+
+
+@pytest.fixture
+def authorized_lib(
+    configured_lib: MSGraph,
+    mocker: MockerFixture,
+    init_auth: str,
+) -> MSGraph:
+    _patch_token_response(configured_lib, mocker, 1)
+    configured_lib.authorize_and_get_token(_get_stateful_mock_auth_code(init_auth))
+    return configured_lib
+
+
+@pytest.fixture
+def sharepoint_site(authorized_lib: MSGraph, mocker: MockerFixture) -> Site:
+    site_id = "contoso.sharepoint.com"
+    response = {
+        "id": "contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE",
+        "displayName": "OneDrive Team Site",
+        "name": "1drvteam",
+        "createdDateTime": "2017-05-09T20:56:00Z",
+        "lastModifiedDateTime": "2017-05-09T20:56:01Z",
+        "webUrl": "https://contoso.sharepoint.com/teams/1drvteam",
+    }
+    _patch_graph_response(authorized_lib, mocker, response)
+
+    return authorized_lib.get_sharepoint_site(site_id)
+
+
+def _patch_token_response(
+    library: MSGraph, mocker: MockerFixture, iteration: int
+) -> MockerFixture._Patcher:
+    return _patch_graph_response(
+        library,
+        mocker,
+        {
+            "token_type": "Bearer",
+            "scope": "%20F".join(SCOPES),
+            "expires_in": 3600,
+            "access_token": MOCK_ACCESS_TOKEN.format(iteration),
+            "refresh_token": MOCK_REFRESH_TOKEN.format(iteration),
+        },
+    )
+
+
+def _create_graph_json_response(return_value: dict) -> MagicMock:
+    mock_graph_response = MagicMock()
+    mock_graph_response.status_code = 200
+    mock_graph_response.headers = {"Content-Type": "application/json"}
+    mock_graph_response.json.return_value = return_value
+    mock_graph_response.text = JSONEncoder().encode(return_value)
+    return mock_graph_response
+
+
+def _patch_graph_response(
+    library: MSGraph, mocker: MockerFixture, return_value: dict
+) -> MagicMock:
+    mock_graph_response = _create_graph_json_response(return_value)
+    config = {"return_value": mock_graph_response}
+
+    return mocker.patch.object(library.client.connection.session, "request", **config)
+
+
+def _patch_multiple_graph_responses(
+    library: MSGraph, mocker: MockerFixture, mocked_responses: List[MagicMock]
+) -> MagicMock:
+    config = {"side_effect": mocked_responses}
+
+    return mocker.patch.object(library.client.connection.session, "request", **config)
+
+
+def test_configuring_graph_client(library: MSGraph, mocker: MockerFixture) -> None:
+    mock_client = mocker.patch("RPA.MSGraph.Account", autospec=True)
+
+    library.generate_oauth_authorization_url(MOCK_CLIENT_ID, MOCK_CLIENT_SECRET)
+
+    mock_client.assert_any_call((MOCK_CLIENT_ID, MOCK_CLIENT_SECRET), token_backend=ANY)
+
+
+def test_generating_auth_url(init_auth: str) -> None:
+    params = {
+        "response_type": "code",
+        "client_id": MOCK_CLIENT_ID,
+        "redirect_uri": DEFAULT_REDIRECT_URI,
+        "scope": " ".join(SCOPES),
+    }
+    encoded_params = urlencode(params).replace(r"+", r"\+")
+    pattern = re.compile(
+        rf"https:\/\/login.microsoftonline.com\/common\/oauth2\/v2.0\/authorize\?{encoded_params}"
+        r"&state=[a-zA-Z0-9]*&access_type=offline"
+    )
+    result = re.match(pattern, init_auth)
+    assert result
+
+
+def test_auth_cycle(
+    library: MSGraph,
+    mocker: MockerFixture,
+    init_auth: str,
+) -> None:
+    _patch_token_response(library, mocker, 1)
+
+    refresh_token = library.authorize_and_get_token(
+        _get_stateful_mock_auth_code(init_auth)
+    )
+
+    assert library.token_backend.get_token()[
+        "access_token"
+    ] == MOCK_ACCESS_TOKEN.format(1)
+    assert refresh_token == MOCK_REFRESH_TOKEN.format(1)
+
+
+def test_refreshing_token(configured_lib: MSGraph, mocker: MockerFixture) -> None:
+    return_token = {
+        "token_type": "Bearer",
+        "expires_in": 3600,
+        "access_token": MOCK_ACCESS_TOKEN.format(2),
+        "refresh_token": MOCK_REFRESH_TOKEN.format(2),
+        "expires_at": time.time() + 3600,
+        "scope": "%20F".join(SCOPES),
+        "scopes": SCOPES,
+    }
+
+    config = {"return_value.refresh_token.return_value": return_token}
+    mocker.patch("O365.connection.OAuth2Session", **config)
+
+    refresh_token = configured_lib.refresh_oauth_token(MOCK_REFRESH_TOKEN.format(1))
+
+    assert configured_lib.token_backend.get_token()[
+        "access_token"
+    ] == MOCK_ACCESS_TOKEN.format(2)
+    assert refresh_token == MOCK_REFRESH_TOKEN.format(2)
+
+
+def test_get_me(authorized_lib: MSGraph, mocker: MockerFixture) -> None:
+    data = {
+        "businessPhones": ["+1 425 555 0109"],
+        "displayName": "Adele Vance",
+        "givenName": "Adele",
+        "jobTitle": "Retail Manager",
+        "mail": "AdeleV@contoso.onmicrosoft.com",
+        "mobilePhone": "+1 425 555 0109",
+        "officeLocation": "18/2111",
+        "preferredLanguage": "en-US",
+        "surname": "Vance",
+        "userPrincipalName": "AdeleV@contoso.onmicrosoft.com",
+        "id": "87d349ed-44d7-43e1-9a83-5f2406dee5bd",
+    }
+    m = _patch_graph_response(authorized_lib, mocker, data)
+
+    user_me = authorized_lib.get_me()
+
+    m.assert_called_once()
+    assert str(user_me) == data["displayName"]
+    assert user_me.object_id == data["id"]
+
+
+@pytest.mark.parametrize(
+    "search_string,response",
+    [
+        (
+            "adam",
+            {
+                "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users",
+                "value": [
+                    {
+                        "businessPhones": [],
+                        "displayName": "Conf Room Adams",
+                        "givenName": None,
+                        "jobTitle": None,
+                        "mail": "Adams@contoso.com",
+                        "mobilePhone": None,
+                        "officeLocation": None,
+                        "preferredLanguage": None,
+                        "surname": None,
+                        "userPrincipalName": "Adams@contoso.com",
+                        "id": "6ea91a8d-e32e-41a1-b7bd-d2d185eed0e0",
+                    },
+                    {
+                        "businessPhones": ["425-555-0100"],
+                        "displayName": "Adam Administrator",
+                        "givenName": "Adam-adm",
+                        "jobTitle": None,
+                        "mail": None,
+                        "mobilePhone": "425-555-0101",
+                        "officeLocation": None,
+                        "preferredLanguage": "en-US",
+                        "surname": "Administrator",
+                        "userPrincipalName": "admin@contoso.com",
+                        "id": "4562bcc8-c436-4f95-b7c0-4f8ce89dca5e",
+                    },
+                ],
+            },
+        ),
+        (
+            "john",
+            {
+                "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users",
+                "value": [
+                    {
+                        "businessPhones": ["555-555-0100"],
+                        "displayName": "Johnny Apple",
+                        "givenName": "John",
+                        "jobTitle": "IT Admin",
+                        "mail": "j.apple@contoso.com",
+                        "mobilePhone": None,
+                        "officeLocation": None,
+                        "preferredLanguage": None,
+                        "surname": None,
+                        "userPrincipalName": "j.apple@contoso.com",
+                        "id": "6ea91a8d-e32e-41a1-b7bd-d2d185eed123",
+                    },
+                    {
+                        "businessPhones": ["555-123-0100"],
+                        "displayName": "John Smith",
+                        "givenName": "John",
+                        "jobTitle": "BDR",
+                        "mail": "j.smith@contoso.com",
+                        "mobilePhone": "555-123-0101",
+                        "officeLocation": None,
+                        "preferredLanguage": "en-US",
+                        "surname": "Administrator",
+                        "userPrincipalName": "admin@contoso.com",
+                        "id": "4562bcc8-c436-4f95-b7c0-4f8ce89dc123",
+                    },
+                ],
+            },
+        ),
+    ],
+)
+def test_search_for_users(
+    authorized_lib: MSGraph, mocker: MockerFixture, search_string: str, response: dict
+) -> None:
+    m = _patch_graph_response(authorized_lib, mocker, response)
+
+    users = authorized_lib.search_for_users(search_string)
+
+    m.assert_called_once()
+    for user in users:
+        assert user.display_name in [u["displayName"] for u in response["value"]]
+        assert user.user_principal_name in [
+            u["userPrincipalName"] for u in response["value"]
+        ]
+
+
+@pytest.mark.parametrize(
+    "folder_path,responses",
+    [
+        (
+            "/Path/To/Folder/",
+            [
+                {
+                    "createdBy": {
+                        "user": {
+                            "id": "efee1b77-fb3b-4f65-99d6-274c11914d12",
+                            "displayName": "Ryan Gregg",
+                        }
+                    },
+                    "createdDateTime": "2016-03-21T20:01:37Z",
+                    "cTag": '"c:{86EB4C8E-D20D-46B9-AD41-23B8868DDA8A},0"',
+                    "eTag": '"{86EB4C8E-D20D-46B9-AD41-23B8868DDA8A},1"',
+                    "folder": {"childCount": 120},
+                    "id": "01NKDM7HMOJTVYMDOSXFDK2QJDXCDI3WUK",
+                    "lastModifiedBy": {
+                        "user": {
+                            "id": "efee1b77-fb3b-4f65-99d6-274c11914d12",
+                            "displayName": "Ryan Gregg",
+                        }
+                    },
+                    "lastModifiedDateTime": "2016-03-21T20:01:37Z",
+                    "name": "OneDrive",
+                    "root": {},
+                    "size": 157286400,
+                    "webUrl": "https://contoso-my.sharepoint.com/personal/rgregg_contoso_com/Documents",
+                },
+                {
+                    "value": [
+                        {"name": "myfile.jpg", "size": 2048, "file": {}},
+                        {"name": "Documents", "folder": {"childCount": 4}},
+                        {"name": "Photos", "folder": {"childCount": 203}},
+                        {"name": "my sheet(1).xlsx", "size": 197},
+                    ],
+                },
+            ],
+        )
+    ],
+)
+def test_listing_files_onedrive_folder(
+    authorized_lib: MSGraph,
+    mocker: MockerFixture,
+    folder_path: str,
+    responses: List[dict],
+) -> None:
+    mocked_responses = [_create_graph_json_response(r) for r in responses]
+    _patch_multiple_graph_responses(authorized_lib, mocker, mocked_responses)
+
+    items = authorized_lib.list_files_in_onedrive_folder(folder_path)
+
+    files_in_response = [
+        item for item in responses[1]["value"] if not item.get("folder")
+    ]
+    for i, item in enumerate(items):
+        assert item.name == files_in_response[i]["name"]
+        assert not item.is_folder
+
+
+@pytest.mark.parametrize(
+    "file_path,responses",
+    [
+        (
+            "/Path/To/File",
+            [
+                {
+                    "name": "my notes.txt",
+                    "size": 197,
+                    "id": "01NKDM7HMOJTVYMDOSXFDK2QJDXCDI3WUK",
+                },
+                "dummy file content".encode(),
+            ],
+        )
+    ],
+)
+def test_downloading_file_from_onedrive(
+    authorized_lib: MSGraph,
+    mocker: MockerFixture,
+    file_path: str,
+    responses: List[dict],
+) -> None:
+    mocked_responses = []
+    mocked_responses.append(_create_graph_json_response(responses[0]))
+    mocked_response = MagicMock()
+    mocked_response.__enter__.return_value.status_code = 200
+    mocked_response.__enter__.return_value.headers = {
+        "Content-Type": "application/octet-stream"
+    }
+    mocked_response.__enter__.return_value.content = responses[1]
+    mocked_responses.append(mocked_response)
+    _patch_multiple_graph_responses(authorized_lib, mocker, mocked_responses)
+
+    downloaded_file = authorized_lib.download_file_from_onedrive(file_path, TEMP_DIR)
+
+    assert downloaded_file
+    assert downloaded_file.exists()
+
+
+@pytest.mark.parametrize(
+    "file_path,responses",
+    [
+        (
+            "/Path/To/File",
+            [
+                {
+                    "createdBy": {
+                        "user": {
+                            "id": "efee1b77-fb3b-4f65-99d6-274c11914d12",
+                            "displayName": "Ryan Gregg",
+                        }
+                    },
+                    "createdDateTime": "2016-03-21T20:01:37Z",
+                    "cTag": '"c:{86EB4C8E-D20D-46B9-AD41-23B8868DDA8A},0"',
+                    "eTag": '"{86EB4C8E-D20D-46B9-AD41-23B8868DDA8A},1"',
+                    "folder": {"childCount": 120},
+                    "id": "01NKDM7HMOJTVYMDOSXFDK2QJDXCDI3WUK",
+                    "lastModifiedBy": {
+                        "user": {
+                            "id": "efee1b77-fb3b-4f65-99d6-274c11914d12",
+                            "displayName": "Ryan Gregg",
+                        }
+                    },
+                    "lastModifiedDateTime": "2016-03-21T20:01:37Z",
+                    "name": "OneDrive",
+                    "root": {},
+                    "size": 157286400,
+                    "webUrl": "https://contoso-my.sharepoint.com/personal/rgregg_contoso_com/Documents",
+                },
+                {
+                    "value": [
+                        {"name": "myfile.jpg", "size": 2048, "file": {}},
+                        {"name": "my sheet(1).xlsx", "size": 197},
+                    ],
+                },
+                "dummy file content".encode(),
+            ],
+        )
+    ],
+)
+def test_downloading_folder_from_onedrive(
+    authorized_lib: MSGraph,
+    mocker: MockerFixture,
+    file_path: str,
+    responses: List[dict],
+) -> None:
+    mocked_responses = [
+        _create_graph_json_response(r) for r in (responses[0], responses[1])
+    ]
+
+    mocked_response = MagicMock()
+    mocked_response.__enter__.return_value.status_code = 200
+    mocked_response.__enter__.return_value.headers = {
+        "Content-Type": "application/octet-stream"
+    }
+    mocked_response.__enter__.return_value.content = responses[2]
+    for _ in responses[1]["value"]:
+        mocked_responses.append(mocked_response)
+
+    _patch_multiple_graph_responses(authorized_lib, mocker, mocked_responses)
+
+    downloaded_folder = authorized_lib.download_folder_from_onedrive(
+        file_path, TEMP_DIR / "downloaded_folder"
+    )
+
+    assert downloaded_folder
+    assert downloaded_folder.exists()
+
+
+@pytest.mark.parametrize(
+    "search_string,response",
+    [
+        (
+            "Contoso Project",
+            {
+                "value": [
+                    {
+                        "id": "0123456789abc!123",
+                        "name": "Contoso Project",
+                        "folder": {},
+                        "searchResult": {
+                            "onClickTelemetryUrl": "https://bing.com/0123456789abc!123"
+                        },
+                    },
+                    {
+                        "id": "0123456789abc!456",
+                        "name": "Contoso Project 2016",
+                        "folder": {},
+                        "searchResult": {
+                            "onClickTelemetryUrl": "https://bing.com/0123456789abc!456"
+                        },
+                    },
+                ],
+            },
+        )
+    ],
+)
+def test_finding_onedrive_file(
+    authorized_lib: MSGraph, mocker: MockerFixture, search_string: str, response: dict
+) -> None:
+    m = _patch_graph_response(authorized_lib, mocker, response)
+
+    items = authorized_lib.find_onedrive_file(search_string)
+
+    m.assert_called_once()
+    for item in items:
+        assert item.object_id in [i["id"] for i in response["value"]]
+        assert item.name in [i["name"] for i in response["value"]]
+
+
+@pytest.mark.parametrize(
+    "share_url,responses",
+    [
+        (
+            "https://1drv.ms/v/s!AjonToUPWqXmgjO3RqDbhRaSMrOM",
+            [
+                {
+                    "name": "report.txt",
+                    "size": 1997,
+                    "id": "01NKDM7HMOJTVYMDOSXFDK2QJDXCDI3WUK",
+                },
+                "dummy file content".encode(),
+            ],
+        )
+    ],
+)
+def test_downloading_from_share_link(
+    authorized_lib: MSGraph,
+    mocker: MockerFixture,
+    share_url: str,
+    responses: List[dict],
+) -> None:
+    mocked_responses = []
+    mocked_responses.append(_create_graph_json_response(responses[0]))
+    mocked_response = MagicMock()
+    mocked_response.__enter__.return_value.status_code = 200
+    mocked_response.__enter__.return_value.headers = {
+        "Content-Type": "application/octet-stream"
+    }
+    mocked_response.__enter__.return_value.content = responses[1]
+    mocked_responses.append(mocked_response)
+    _patch_multiple_graph_responses(authorized_lib, mocker, mocked_responses)
+
+    downloaded_file = authorized_lib.download_file_from_share_link(share_url, TEMP_DIR)
+
+    assert downloaded_file
+    assert downloaded_file.exists()
+
+
+@pytest.mark.parametrize(
+    "file_path,folder_path,responses",
+    [
+        (
+            "/Path/To/my notes.txt",
+            "/Path/To/Folder/",
+            [
+                {
+                    "createdBy": {
+                        "user": {
+                            "id": "efee1b77-fb3b-4f65-99d6-274c11914d12",
+                            "displayName": "Ryan Gregg",
+                        }
+                    },
+                    "createdDateTime": "2016-03-21T20:01:37Z",
+                    "cTag": '"c:{86EB4C8E-D20D-46B9-AD41-23B8868DDA8A},0"',
+                    "eTag": '"{86EB4C8E-D20D-46B9-AD41-23B8868DDA8A},1"',
+                    "folder": {"childCount": 120},
+                    "id": "01NKDM7HMOJTVYMDOSXFDK2QJDXCDI3WUK",
+                    "lastModifiedBy": {
+                        "user": {
+                            "id": "efee1b77-fb3b-4f65-99d6-274c11914d12",
+                            "displayName": "Ryan Gregg",
+                        }
+                    },
+                    "lastModifiedDateTime": "2016-03-21T20:01:37Z",
+                    "name": "OneDrive",
+                    "root": {},
+                    "size": 157286400,
+                    "webUrl": "https://contoso-my.sharepoint.com/personal/rgregg_contoso_com/Documents",
+                },
+                {
+                    "name": "my notes.txt",
+                    "size": 197,
+                    "id": "01NKDM7HMOJTVYMDOSXFDK2QJDXCDI3WUK",
+                },
+            ],
+        )
+    ],
+)
+def test_uploading_file_to_onedrive(
+    authorized_lib: MSGraph,
+    mocker: MockerFixture,
+    file_path: str,
+    folder_path: str,
+    responses: List[dict],
+) -> None:
+    mocked_responses = [_create_graph_json_response(r) for r in responses]
+    _patch_multiple_graph_responses(authorized_lib, mocker, mocked_responses)
+
+    # Mock file interactions
+    mocker.patch("pathlib.Path.exists", return_value=True)
+    mocker.patch("pathlib.Path.is_file", return_value=True)
+    mocked_path = MagicMock()
+    mocked_path.st_size = 956514
+    mocker.patch("pathlib.Path.stat", return_value=mocked_path)
+    mocker.patch("io.open", mocker.mock_open(read_data="secret notes"))
+
+    item = authorized_lib.upload_file_to_onedrive(file_path, folder_path)
+
+    assert item.name == responses[1]["name"]
+
+
+@pytest.mark.parametrize(
+    "args",
+    [
+        ("root"),
+        ("contoso.sharepoint.com"),
+        ("contoso.sharepoint.com", "/path/to/site"),
+        (
+            "contoso.sharepoint.com,a384ebb0-67a5-4976-a7eb-60bbd5d5f87a,f0a2c07f-bfa0-4259-b9cd-fbdab88ebcf4"
+        ),
+        (
+            "contoso.sharepoint.com",
+            "a384ebb0-67a5-4976-a7eb-60bbd5d5f87a",
+            "f0a2c07f-bfa0-4259-b9cd-fbdab88ebcf4",
+        ),
+    ],
+)
+def test_get_sharepoint_site(
+    authorized_lib: MSGraph, mocker: MockerFixture, args: tuple
+) -> None:
+    response = {
+        "id": "contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE",
+        "displayName": "OneDrive Team Site",
+        "name": "1drvteam",
+        "createdDateTime": "2017-05-09T20:56:00Z",
+        "lastModifiedDateTime": "2017-05-09T20:56:01Z",
+        "webUrl": "https://contoso.sharepoint.com/teams/1drvteam",
+    }
+    m = _patch_graph_response(authorized_lib, mocker, response)
+
+    if isinstance(args, str):
+        site = authorized_lib.get_sharepoint_site(args)
+    else:
+        site = authorized_lib.get_sharepoint_site(*args)
+
+    m.assert_called_once()
+    assert site.display_name == response["displayName"]
+    assert site.object_id == response["id"]
+
+
+def test_get_items_from_sharepoint_list(
+    authorized_lib: MSGraph, mocker: MockerFixture, sharepoint_site: Site
+) -> None:
+    list_name = "Documents"
+    list_response = {
+        "id": "b57af081-936c-4803-a120-d94887b03864",
+        "name": "Documents",
+        "createdDateTime": "2016-08-30T08:32:00Z",
+        "lastModifiedDateTime": "2016-08-30T08:32:00Z",
+        "list": {"hidden": False, "template": "documentLibrary"},
+    }
+    columns_response = {
+        "value": [
+            {
+                "description": "",
+                "displayName": "Name",
+                "hidden": False,
+                "id": "99ddcf45-e2f7-4f17-82b0-6fba34445103",
+                "indexed": False,
+                "name": "Name",
+                "readOnly": False,
+                "required": False,
+                "text": {
+                    "allowMultipleLines": False,
+                    "appendChangesToExistingText": False,
+                    "linesForEditing": 0,
+                    "maxLength": 255,
+                },
+            },
+            {
+                "description": "",
+                "displayName": "Color",
+                "id": "11dfef35-e2f7-4f17-82b0-6fba34445103",
+                "indexed": False,
+                "name": "Color",
+                "readOnly": False,
+                "required": False,
+                "text": {
+                    "allowMultipleLines": False,
+                    "appendChangesToExistingText": False,
+                    "linesForEditing": 0,
+                    "maxLength": 255,
+                },
+            },
+            {
+                "description": "",
+                "displayName": "Quantity",
+                "id": "27c36545-4c19-4af8-a5a1-eaf520dbba25",
+                "indexed": False,
+                "name": "Quantity",
+                "readOnly": False,
+                "required": False,
+                "text": {
+                    "allowMultipleLines": False,
+                    "appendChangesToExistingText": False,
+                    "linesForEditing": 0,
+                    "maxLength": 255,
+                },
+            },
+        ]
+    }
+    list_items_response = {
+        "value": [
+            {
+                "id": "2",
+                "fields": {"Name": "Gadget", "Color": "Red", "Quantity": 503},
+            },
+            {
+                "id": "4",
+                "fields": {"Name": "Widget", "Color": "Blue", "Quantity": 2357},
+            },
+            {
+                "id": "7",
+                "fields": {"Name": "Gizmo", "Color": "Green", "Quantity": 92},
+            },
+        ]
+    }
+    mocked_responses = [
+        _create_graph_json_response(r)
+        for r in (list_response, columns_response, list_items_response)
+    ]
+    _patch_multiple_graph_responses(authorized_lib, mocker, mocked_responses)
+
+    items = authorized_lib.get_items_from_sharepoint_list(list_name, sharepoint_site)
+
+    assert items
+    for item in items:
+        assert item["object_id"] in [i["id"] for i in list_items_response["value"]]
+
+
+def test_create_sharepoint_list(
+    authorized_lib: MSGraph, mocker: MockerFixture, sharepoint_site: Site
+) -> None:
+    new_list_data = {
+        "displayName": "Books",
+        "columns": [
+            {"name": "Author", "text": {}},
+            {"name": "PageCount", "number": {}},
+        ],
+        "list": {"template": "genericList"},
+    }
+    list_response = {
+        "id": "22e03ef3-6ef4-424d-a1d3-92a337807c30",
+        "createdDateTime": "2017-04-30T01:21:00Z",
+        "createdBy": {
+            "user": {
+                "displayName": "Ryan Gregg",
+                "id": "8606e4d5-d582-4f5f-aeba-7d7c18b20cfd",
+            }
+        },
+        "lastModifiedDateTime": "2016-08-30T08:26:00Z",
+        "lastModifiedBy": {
+            "user": {
+                "displayName": "Ryan Gregg",
+                "id": "8606e4d5-d582-4f5f-aeba-7d7c18b20cfd",
+            }
+        },
+    }
+    columns_response = {
+        "value": [
+            {
+                "description": "",
+                "displayName": "Author",
+                "hidden": False,
+                "id": "99ddcf45-e2f7-4f17-82b0-6fba34445103",
+                "indexed": False,
+                "name": "Author",
+                "readOnly": False,
+                "required": False,
+                "text": {
+                    "allowMultipleLines": False,
+                    "appendChangesToExistingText": False,
+                    "linesForEditing": 0,
+                    "maxLength": 255,
+                },
+            },
+            {
+                "description": "",
+                "displayName": "PageCount",
+                "id": "11dfef35-e2f7-4f17-82b0-6fba34445103",
+                "indexed": False,
+                "name": "PageCount",
+                "readOnly": False,
+                "required": False,
+                "text": {
+                    "allowMultipleLines": False,
+                    "appendChangesToExistingText": False,
+                    "linesForEditing": 0,
+                    "maxLength": 255,
+                },
+            },
+        ]
+    }
+    mocked_responses = [
+        _create_graph_json_response(r) for r in (list_response, columns_response)
+    ]
+    _patch_multiple_graph_responses(authorized_lib, mocker, mocked_responses)
+
+    sp_list = authorized_lib.create_sharepoint_list(new_list_data, sharepoint_site)
+
+    assert sp_list.object_id == list_response["id"]
+    assert (
+        sp_list.created_by.display_name
+        == list_response["createdBy"]["user"]["displayName"]
+    )
+
+
+def test_list_sharepoint_drives(
+    authorized_lib: MSGraph, mocker: MockerFixture, sharepoint_site: Site
+) -> None:
+    response = {
+        "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#drives",
+        "value": [
+            {
+                "createdDateTime": "2017-07-27T02:41:36Z",
+                "description": "",
+                "id": "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd",
+                "lastModifiedDateTime": "2018-03-27T07:34:38Z",
+                "name": "OneDrive",
+                "webUrl": "https://m365x214355-my.sharepoint.com/personal/meganb_m365x214355_onmicrosoft_com/Documents",
+                "driveType": "business",
+                "createdBy": {"user": {"displayName": "System Account"}},
+                "lastModifiedBy": {
+                    "user": {
+                        "email": "MeganB@M365x214355.onmicrosoft.com",
+                        "id": "48d31887-5fad-4d73-a9f5-3c356e68a038",
+                        "displayName": "Megan Bowen",
+                    }
+                },
+                "owner": {
+                    "user": {
+                        "email": "MeganB@M365x214355.onmicrosoft.com",
+                        "id": "48d31887-5fad-4d73-a9f5-3c356e68a038",
+                        "displayName": "Megan Bowen",
+                    }
+                },
+                "quota": {
+                    "deleted": 0,
+                    "remaining": 1099217021300,
+                    "state": "normal",
+                    "total": 1099511627776,
+                    "used": 294606476,
+                },
+            }
+        ],
+    }
+    m = _patch_graph_response(authorized_lib, mocker, response)
+
+    sp_drives = authorized_lib.list_sharepoint_site_drives(sharepoint_site)
+
+    m.assert_called_once()
+    assert sp_drives
+    for sp_drive in sp_drives:
+        assert sp_drive.object_id in [drive["id"] for drive in response["value"]]
+        assert sp_drive.name in [drive["name"] for drive in response["value"]]
+
+
+def test_list_files_in_sharepoint_drive(
+    authorized_lib: MSGraph,
+    mocker: MockerFixture,
+    sharepoint_site: Site,
+) -> None:
+    drive_id = None
+    response_folder = {
+        "createdBy": {
+            "user": {
+                "id": "efee1b77-fb3b-4f65-99d6-274c11914d12",
+                "displayName": "Ryan Gregg",
+            }
+        },
+        "createdDateTime": "2016-03-21T20:01:37Z",
+        "cTag": '"c:{86EB4C8E-D20D-46B9-AD41-23B8868DDA8A},0"',
+        "eTag": '"{86EB4C8E-D20D-46B9-AD41-23B8868DDA8A},1"',
+        "folder": {"childCount": 120},
+        "id": "01NKDM7HMOJTVYMDOSXFDK2QJDXCDI3WUK",
+        "lastModifiedBy": {
+            "user": {
+                "id": "efee1b77-fb3b-4f65-99d6-274c11914d12",
+                "displayName": "Ryan Gregg",
+            }
+        },
+        "lastModifiedDateTime": "2016-03-21T20:01:37Z",
+        "name": "OneDrive",
+        "root": {},
+        "size": 157286400,
+        "webUrl": "https://contoso-my.sharepoint.com/personal/rgregg_contoso_com/Documents",
+    }
+
+    response_files = {
+        "value": [
+            {"name": "myfile.jpg", "size": 2048, "file": {}},
+            {"name": "Documents", "folder": {"childCount": 4}},
+            {"name": "Photos", "folder": {"childCount": 203}},
+            {"name": "my sheet(1).xlsx", "size": 197},
+        ],
+    }
+    mocked_responses = [
+        _create_graph_json_response(r) for r in (response_folder, response_files)
+    ]
+    _patch_multiple_graph_responses(authorized_lib, mocker, mocked_responses)
+
+    sp_files = authorized_lib.list_files_in_sharepoint_site_drive(
+        sharepoint_site, drive_id=drive_id
+    )
+
+    for file in sp_files:
+        assert file.name in [file["name"] for file in response_files["value"]]
+
+
+@pytest.mark.parametrize(
+    "file_path, target_directory, drive_id, responses",
+    [
+        (
+            "/Path/To/File",
+            TEMP_DIR,
+            None,
+            [
+                {
+                    "name": "file-from-sharepoint.txt",
+                    "size": 500097,
+                    "id": "3JL14H5L1HJC0PPPQO7EMQB",
+                },
+                "dummy file content".encode(),
+            ],
+        ),
+    ],
+)
+def test_download_file_from_sharepoint(
+    authorized_lib: MSGraph,
+    mocker: MockerFixture,
+    sharepoint_site: Site,
+    drive_id: str,
+    file_path: str,
+    target_directory: str,
+    responses: List[dict],
+) -> None:
+    mocked_responses = []
+    mocked_responses.append(_create_graph_json_response(responses[0]))
+    mocked_response = MagicMock()
+    mocked_response.__enter__.return_value.status_code = 200
+    mocked_response.__enter__.return_value.headers = {
+        "Content-Type": "application/octet-stream"
+    }
+    mocked_response.__enter__.return_value.content = responses[1]
+    mocked_responses.append(mocked_response)
+    _patch_multiple_graph_responses(authorized_lib, mocker, mocked_responses)
+
+    downloaded_file = authorized_lib.download_file_from_sharepoint(
+        file_path, sharepoint_site, target_directory, drive_id
+    )
+
+    assert downloaded_file
+    assert downloaded_file.exists()