GSuiteApiModule

Common G Suite code that will be appended to each Google/GSuite integration when it is deployed.

python · ApiModules

Details

IDGSuiteApiModule
Languagepython
From Version5.0.0
Docker Imagedemisto/googleapi-python3:1.0.0.12698
Tagsinfra server

README

To use the common GSuite API logic (authentication and API requests), run the following command to import the GSuiteApiModule.

def main():
    ...


from GSuiteApiModule import *  # noqa: E402

if __name__ in ["builtins", "__main__"]:
    main()

Then, the GSuiteClient will be available for usage. For examples, see the G Suite Admin or Google Drive or Google Calendar integrations.

import json

import pytest
from GSuiteApiModule import (
    COMMON_MESSAGES,
    DemistoException,
    GSuiteClient,
    UcpException,
)

with open("test_data/service_account_json.txt") as f:
    TEST_JSON = f.read()

PROXY_METHOD_NAME = "GSuiteApiModule.handle_proxy"

CREDENTIAL_SUBJECT = "test@org.com"

MOCKER_HTTP_METHOD = "GSuiteApiModule.GSuiteClient.http_request"


@pytest.fixture
def gsuite_client():
    headers = {"Content-Type": "application/json"}
    return GSuiteClient(
        GSuiteClient.safe_load_non_strict_json(TEST_JSON),
        base_url="https://www.googleapis.com/",
        verify=False,
        proxy=False,
        headers=headers,
    )


def test_safe_load_non_strict_json():
    """
    Scenario: Dictionary should be prepared from json string.

    Given:
    - json as string.

    When:
    - Preparing dictionary from string.

    Then:
    - Ensure valid json should be loaded successfully.
    """
    excepted_json = json.loads(TEST_JSON, strict=False)
    assert GSuiteClient.safe_load_non_strict_json(TEST_JSON) == excepted_json


def test_safe_load_non_strict_json_parse_error():
    """
    Scenario: Failed to load json when invalid json string is given.

    Given:
    - Empty json string.

    When:
    - Preparing dictionary from string.

    Then:
    - Ensure Exception is raised with proper error message.
    """

    with pytest.raises(ValueError, match=COMMON_MESSAGES["JSON_PARSE_ERROR"]):
        GSuiteClient.safe_load_non_strict_json("Invalid json")


def test_safe_load_non_strict_json_empty():
    """
    Scenario: Returns {}(blank) dictionary when empty json string is given.

    Given:
    - Invalid json as string.

    When:
    - Preparing dictionary from string.

    Then:
    - Ensure {}(blank) dictionary should be returned.
    """

    assert GSuiteClient.safe_load_non_strict_json("") == {}


def test_validate_and_extract_response(mocker):
    """
    Scenario: Parse response when status code is 200 or 204.

    Given:
    - Tuple containing response object and content.

    When:
    - Validating and loading json from response.

    Then:
    - Ensure content json should be parsed successfully.
    """
    from GSuiteApiModule import demisto, httplib2

    mocker.patch.object(demisto, "debug")
    response = httplib2.Response({"status": 200})
    expected_content = {"response": {}}
    assert GSuiteClient.validate_and_extract_response((response, b'{"response": {}}')) == expected_content


def test_validate_and_extract_response_error(mocker):
    """
    Scenario: Should raise exception when status code is not 200 or 204.

    Given:
    - Tuple containing response object and content.

    When:
    - Validating and loading json from response.

    Then:
    - Ensure the Demisto exception should be raised respective to status code.
    """
    from GSuiteApiModule import demisto, httplib2

    mocker.patch.object(demisto, "debug")
    response = httplib2.Response({"status": 400})

    with pytest.raises(DemistoException, match=COMMON_MESSAGES["BAD_REQUEST_ERROR"].format("BAD REQUEST")):
        GSuiteClient.validate_and_extract_response((response, b'{"error": {"message":"BAD REQUEST"}}'))

    response = httplib2.Response({"status": 509})

    with pytest.raises(DemistoException, match=COMMON_MESSAGES["UNKNOWN_ERROR"].format(509, "error")):
        GSuiteClient.validate_and_extract_response((response, b'{"error": {"message":"error"}}'))


def test_get_http_client(mocker):
    """
    Scenario: Should return http client object with configured proxy, verify and timeout parameters.

    Given:
    - proxy: Boolean indicates whether to use proxy or not.
    - verify: Boolean indicates whether to use ssl certification.
    - timeout: Timeout value for request.

    When:
    - Initializing httplib2.Http object when proxy, timeout and verify parameters provided.

    Then:
    - Ensure configured Http() object should be return.
    """
    from GSuiteApiModule import httplib2

    mocker.patch(PROXY_METHOD_NAME, return_value={"https": "http url"})

    http = GSuiteClient.get_http_client(proxy=True, verify=False, timeout=60)
    assert isinstance(http, httplib2.Http)
    assert http.disable_ssl_certificate_validation is True
    assert http.timeout == 60


def test_get_http_client_prefix_https_addition(mocker):
    """
    Scenario: Should return Http object with proxy configured with prefix https.

    Given:
    - proxy: Boolean indicates whether to use proxy or not.
    - verify: Boolean indicates whether to use ssl certification.
    - timeout: Timeout value for request.

    When:
    - Initializing httplib2.Http object when proxy, timeout and verify parameters provided.

    Then:
    - Ensure prefix https should be added before given https proxy value.
    """
    from GSuiteApiModule import httplib2

    mocker.patch(PROXY_METHOD_NAME, return_value={"https": "demisto:admin@0.0.0.0:3128"})

    http = GSuiteClient.get_http_client(proxy=True, verify=True)
    assert isinstance(http, httplib2.Http)
    assert http.proxy_info.proxy_host == "0.0.0.0"
    assert http.proxy_info.proxy_port == 3128
    assert http.proxy_info.proxy_user == "demisto"
    assert http.proxy_info.proxy_pass == "admin"


def test_set_authorized_http(gsuite_client):
    """
    Scenario: Initialize AuthorizedHttp with given subject, scopes and timeout.

    Given:
    - scopes: List of scopes needed to make request.
    - subject: To link subject with credentials.
    - timeout: Timeout value for request.

    When:
    - Initializing AuthorizedHttp with the parameters provided.

    Then:
    - Ensure AuthorizedHttp is returned with configuration.
    """
    from GSuiteApiModule import AuthorizedHttp

    gsuite_client.set_authorized_http(scopes=["scope1", "scope2"], subject=CREDENTIAL_SUBJECT)
    assert isinstance(gsuite_client.authorized_http, AuthorizedHttp)


def test_http_request(mocker, gsuite_client):
    """
    Scenario: Request to API call should give response.

    Given:
    - url_suffix: url_suffix of url.
    - params: Parameters to pass in request url.
    - method: Method to use while making http request.
    - body: Request body.

    When:
    - Initializing AuthorizedHttp with the parameters provided.

    Then:
    - Ensure AuthorizedHttp is returned with configuration.
    """
    from GSuiteApiModule import AuthorizedHttp, httplib2

    content = '{"items": {}}'
    response = httplib2.Response({"status": 200, "content": content})

    mocker.patch.object(AuthorizedHttp, "request", return_value=(response, content))

    gsuite_client.set_authorized_http(scopes=["scope1", "scope2"], subject=CREDENTIAL_SUBJECT)
    expected_response = gsuite_client.http_request(
        url_suffix="url_suffix",
        params={"userId": "abc"},
    )

    assert expected_response == {"items": {}}


def test_http_request_http_error(mocker, gsuite_client):
    """
    Scenario: Proxy setup is invalid, Request to API call should give respective message for proxy error.

    Given:
    - url_suffix: url_suffix of url.
    - params: Parameters to pass in request url.

    When:
    - Initializing AuthorizedHttp with the parameters provided.

    Then:
    - Ensure Demisto exception is raised with respective proxy error.
    """
    from GSuiteApiModule import AuthorizedHttp, httplib2

    gsuite_client.set_authorized_http(scopes=["scope1", "scope2"], subject=CREDENTIAL_SUBJECT)

    # Proxy Error
    mocker.patch.object(AuthorizedHttp, "request", side_effect=httplib2.socks.HTTPError((407, b"proxy error")))
    with pytest.raises(DemistoException):
        gsuite_client.http_request(url_suffix="url_suffix", params={"userId": "abc"})

    # HTTP Error
    mocker.patch.object(AuthorizedHttp, "request", side_effect=httplib2.socks.HTTPError((409, b"HTTP error")))
    with pytest.raises(DemistoException):
        gsuite_client.http_request(url_suffix="url_suffix", params={"userId": "abc"})

    # HTTP Error no tuple
    mocker.patch.object(AuthorizedHttp, "request", side_effect=httplib2.socks.HTTPError("HTTP error"))
    with pytest.raises(DemistoException):
        gsuite_client.http_request(url_suffix="url_suffix", params={"userId": "abc"})


def test_http_request_timeout_error(mocker, gsuite_client):
    """
    Scenario: url is invalid, Request to API call should give respective message for connection timeout.

    Given:
    - url_suffix: url_suffix of url.
    - params: Parameters to pass in request url.

    When:
    - Initializing AuthorizedHttp with the parameters provided.

    Then:
    - Ensure Demisto exception is raised with respective connection timeout error.
    """
    from GSuiteApiModule import AuthorizedHttp

    gsuite_client.set_authorized_http(scopes=["scope1", "scope2"], subject=CREDENTIAL_SUBJECT)

    mocker.patch.object(AuthorizedHttp, "request", side_effect=TimeoutError("timeout error"))

    with pytest.raises(DemistoException, match=COMMON_MESSAGES["TIMEOUT_ERROR"].format("timeout error")):
        gsuite_client.http_request(url_suffix="url_suffix", params={"userId": "abc"})


def test_http_request_transport_error(mocker, gsuite_client):
    """
    Scenario: url is invalid, Request to API call should give respective message for transport error.

    Given:
    - url_suffix: url_suffix of url.
    - params: Parameters to pass in request url.

    When:
    - Initializing AuthorizedHttp with the parameters provided.

    Then:
    - Ensure Demisto exception is raised with respective transport error.
    """
    from GSuiteApiModule import AuthorizedHttp, exceptions

    gsuite_client.set_authorized_http(scopes=["scope1", "scope2"], subject=CREDENTIAL_SUBJECT)

    mocker.patch.object(AuthorizedHttp, "request", side_effect=exceptions.TransportError("proxyerror"))

    with pytest.raises(DemistoException, match=COMMON_MESSAGES["PROXY_ERROR"]):
        gsuite_client.http_request(url_suffix="url_suffix", params={"userId": "abc"})

    mocker.patch.object(AuthorizedHttp, "request", side_effect=exceptions.TransportError("new error"))
    with pytest.raises(DemistoException, match=COMMON_MESSAGES["TRANSPORT_ERROR"].format("new error")):
        gsuite_client.http_request(url_suffix="url_suffix", params={"userId": "abc"})


def test_http_request_refresh_error(mocker, gsuite_client):
    """
    Scenario: Failed to generate/refresh token, Request to API call should give respective message.

    Given:
    - url_suffix: url_suffix of url.
    - params: Parameters to pass in request url.

    When:
    - Initializing AuthorizedHttp with the parameters provided.

    Then:
    - Ensure Demisto exception is raised with respective refresh error message.
    """
    from GSuiteApiModule import AuthorizedHttp, exceptions

    gsuite_client.set_authorized_http(scopes=["scope1", "scope2"], subject=CREDENTIAL_SUBJECT)
    mocker.patch.object(
        AuthorizedHttp,
        "request",
        side_effect=exceptions.RefreshError("invalid_request: Invalid impersonation & quot; sub & quot; field."),
    )

    with pytest.raises(
        DemistoException,
        match=COMMON_MESSAGES["REFRESH_ERROR"].format("invalid_request: Invalid impersonation & quot; sub & quot; field."),
    ):
        gsuite_client.http_request(url_suffix="url_suffix", params={"userId": "abc"})


def test_http_request_error(mocker, gsuite_client):
    """
    Scenario: Some unknown error occurred during request to API call should give respective message.

    Given:
    - url_suffix: url_suffix of url.
    - params: Parameters to pass in request url.

    When:
    - Initializing AuthorizedHttp with the parameters provided.

    Then:
    - Ensure Demisto exception is raised with respective error.
    """
    from GSuiteApiModule import AuthorizedHttp

    gsuite_client.set_authorized_http(scopes=["scope1", "scope2"], subject=CREDENTIAL_SUBJECT)

    mocker.patch.object(AuthorizedHttp, "request", side_effect=Exception("error"))

    with pytest.raises(DemistoException, match="error"):
        gsuite_client.http_request(url_suffix="url_suffix", params={"userId": "abc"})


def test_strip_dict():
    """
    Scenario: Call to test-module should return 'ok' if API call succeeds.

    Given:
    - A dictionary with entries having whitespaces and empty values

    When:
    - Calling strip_dict() method.

    Then:
    - Ensure returned dictionary has stripped values and entries with empty values are removed.
    """
    sample_input = {"key1": "  VALUE_1 ", "key2": ""}
    sample_output = {"key1": "VALUE_1"}
    assert GSuiteClient.strip_dict(sample_input) == sample_output


# ---------------------------------------------------------------------------
# UCP (ConnectUs) support tests
# ---------------------------------------------------------------------------


TEST_ACCESS_TOKEN = "eyJhbGci.test.token"


def _oauth2_credentials(access_token):
    """Build a UCP oauth2 credential envelope."""
    return {"type": "oauth2", "oauth2": {"access_token": access_token, "expires_at": "2026-04-19T18:00:00+00:00"}}


def _patch_ucp_fetch(mocker, access_token=TEST_ACCESS_TOKEN):
    """Patch the UCP profile resolution + fetch chain to return an oauth2 token.

    :return: The ``get_ucp_credentials`` mock so callers can assert on its args.
    """
    mocker.patch("GSuiteApiModule.resolve_ucp_capability", return_value="cap")
    mocker.patch("GSuiteApiModule.get_ucp_method_unique_id", return_value="method-1")
    return mocker.patch("GSuiteApiModule.get_ucp_credentials", return_value=_oauth2_credentials(access_token))


class TestGetUcpAccessToken:
    def test_nested_access_token(self, mocker):
        """get_ucp_access_token should unwrap a nested oauth2 access token."""
        _patch_ucp_fetch(mocker, TEST_ACCESS_TOKEN)
        assert GSuiteClient.get_ucp_access_token() == ("method-1", TEST_ACCESS_TOKEN)

    def test_subject_forwarded_as_extra_body(self, mocker):
        """A subject should be forwarded to UCP as ``{"extra": {"subject": ...}}``."""
        get_creds = _patch_ucp_fetch(mocker, TEST_ACCESS_TOKEN)

        GSuiteClient.get_ucp_access_token(subject=CREDENTIAL_SUBJECT)

        get_creds.assert_called_once_with("method-1", body={"extra": {"subject": CREDENTIAL_SUBJECT}})

    def test_no_subject_sends_no_body(self, mocker):
        """When no subject is given, no body is sent to UCP."""
        get_creds = _patch_ucp_fetch(mocker, TEST_ACCESS_TOKEN)

        GSuiteClient.get_ucp_access_token()

        get_creds.assert_called_once_with("method-1", body=None)

    def test_top_level_fallback(self, mocker):
        """When there is no typed sub-dict, the access token is read from the top level."""
        mocker.patch("GSuiteApiModule.resolve_ucp_capability", return_value="cap")
        mocker.patch("GSuiteApiModule.get_ucp_method_unique_id", return_value="method-1")
        mocker.patch("GSuiteApiModule.get_ucp_credentials", return_value={"access_token": TEST_ACCESS_TOKEN})
        assert GSuiteClient.get_ucp_access_token() == ("method-1", TEST_ACCESS_TOKEN)

    def test_empty_token_raises(self, mocker):
        """An empty/missing access token should raise UcpException."""
        _patch_ucp_fetch(mocker, "")
        with pytest.raises(UcpException):
            GSuiteClient.get_ucp_access_token()


class TestGSuiteClientUcp:
    def test_init_fetches_token_from_ucp_when_no_service_account(self, mocker):
        """When no service_account_dict and UCP is active, an oauth2 token is fetched."""
        mocker.patch("GSuiteApiModule.should_use_ucp_auth", return_value=True)
        _patch_ucp_fetch(mocker)
        oauth2 = mocker.patch("GSuiteApiModule.oauth2_credentials.Credentials")

        client = GSuiteClient(None, proxy=False, verify=False)

        oauth2.assert_called_once_with(token=TEST_ACCESS_TOKEN)
        assert client._ucp_method_id == "method-1"
        assert client._ucp_token == TEST_ACCESS_TOKEN

    def test_init_forwards_user_id_as_subject(self, mocker):
        """The client ``user_id`` is forwarded to UCP as the impersonation subject."""
        mocker.patch("GSuiteApiModule.should_use_ucp_auth", return_value=True)
        get_creds = _patch_ucp_fetch(mocker)
        mocker.patch("GSuiteApiModule.oauth2_credentials.Credentials")

        GSuiteClient(None, proxy=False, verify=False, user_id=CREDENTIAL_SUBJECT)

        get_creds.assert_called_once_with("method-1", body={"extra": {"subject": CREDENTIAL_SUBJECT}})

    def test_init_without_user_id_sends_no_subject(self, mocker):
        """When no ``user_id`` is set, no subject body is sent to UCP."""
        mocker.patch("GSuiteApiModule.should_use_ucp_auth", return_value=True)
        get_creds = _patch_ucp_fetch(mocker)
        mocker.patch("GSuiteApiModule.oauth2_credentials.Credentials")

        GSuiteClient(None, proxy=False, verify=False)

        get_creds.assert_called_once_with("method-1", body=None)

    def test_init_uses_params_when_not_ucp(self, mocker):
        """When a service_account_dict is provided, UCP is not consulted."""
        sa = GSuiteClient.safe_load_non_strict_json(TEST_JSON)
        mocker.patch("GSuiteApiModule.service_account.Credentials.from_service_account_info")

        client = GSuiteClient(sa, proxy=False, verify=False)

        assert client._ucp_method_id is None
        assert client._ucp_token is None

    def test_set_authorized_http_skips_scopes_in_token_mode(self, mocker):
        """In UCP token mode scopes/subject are not re-applied to the credentials."""
        from GSuiteApiModule import AuthorizedHttp

        mocker.patch("GSuiteApiModule.should_use_ucp_auth", return_value=True)
        _patch_ucp_fetch(mocker)
        creds = mocker.MagicMock()
        mocker.patch("GSuiteApiModule.oauth2_credentials.Credentials", return_value=creds)
        client = GSuiteClient(None, proxy=False, verify=False)

        client.set_authorized_http(scopes=["scope1"], subject=CREDENTIAL_SUBJECT)

        creds.with_scopes.assert_not_called()
        creds.with_subject.assert_not_called()
        assert isinstance(client.authorized_http, AuthorizedHttp)

    def test_invalidate_on_auth_error(self, mocker):
        """A 401/403 response should invalidate the cached UCP credentials."""
        mocker.patch("GSuiteApiModule.should_use_ucp_auth", return_value=True)
        _patch_ucp_fetch(mocker)
        mocker.patch("GSuiteApiModule.oauth2_credentials.Credentials")
        invalidate = mocker.patch("GSuiteApiModule.invalidate_ucp_credentials")
        client = GSuiteClient(None, proxy=False, verify=False)

        client._invalidate_ucp_credentials_on_auth_error((mocker.MagicMock(status=401), b"{}"))

        invalidate.assert_called_once_with("method-1")

    def test_no_invalidate_on_success(self, mocker):
        """A 200 response should not invalidate credentials."""
        mocker.patch("GSuiteApiModule.should_use_ucp_auth", return_value=True)
        _patch_ucp_fetch(mocker)
        mocker.patch("GSuiteApiModule.oauth2_credentials.Credentials")
        invalidate = mocker.patch("GSuiteApiModule.invalidate_ucp_credentials")
        client = GSuiteClient(None, proxy=False, verify=False)

        client._invalidate_ucp_credentials_on_auth_error((mocker.MagicMock(status=200), b"{}"))

        invalidate.assert_not_called()

    def test_no_invalidate_when_not_ucp(self, mocker):
        """Non-UCP clients never invalidate."""
        sa = GSuiteClient.safe_load_non_strict_json(TEST_JSON)
        mocker.patch("GSuiteApiModule.should_use_ucp_auth", return_value=False)
        mocker.patch("GSuiteApiModule.service_account.Credentials.from_service_account_info")
        invalidate = mocker.patch("GSuiteApiModule.invalidate_ucp_credentials")
        client = GSuiteClient(sa, proxy=False, verify=False)

        client._invalidate_ucp_credentials_on_auth_error((mocker.MagicMock(status=401), b"{}"))

        invalidate.assert_not_called()