diff --git a/README.rst b/README.rst index 2e7d786c..d561782b 100644 --- a/README.rst +++ b/README.rst @@ -115,6 +115,7 @@ The following commands are currently available: - ``preset-cli auth``: store authentication credentials. - ``preset-cli invite-users``: invite users to Preset. - ``preset-cli import-users``: automatically add users to Preset. +- ``preset-cli list-group-membership``: List SCIM groups from a team and their memberships. - ``preset-cli superset sql``: run SQL interactively or programmatically against an analytical database. - ``preset-cli superset export-assets``: export resources (databases, datasets, charts, dashboards) into a directory as YAML files. - ``preset-cli superset export-ownership``: export resource ownership (UUID -> email) into a YAML file. @@ -470,3 +471,7 @@ Exporting ownership ~~~~~~~~~~~~~~~~~~~ The ``preset-cli superset export-ownership`` command generates a YAML file with information about ownership of different resources. The file maps resource UUIDs to user email address, and in the future will be used to recreate ownership on a different instance of Superset. + +Listing SCIM Groups +~~~~~~~~~~~~~~~~~~~ +The ``preset-cli list-group-membership`` command prints all SCIM groups (including membership) associated with a Preset team. Instead of printing the results on the terminal (whcih can be useful for quick troubleshooting), it's possible to use ``--save-report=yaml`` or ``--save-report=csv`` to write results to a file. The file name would be ``{TeamSlug}__user_group_membership.{FileExtension}``. diff --git a/src/preset_cli/api/clients/preset.py b/src/preset_cli/api/clients/preset.py index 9db37baa..41308afa 100644 --- a/src/preset_cli/api/clients/preset.py +++ b/src/preset_cli/api/clients/preset.py @@ -5,7 +5,7 @@ import json import logging from enum import Enum -from typing import Any, Iterator, List, Optional, Union +from typing import Any, Dict, Iterator, List, Optional, Union from bs4 import BeautifulSoup from yarl import URL @@ -242,3 +242,23 @@ def get_base_url(self, version: Optional[str] = "v1") -> URL: Return the base URL for API calls. """ return self.baseurl / version + + def get_group_membership( + self, + team_name: str, + page: int, + ) -> Dict[str, Any]: + """ + Lists all user/SCIM groups associated with a team + """ + url = ( + self.get_base_url() + / "teams" + / team_name + / "scim/v2/Groups" + % {"startIndex": str(page)} + ) + self.session.headers["Accept"] = "application/scim+json" + _logger.debug("GET %s", url) + response = self.session.get(url) + return response.json() diff --git a/src/preset_cli/cli/main.py b/src/preset_cli/cli/main.py index 0d9a648d..2e02444d 100644 --- a/src/preset_cli/cli/main.py +++ b/src/preset_cli/cli/main.py @@ -2,8 +2,10 @@ Main entry point for the CLI. """ +import csv import getpass import logging +import os.path import sys import webbrowser from collections import defaultdict @@ -325,6 +327,138 @@ def invite_users(ctx: click.core.Context, teams: List[str], path: str) -> None: client.invite_users(teams, emails) +@click.command() +@click.option("--teams", callback=split_comma) +@click.option( + "--save-report", + help="Save results to a YAML or CSV file instead of priting on the terminal", +) +@click.pass_context +def list_group_membership( + ctx: click.core.Context, + teams: List[str], + save_report: str, +) -> None: + """ + List SCIM/user groups from Preset team(s) + """ + client = PresetClient(ctx.obj["MANAGER_URL"], ctx.obj["AUTH"]) + if not teams: + # prompt the user to specify the team(s), in case not specified via the `--teams` option + teams = get_teams(client) + + # in case --save-report was used, confirm if a valid option was used before sending requests + if save_report and save_report.casefold() not in {"yaml", "csv"}: + click.echo( + click.style( + "Invalid option. Please use --save-report=csv or --save-report=yaml", + fg="bright_red", + ), + ) + sys.exit(1) + + for team in teams: + + # print the team name in case multiple teams were provided and it's not an export + if not save_report and len(teams) > 1: + click.echo(f"## Team {team} ##") + + # defining default start_at and group_count to execute it at least once + start_at = 1 + group_count = 100 + + # account for pagination + while start_at <= group_count: + + groups = client.get_group_membership(team, start_at) + group_count = groups["totalResults"] + + if group_count > 0: + + # print groups in console + if not save_report: + print_group_membership(groups) + + # write report to a YAML file + elif save_report.casefold() == "yaml": + export_group_membership_yaml(groups, team) + + # write report to a CSV file + else: + export_group_membership_csv(groups, team) + + else: + click.echo(f"Team {team} has no SCIM groups\n") + + # increment start_at in case a new page is needed + start_at += 100 + + +def print_group_membership(groups: Dict[str, Any]) -> None: + """ + Print group membership on the terminal + """ + for group in groups["Resources"]: + click.echo(f'\nName: {group["displayName"]} ID: {group["id"]}') + if group.get("members"): + for member in group["members"]: + click.echo( + f'# User: {member["display"]} Username: {member["value"]}', + ) + else: + click.echo("# Group with no users\n") + + +def export_group_membership_yaml(groups: Dict[str, Any], team: str) -> None: + """ + Export group membership to a YAML file + """ + yaml_name = team + "_user_group_membership.yaml" + with open( + yaml_name, + "a+", + encoding="UTF8", + ) as yaml_creator: + yaml.dump(groups, yaml_creator) + + +def export_group_membership_csv(groups: Dict[str, Any], team: str) -> None: + """ + Export group membership to a CSV file + """ + csv_name = team + "_user_group_membership.csv" + for group in groups["Resources"]: + + # CSV report would include a group only in case it has members + if group.get("members"): + + # Assure we just write headers once + file_exists = os.path.isfile(csv_name) + + with open(csv_name, "a+", encoding="UTF8") as csv_writer: + writer = csv.DictWriter( + csv_writer, + delimiter=",", + fieldnames=[ + "Group Name", + "Group ID", + "User", + "Username", + ], + ) + if not file_exists: + writer.writeheader() + for member in group["members"]: + writer.writerow( + { + "Group Name": group["displayName"], + "Group ID": group["id"], + "User": member["display"], + "Username": member["value"], + }, + ) + + @click.command() @click.option("--teams", callback=split_comma) @click.argument( @@ -503,3 +637,4 @@ def sync_user_role_to_workspace( preset_cli.add_command(import_users) preset_cli.add_command(sync_roles) preset_cli.add_command(superset) +preset_cli.add_command(list_group_membership) diff --git a/tests/api/clients/preset_test.py b/tests/api/clients/preset_test.py index 36306256..c686160d 100644 --- a/tests/api/clients/preset_test.py +++ b/tests/api/clients/preset_test.py @@ -361,3 +361,107 @@ def test_change_workspace_role(requests_mock: Mocker) -> None: "role_identifier": "PresetAlpha", "user_id": 2, } + + +def test_get_group_membership(requests_mock: Mocker) -> None: + """ + Test the ``get_groups`` method. + """ + requests_mock.get( + "https://ws.preset.io/v1/teams/testSlug/scim/v2/Groups", + json={ + "Resources": [ + { + "displayName": "SCIM First Test Group", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5426", + "members": [ + { + "display": "Test Account 01", + "value": "samlp|example|testaccount01@example.com", + }, + { + "display": "Test Account 02", + "value": "samlp|example|testaccount02@example.com", + }, + ], + "meta": { + "resourceType": "Group", + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group", + ], + }, + { + "displayName": "SCIM Second Test Group", + "id": "fba067fc-506a-452b-8cf4-7d98f6960a6b", + "members": [ + { + "display": "Test Account 02", + "value": "samlp|example|testaccount02@example.com", + }, + ], + "meta": { + "resourceType": "Group", + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group", + ], + }, + ], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 2, + }, + ) + + auth = Auth() + client = PresetClient("https://ws.preset.io/", auth) + assert client.get_group_membership("testSlug", 1) == { + "Resources": [ + { + "displayName": "SCIM First Test Group", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5426", + "members": [ + { + "display": "Test Account 01", + "value": "samlp|example|testaccount01@example.com", + }, + { + "display": "Test Account 02", + "value": "samlp|example|testaccount02@example.com", + }, + ], + "meta": { + "resourceType": "Group", + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group", + ], + }, + { + "displayName": "SCIM Second Test Group", + "id": "fba067fc-506a-452b-8cf4-7d98f6960a6b", + "members": [ + { + "display": "Test Account 02", + "value": "samlp|example|testaccount02@example.com", + }, + ], + "meta": { + "resourceType": "Group", + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group", + ], + }, + ], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 2, + } diff --git a/tests/cli/main_test.py b/tests/cli/main_test.py index 1bb6960f..cfb96a14 100644 --- a/tests/cli/main_test.py +++ b/tests/cli/main_test.py @@ -3,8 +3,11 @@ """ # pylint: disable=unused-argument, invalid-name, redefined-outer-name, too-many-lines +import csv +import os from pathlib import Path from typing import Any, Dict +from unittest.mock import call import pytest import yaml @@ -14,9 +17,12 @@ from yarl import URL from preset_cli.cli.main import ( + export_group_membership_csv, + export_group_membership_yaml, get_status_icon, parse_selection, preset_cli, + print_group_membership, sync_all_user_roles_to_team, sync_user_role_to_workspace, sync_user_roles_to_team, @@ -999,3 +1005,722 @@ def test_sync_user_role_to_workspace(mocker: MockerFixture) -> None: 1001, "PresetGamma", ) + + +def test_list_group_membership_specified_team(mocker: MockerFixture) -> None: + """ + Test the ``list_group_membership`` command when a team is specified. + """ + PresetClient = mocker.patch("preset_cli.cli.main.PresetClient") + mocker.patch("preset_cli.cli.main.input", side_effect=["invalid", "-"]) + + client = PresetClient() + client.get_teams.assert_not_called() + client.get_group_membership.return_value = { + "Resources": [], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 0, + } + + runner = CliRunner() + result = runner.invoke( + preset_cli, + ["--jwt-token=XXX", "list-group-membership", "--teams=team1"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + + client.get_group_membership.assert_called_with("team1", 1) + assert result.output == "Team team1 has no SCIM groups\n\n" + + +def test_list_group_membership_multiple_teams(mocker: MockerFixture) -> None: + """ + Test the ``list_group_membership`` command when specifying two teams. + """ + PresetClient = mocker.patch("preset_cli.cli.main.PresetClient") + mocker.patch("preset_cli.cli.main.input", side_effect=["invalid", "-"]) + + client = PresetClient() + client.get_teams.assert_not_called() + client.get_group_membership.side_effect = [ + { + "Resources": [], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 0, + }, + { + "Resources": [], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 0, + }, + ] + + runner = CliRunner() + result = runner.invoke( + preset_cli, + ["--jwt-token=XXX", "list-group-membership", "--teams=team1,team2"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + + expected_calls = [call("team1", 1), call("team2", 1)] + + client.get_group_membership.assert_has_calls(expected_calls, any_order=False) + + assert ( + result.output + == """## Team team1 ## +Team team1 has no SCIM groups + +## Team team2 ## +Team team2 has no SCIM groups + +""" + ) + + +def test_list_group_membership_no_team_available(mocker: MockerFixture) -> None: + """ + Test the ``list_group_membership`` command when no teams are available. + """ + PresetClient = mocker.patch("preset_cli.cli.main.PresetClient") + mocker.patch("preset_cli.cli.main.input", side_effect=["invalid", "-"]) + + client = PresetClient() + client.get_teams.return_value = [] + + runner = CliRunner() + result = runner.invoke( + preset_cli, + ["--jwt-token=XXX", "list-group-membership"], + catch_exceptions=False, + ) + assert result.exit_code == 1 + + +def test_list_group_membership_team_with_no_groups(mocker: MockerFixture) -> None: + """ + Test the ``list_group_membership`` command when specifying a team with no groups available. + """ + PresetClient = mocker.patch("preset_cli.cli.main.PresetClient") + mocker.patch("preset_cli.cli.main.input", side_effect=["invalid", "-"]) + + client = PresetClient() + client.get_teams.assert_not_called() + client.get_group_membership.return_value = { + "Resources": [], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 0, + } + + runner = CliRunner() + result = runner.invoke( + preset_cli, + ["--jwt-token=XXX", "list-group-membership", "--teams=team1"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + + client.get_group_membership.assert_called_with("team1", 1) + + assert result.output == "Team team1 has no SCIM groups\n\n" + + +def test_list_group_membership_group_with_no_members(mocker: MockerFixture) -> None: + """ + Test the ``list_group_membership`` command when the specified team has a group with no members. + """ + PresetClient = mocker.patch("preset_cli.cli.main.PresetClient") + mocker.patch("preset_cli.cli.main.input", side_effect=["invalid", "-"]) + print_group_membership = mocker.patch( + "preset_cli.cli.main.print_group_membership", + ) + + client = PresetClient() + client.get_teams.assert_not_called() + client.get_group_membership.return_value = { + "Resources": [ + { + "displayName": "SCIM Group", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5426", + "members": [], + }, + ], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 1, + } + + runner = CliRunner() + result = runner.invoke( + preset_cli, + ["--jwt-token=XXX", "list-group-membership", "--teams=team1"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + + client.get_group_membership.assert_called_with("team1", 1) + + print_group_membership.assert_called_with = { + "Resources": [ + { + "displayName": "SCIM Group", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5426", + "members": [], + }, + ], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 1, + } + + +def test_list_group_membership_incorrect_export(mocker: MockerFixture) -> None: + """ + Test the ``list_group_membership`` command with an incorrect --export-report parameter. + """ + PresetClient = mocker.patch("preset_cli.cli.main.PresetClient") + mocker.patch("preset_cli.cli.main.input", side_effect=["invalid", "-"]) + + client = PresetClient() + client.get_teams.assert_not_called() + client.get_group_membership.not_called = () + + runner = CliRunner() + result = runner.invoke( + preset_cli, + [ + "--jwt-token=XXX", + "list-group-membership", + "--teams=team1", + "--save-report=invalid", + ], + catch_exceptions=False, + ) + assert result.exit_code == 1 + + +def test_list_group_membership_export_yaml(mocker: MockerFixture) -> None: + """ + Test the ``list_group_membership`` command setting --export-report=yaml. + """ + PresetClient = mocker.patch("preset_cli.cli.main.PresetClient") + mocker.patch("preset_cli.cli.main.input", side_effect=["invalid", "-"]) + export_group_membership_yaml = mocker.patch( + "preset_cli.cli.main.export_group_membership_yaml", + ) + + client = PresetClient() + client.get_teams.assert_not_called() + client.get_group_membership.return_value = { + "Resources": [ + { + "displayName": "SCIM Test Group", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5426", + "members": [ + { + "display": "Test Account 01", + "value": "samlp|example|testaccount01@example.com", + }, + ], + "meta": { + "resourceType": "Group", + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group", + ], + }, + ], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 1, + } + + runner = CliRunner() + result = runner.invoke( + preset_cli, + [ + "--jwt-token=XXX", + "list-group-membership", + "--teams=team1", + "--save-report=yaml", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + + client.get_group_membership.assert_called_with("team1", 1) + + export_group_membership_yaml.assert_called_with = ( + { + "Resources": [ + { + "displayName": "SCIM Test Group", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5426", + "members": [ + { + "display": "Test Account 01", + "value": "samlp|example|testaccount01@example.com", + }, + ], + "meta": { + "resourceType": "Group", + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group", + ], + }, + ], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 1, + }, + "team1", + ) + + +def test_list_group_membership_export_csv(mocker: MockerFixture) -> None: + """ + Test the ``list_group_membership`` setting --export-report=csv. + """ + PresetClient = mocker.patch("preset_cli.cli.main.PresetClient") + mocker.patch("preset_cli.cli.main.input", side_effect=["invalid", "-"]) + export_group_membership_csv = mocker.patch( + "preset_cli.cli.main.export_group_membership_csv", + ) + + client = PresetClient() + client.get_teams.assert_not_called() + client.get_group_membership.return_value = { + "Resources": [ + { + "displayName": "SCIM Test Group", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5426", + "members": [ + { + "display": "Test Account 01", + "value": "samlp|example|testaccount01@example.com", + }, + ], + "meta": { + "resourceType": "Group", + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group", + ], + }, + ], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 1, + } + + runner = CliRunner() + result = runner.invoke( + preset_cli, + [ + "--jwt-token=XXX", + "list-group-membership", + "--teams=team1", + "--save-report=csv", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + + client.get_group_membership.assert_called_with( + "team1", + 1, + ) + + export_group_membership_csv.assert_called_with = ( + { + "Resources": [ + { + "displayName": "SCIM Test Group", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5426", + "members": [ + { + "display": "Test Account 01", + "value": "samlp|example|testaccount01@example.com", + }, + ], + "meta": { + "resourceType": "Group", + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group", + ], + }, + ], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 1, + }, + "team1", + ) + + +def test_print_group_membership_group_with_no_members(capfd) -> None: + """ + Test the ``print_group_membership`` helper with a group with no members. + """ + + groups = { + "Resources": [ + { + "displayName": "SCIM Group", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5426", + "members": [], + }, + ], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 1, + } + + print_group_membership(groups) + out = capfd.readouterr().out + assert ( + out + == """ +Name: SCIM Group ID: b2a691ca-0ef8-464c-9601-9c50158c5426 +# Group with no users + +""" + ) + + +def test_print_group_membership_group_with_members(capfd) -> None: + """ + Test the ``print_group_membership`` helper. + """ + + groups = { + "Resources": [ + { + "displayName": "SCIM Group", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5426", + "members": [ + { + "display": "Test Account 01", + "value": "samlp|example|testaccount01@example.com", + }, + ], + }, + ], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 1, + } + + print_group_membership(groups) + out = capfd.readouterr().out + assert ( + out + == """ +Name: SCIM Group ID: b2a691ca-0ef8-464c-9601-9c50158c5426 +# User: Test Account 01 Username: samlp|example|testaccount01@example.com +""" + ) + + +def test_export_group_membership_yaml() -> None: + """ + Test the ``export_group_membership_yaml`` helper. + """ + + groups = { + "Resources": [ + { + "displayName": "SCIM Group", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5426", + "members": [ + { + "display": "Test Account 01", + "value": "samlp|example|testaccount01@example.com", + }, + ], + }, + ], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 1, + } + + export_group_membership_yaml(groups, "team1") + with open("team1_user_group_membership.yaml", encoding="utf-8") as yaml_test_output: + assert yaml.load(yaml_test_output.read(), Loader=yaml.SafeLoader) == { + "Resources": [ + { + "displayName": "SCIM Group", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5426", + "members": [ + { + "display": "Test Account 01", + "value": "samlp|example|testaccount01@example.com", + }, + ], + }, + ], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 1, + } + + os.remove("team1_user_group_membership.yaml") + + +def test_export_group_membership_csv() -> None: + """ + Test the ``export_group_membership_csv`` helper. + """ + + groups = { + "Resources": [ + { + "displayName": "SCIM Test Group", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5426", + "members": [ + { + "display": "Test Account 01", + "value": "samlp|example|testaccount01@example.com", + }, + ], + "meta": { + "resourceType": "Group", + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group", + ], + }, + { + "displayName": "SCIM Test Group 02", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5537", + "members": [ + { + "display": "Test Account 02", + "value": "samlp|example|testaccount02@example.com", + }, + ], + "meta": { + "resourceType": "Group", + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group", + ], + }, + ], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 2, + } + + data = [ + [ + "SCIM Test Group", + "b2a691ca-0ef8-464c-9601-9c50158c5426", + "Test Account 01", + "samlp|example|testaccount01@example.com", + ], + [ + "SCIM Test Group 02", + "b2a691ca-0ef8-464c-9601-9c50158c5537", + "Test Account 02", + "samlp|example|testaccount02@example.com", + ], + ] + i = 0 + + export_group_membership_csv(groups, "team1") + with open( + "team1_user_group_membership.csv", + "r", + encoding="utf-8", + ) as csv_test_output: + file_content = csv.reader(csv_test_output) + assert next(file_content) == ["Group Name", "Group ID", "User", "Username"] + for row in file_content: + assert row == data[i] + i += 1 + + os.remove("team1_user_group_membership.csv") + + +def test_export_group_membership_csv_empty_group() -> None: + """ + Test the ``export_group_membership_csv`` helper with an empty group. + """ + groups = { + "Resources": [ + { + "displayName": "SCIM Test Group", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5426", + "members": [], + "meta": { + "resourceType": "Group", + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group", + ], + }, + ], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 1, + } + + export_group_membership_csv(groups, "team1") + + file_exists = os.path.isfile("team1_user_group_membership.csv") + assert not file_exists + + +def test_export_group_membership_csv_pagination() -> None: + """ + Test the ``export_group_membership_csv`` when pagination is needed. + """ + + groups = { + "Resources": [ + { + "displayName": "SCIM Test Group", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5426", + "members": [ + { + "display": "Test Account 01", + "value": "samlp|example|testaccount01@example.com", + }, + ], + "meta": { + "resourceType": "Group", + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group", + ], + }, + { + "displayName": "SCIM Test Group 02", + "id": "b2a691ca-0ef8-464c-9601-9c50158c5537", + "members": [ + { + "display": "Test Account 02", + "value": "samlp|example|testaccount02@example.com", + }, + ], + "meta": { + "resourceType": "Group", + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group", + ], + }, + ], + "itemsPerPage": 100, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ], + "startIndex": 1, + "totalResults": 102, + } + + data = [ + [ + "SCIM Test Group", + "b2a691ca-0ef8-464c-9601-9c50158c5426", + "Test Account 01", + "samlp|example|testaccount01@example.com", + ], + [ + "SCIM Test Group 02", + "b2a691ca-0ef8-464c-9601-9c50158c5537", + "Test Account 02", + "samlp|example|testaccount02@example.com", + ], + [ + "SCIM Test Group", + "b2a691ca-0ef8-464c-9601-9c50158c5426", + "Test Account 01", + "samlp|example|testaccount01@example.com", + ], + [ + "SCIM Test Group 02", + "b2a691ca-0ef8-464c-9601-9c50158c5537", + "Test Account 02", + "samlp|example|testaccount02@example.com", + ], + ] + i = 0 + + export_group_membership_csv(groups, "team1") + with open( + "team1_user_group_membership.csv", + "r", + encoding="utf-8", + ) as csv_test_output: + file_content = csv.reader(csv_test_output) + assert next(file_content) == ["Group Name", "Group ID", "User", "Username"] + for row in file_content: + assert row == data[i] + i += 1 + + os.remove("team1_user_group_membership.csv")