From d3574e6a939cfa4714f73f2e8c0891a0d21cf289 Mon Sep 17 00:00:00 2001 From: Aaron Jacobs Date: Mon, 1 Jun 2026 13:41:01 -0400 Subject: [PATCH] feat: Add an `integration` subcommand for manipulating integrations. This commit adds a suite of CLI commands for managing "OAuth" integrations: $ rsconnect integration list $ rsconnect integration show GUID $ rsconnect integration add --template [-N name] [-C key=value ...] $ rsconnect integration edit GUID [-N name] [-C key=value ...] $ rsconnect integration remove GUID $ rsconnect integration templates list $ rsconnect integration templates show --key The `edit` command merges `--config` fields with the existing config (fetching it only when config changes are requested), so users only need to specify fields they want to change. ACLs can be passed via `--allow-user` and `--allow-group`. Unit tests are included, as is autogenerated documentation. Signed-off-by: Aaron Jacobs --- docs/CHANGELOG.md | 1 + docs/commands/integration.md | 3 + mkdocs.yml | 1 + rsconnect/actions_integration.py | 118 +++++++ rsconnect/api.py | 38 ++ rsconnect/main.py | 329 ++++++++++++++++++ rsconnect/models.py | 59 ++++ tests/test_main_integration.py | 254 ++++++++++++++ .../connect-responses/get-integration.json | 12 + .../connect-responses/get-template.json | 12 + .../connect-responses/list-integrations.json | 26 ++ .../connect-responses/list-templates.json | 14 + 12 files changed, 867 insertions(+) create mode 100644 docs/commands/integration.md create mode 100644 rsconnect/actions_integration.py create mode 100644 tests/test_main_integration.py create mode 100644 tests/testdata/connect-responses/get-integration.json create mode 100644 tests/testdata/connect-responses/get-template.json create mode 100644 tests/testdata/connect-responses/list-integrations.json create mode 100644 tests/testdata/connect-responses/list-templates.json diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 21002519..91ff0bdc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 used automatically. `rsconnect login` sets the server as default unless `--no-set-default` is passed. `CONNECT_SERVER` still takes precedence. - New `environment` subcommand for managing execution environments on Connect. +- New `integration` subcommand for managing OAuth integrations on Connect. ### Added diff --git a/docs/commands/integration.md b/docs/commands/integration.md new file mode 100644 index 00000000..48eb13e7 --- /dev/null +++ b/docs/commands/integration.md @@ -0,0 +1,3 @@ +::: mkdocs-click + :module: rsconnect.main + :command: integration diff --git a/mkdocs.yml b/mkdocs.yml index cde57107..b44e381e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,6 +46,7 @@ nav: - details: commands/details.md - environment: commands/environment.md - info: commands/info.md + - integration: commands/integration.md - list: commands/list.md - login: commands/login.md - logout: commands/logout.md diff --git a/rsconnect/actions_integration.py b/rsconnect/actions_integration.py new file mode 100644 index 00000000..3fc299bd --- /dev/null +++ b/rsconnect/actions_integration.py @@ -0,0 +1,118 @@ +""" +Public API for managing OAuth integrations on Posit Connect. +""" + +from __future__ import annotations + +from typing import Optional, Union + +from .api import RSConnectClient, RSConnectServer, SPCSConnectServer +from .models import ( + OAuthIntegration, + OAuthIntegrationInput, + OAuthIntegrationPermission, + OAuthIntegrationUpdate, + OAuthTemplate, +) + + +def list_oauth_integrations( + connect_server: Union[RSConnectServer, SPCSConnectServer], +) -> list[OAuthIntegration]: + with RSConnectClient(connect_server) as client: + return client.oauth_integration_list() + + +def get_oauth_integration( + connect_server: Union[RSConnectServer, SPCSConnectServer], + guid: str, +) -> OAuthIntegration: + with RSConnectClient(connect_server) as client: + return client.oauth_integration_get(guid) + + +def create_oauth_integration( + connect_server: Union[RSConnectServer, SPCSConnectServer], + template: str, + config: dict[str, object], + name: Optional[str] = None, + description: Optional[str] = None, + user_guids: Optional[list[str]] = None, + group_guids: Optional[list[str]] = None, +) -> OAuthIntegration: + permissions: list[OAuthIntegrationPermission] = [] + for g in user_guids or []: + permissions.append({"user_guid": g, "group_guid": None}) + for g in group_guids or []: + permissions.append({"user_guid": None, "group_guid": g}) + + body: OAuthIntegrationInput = { + "template": template, + "config": config, + } + if name is not None: + body["name"] = name + if description is not None: + body["description"] = description + if permissions: + body["permissions"] = permissions + + with RSConnectClient(connect_server) as client: + return client.oauth_integration_create(body) + + +def update_oauth_integration( + connect_server: Union[RSConnectServer, SPCSConnectServer], + guid: str, + config: Optional[dict[str, object]] = None, + name: Optional[str] = None, + description: Optional[str] = None, + user_guids: Optional[list[str]] = None, + group_guids: Optional[list[str]] = None, +) -> OAuthIntegration: + with RSConnectClient(connect_server) as client: + body: OAuthIntegrationUpdate = {} + if name is not None: + body["name"] = name + if description is not None: + body["description"] = description + if config is not None: + existing = client.oauth_integration_get(guid) + merged_config = dict(existing["config"]) + merged_config.update(config) + body["config"] = merged_config + + permissions: list[OAuthIntegrationPermission] = [] + if user_guids: + for g in user_guids: + permissions.append({"user_guid": g, "group_guid": None}) + if group_guids: + for g in group_guids: + permissions.append({"user_guid": None, "group_guid": g}) + if permissions: + body["permissions"] = permissions + + return client.oauth_integration_update(guid, body) + + +def delete_oauth_integration( + connect_server: Union[RSConnectServer, SPCSConnectServer], + guid: str, +) -> None: + with RSConnectClient(connect_server) as client: + client.oauth_integration_delete(guid) + + +def list_oauth_templates( + connect_server: Union[RSConnectServer, SPCSConnectServer], +) -> list[OAuthTemplate]: + with RSConnectClient(connect_server) as client: + return client.oauth_template_list() + + +def get_oauth_template( + connect_server: Union[RSConnectServer, SPCSConnectServer], + key: str, +) -> OAuthTemplate: + with RSConnectClient(connect_server) as client: + return client.oauth_template_get(key) diff --git a/rsconnect/api.py b/rsconnect/api.py index 328f9e7e..ed55d39a 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -82,6 +82,10 @@ EnvironmentUpdateInput, EnvironmentV1, ListEntryOutputDTO, + OAuthIntegration, + OAuthIntegrationInput, + OAuthIntegrationUpdate, + OAuthTemplate, PyInfo, ServerSettings, TaskStatusV1, @@ -782,6 +786,40 @@ def environment_permission_delete(self, env_guid: str, permission_guid: str) -> ) self._server.handle_bad_response(response, is_httpresponse=True) + def oauth_integration_list(self) -> list[OAuthIntegration]: + response = cast(Union[List[OAuthIntegration], HTTPResponse], self.get("v1/oauth/integrations")) + response = self._server.handle_bad_response(response) + return response + + def oauth_integration_get(self, guid: str) -> OAuthIntegration: + response = cast(Union[OAuthIntegration, HTTPResponse], self.get(f"v1/oauth/integrations/{guid}")) + response = self._server.handle_bad_response(response) + return response + + def oauth_integration_create(self, body: OAuthIntegrationInput) -> OAuthIntegration: + response = cast(Union[OAuthIntegration, HTTPResponse], self.post("v1/oauth/integrations", body=body)) + response = self._server.handle_bad_response(response) + return response + + def oauth_integration_update(self, guid: str, body: OAuthIntegrationUpdate) -> OAuthIntegration: + response = cast(Union[OAuthIntegration, HTTPResponse], self.patch(f"v1/oauth/integrations/{guid}", body=body)) + response = self._server.handle_bad_response(response) + return response + + def oauth_integration_delete(self, guid: str) -> None: + response = cast(HTTPResponse, self.delete(f"v1/oauth/integrations/{guid}", decode_response=False)) + self._server.handle_bad_response(response, is_httpresponse=True) + + def oauth_template_list(self) -> list[OAuthTemplate]: + response = cast(Union[List[OAuthTemplate], HTTPResponse], self.get("v1/oauth/templates")) + response = self._server.handle_bad_response(response) + return response + + def oauth_template_get(self, key: str) -> OAuthTemplate: + response = cast(Union[OAuthTemplate, HTTPResponse], self.get(f"v1/oauth/templates/{key}")) + response = self._server.handle_bad_response(response) + return response + def task_get( self, task_id: str, diff --git a/rsconnect/main.py b/rsconnect/main.py index 75faf25a..76abc89e 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -71,6 +71,15 @@ list_environments, update_environment, ) +from .actions_integration import ( + create_oauth_integration, + delete_oauth_integration, + get_oauth_integration, + get_oauth_template, + list_oauth_integrations, + list_oauth_templates, + update_oauth_integration, +) from .models import EnvironmentInstallation, EnvironmentVolumeMount from .api import ( RSConnectClient, @@ -123,6 +132,7 @@ BuildStatus, ContentGuidWithBundle, ContentGuidWithBundleParamType, + KeyValueParamType, StrippedStringParamType, VersionSearchFilter, VersionSearchFilterParamType, @@ -4778,6 +4788,325 @@ def environment_remove( click.echo("Deleted environment %s." % guid) +@cli.group(no_args_is_help=True, help="Manage OAuth integrations on Posit Connect.") +def integration(): + pass + + +@integration.command(name="list", short_help="List OAuth integrations.") +@server_args +@spcs_args +@cli_exception_handler +@click.pass_context +def integration_list( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + verbose: int, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + with cli_feedback("", stderr=True): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.remote_server, (RSConnectServer, SPCSConnectServer)): + raise RSConnectException("`rsconnect integration list` requires a Posit Connect server.") + result = list_oauth_integrations(ce.remote_server) + if not result: + click.echo("No integrations found.") + else: + for item in result: + label = item.get("name") or item.get("template") or "(unnamed)" + click.echo("%s %s" % (item["guid"], label)) + + +@integration.command(name="show", short_help="Show a single OAuth integration.") +@server_args +@spcs_args +@click.argument("guid", type=StrippedStringParamType()) +@cli_exception_handler +@click.pass_context +def integration_show( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + verbose: int, + guid: str, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + with cli_feedback("", stderr=True): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.remote_server, (RSConnectServer, SPCSConnectServer)): + raise RSConnectException("`rsconnect integration show` requires a Posit Connect server.") + result = get_oauth_integration(ce.remote_server, guid) + json.dump(result, sys.stdout, indent=2) + + +@integration.command(name="add", short_help="Create a new OAuth integration.") +@server_args +@spcs_args +@click.option("--template", "-t", required=True, help="The template key (e.g. 'custom').") +@click.option("--integration-name", "-N", default=None, help="A display name for the integration.") +@click.option("--description", "-d", default=None, help="A description for the integration.") +@click.option( + "--config", + "-C", + multiple=True, + type=KeyValueParamType(), + metavar="KEY=VALUE", + help="A config field as key=value. Repeat for multiple fields.", +) +@click.option("--allow-user", multiple=True, type=StrippedStringParamType(), help="A user GUID to grant access.") +@click.option("--allow-group", multiple=True, type=StrippedStringParamType(), help="A group GUID to grant access.") +@cli_exception_handler +@click.pass_context +def integration_add( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + verbose: int, + template: str, + integration_name: Optional[str], + description: Optional[str], + config: tuple[tuple[str, str], ...], + allow_user: tuple[str, ...], + allow_group: tuple[str, ...], +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + with cli_feedback("", stderr=True): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.remote_server, (RSConnectServer, SPCSConnectServer)): + raise RSConnectException("`rsconnect integration add` requires a Posit Connect server.") + config_dict: dict[str, object] = dict(config) + result = create_oauth_integration( + ce.remote_server, + template=template, + config=config_dict, + name=integration_name, + description=description, + user_guids=list(allow_user) if allow_user else None, + group_guids=list(allow_group) if allow_group else None, + ) + json.dump(result, sys.stdout, indent=2) + + +@integration.command(name="edit", short_help="Update an existing OAuth integration.") +@server_args +@spcs_args +@click.argument("guid", type=StrippedStringParamType()) +@click.option("--integration-name", "-N", default=None, help="A new display name for the integration.") +@click.option("--description", "-d", default=None, help="A new description for the integration.") +@click.option( + "--config", + "-C", + multiple=True, + type=KeyValueParamType(), + metavar="KEY=VALUE", + help="A config field as key=value. Merges with existing config. Repeat for multiple fields.", +) +@click.option("--allow-user", multiple=True, type=StrippedStringParamType(), help="A user GUID to grant access.") +@click.option("--allow-group", multiple=True, type=StrippedStringParamType(), help="A group GUID to grant access.") +@cli_exception_handler +@click.pass_context +def integration_edit( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + verbose: int, + guid: str, + integration_name: Optional[str], + description: Optional[str], + config: tuple[tuple[str, str], ...], + allow_user: tuple[str, ...], + allow_group: tuple[str, ...], +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + with cli_feedback("", stderr=True): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.remote_server, (RSConnectServer, SPCSConnectServer)): + raise RSConnectException("`rsconnect integration edit` requires a Posit Connect server.") + config_dict: dict[str, object] | None = dict(config) if config else None + result = update_oauth_integration( + ce.remote_server, + guid=guid, + config=config_dict, + name=integration_name, + description=description, + user_guids=list(allow_user) if allow_user else None, + group_guids=list(allow_group) if allow_group else None, + ) + json.dump(result, sys.stdout, indent=2) + + +@integration.command(name="remove", short_help="Delete an OAuth integration.") +@server_args +@spcs_args +@click.argument("guid", type=StrippedStringParamType()) +@cli_exception_handler +@click.pass_context +def integration_remove( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + verbose: int, + guid: str, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + with cli_feedback("", stderr=True): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.remote_server, (RSConnectServer, SPCSConnectServer)): + raise RSConnectException("`rsconnect integration remove` requires a Posit Connect server.") + delete_oauth_integration(ce.remote_server, guid) + click.echo("Deleted integration %s." % guid) + + +@integration.group(name="templates", no_args_is_help=True, help="List and inspect OAuth integration templates.") +def integration_templates(): + pass + + +@integration_templates.command(name="list", short_help="List available OAuth templates.") +@server_args +@spcs_args +@cli_exception_handler +@click.pass_context +def integration_templates_list( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + verbose: int, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + with cli_feedback("", stderr=True): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.remote_server, (RSConnectServer, SPCSConnectServer)): + raise RSConnectException("`rsconnect integration templates list` requires a Posit Connect server.") + result = list_oauth_templates(ce.remote_server) + if not result: + click.echo("No templates found.") + else: + for t in result: + click.echo("%s %s" % (t["id"], t.get("name") or "")) + + +@integration_templates.command(name="show", short_help="Show details of an OAuth template.") +@server_args +@spcs_args +@click.argument("template_id", type=StrippedStringParamType()) +@cli_exception_handler +@click.pass_context +def integration_templates_show( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + verbose: int, + template_id: str, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + with cli_feedback("", stderr=True): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.remote_server, (RSConnectServer, SPCSConnectServer)): + raise RSConnectException("`rsconnect integration templates show` requires a Posit Connect server.") + result = get_oauth_template(ce.remote_server, template_id) + json.dump(result, sys.stdout, indent=2) + + if __name__ == "__main__": cli() click.echo() diff --git a/rsconnect/models.py b/rsconnect/models.py index 1a8909dd..e071557d 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -759,3 +759,62 @@ class EnvironmentPermissionV1(TypedDict): class EnvironmentPermissionInput(TypedDict, total=False): user_guid: str | None group_guid: str | None + + +class OAuthIntegrationPermission(TypedDict): + user_guid: str | None + group_guid: str | None + + +class OAuthIntegration(TypedDict): + guid: str + name: str | None + description: str | None + template: str | None + auth_type: str | None + config: dict[str, object] + permissions: list[OAuthIntegrationPermission] + environment_variables: list[str] + created_time: str + updated_time: str + + +class OAuthIntegrationInput(TypedDict, total=False): + name: str | None + description: str | None + template: str + config: dict[str, object] + permissions: list[OAuthIntegrationPermission] | None + + +class OAuthIntegrationUpdate(TypedDict, total=False): + name: str | None + description: str | None + config: dict[str, object] + permissions: list[OAuthIntegrationPermission] | None + + +class OAuthTemplate(TypedDict): + id: str + name: str + description: str + fields: list[object] + options: list[object] + + +class KeyValueParamType(ParamType): + name = "key=value" + + def convert( + self, + value: str | tuple[str, str], + param: Optional[click.Parameter], + ctx: Optional[click.Context], + ) -> tuple[str, str]: + if isinstance(value, tuple): + return value + try: + k, v = value.split("=", 1) + return (k.strip(), v.strip()) + except ValueError: + self.fail(f"'{value}' is not in 'key=value' format", param, ctx) diff --git a/tests/test_main_integration.py b/tests/test_main_integration.py new file mode 100644 index 00000000..e5382054 --- /dev/null +++ b/tests/test_main_integration.py @@ -0,0 +1,254 @@ +import json +import unittest + +import httpretty +from click.testing import CliRunner + +from rsconnect.main import cli + +from .utils import apply_common_args + +INTEGRATION_GUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + + +def register_uris(connect_server: str): + httpretty.register_uri( + httpretty.GET, + f"{connect_server}/__api__/server_settings", + body=open("tests/testdata/connect-responses/server_settings.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.GET, + f"{connect_server}/__api__/v1/user", + body=open("tests/testdata/connect-responses/me.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.GET, + f"{connect_server}/__api__/v1/oauth/integrations", + body=open("tests/testdata/connect-responses/list-integrations.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.GET, + f"{connect_server}/__api__/v1/oauth/integrations/{INTEGRATION_GUID}", + body=open("tests/testdata/connect-responses/get-integration.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.POST, + f"{connect_server}/__api__/v1/oauth/integrations", + body=open("tests/testdata/connect-responses/get-integration.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.PATCH, + f"{connect_server}/__api__/v1/oauth/integrations/{INTEGRATION_GUID}", + body=open("tests/testdata/connect-responses/get-integration.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.DELETE, + f"{connect_server}/__api__/v1/oauth/integrations/{INTEGRATION_GUID}", + status=204, + body="", + ) + httpretty.register_uri( + httpretty.GET, + f"{connect_server}/__api__/v1/oauth/templates", + body=open("tests/testdata/connect-responses/list-templates.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.GET, + f"{connect_server}/__api__/v1/oauth/templates/custom", + body=open("tests/testdata/connect-responses/get-template.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + + +class TestIntegrationSubcommand(unittest.TestCase): + def setUp(self): + self.connect_server = "http://localhost:3939" + self.api_key = "testapikey123" + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_integration_list(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args(["integration", "list"], server=self.connect_server, key=self.api_key) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn(INTEGRATION_GUID, result.output) + self.assertIn("My OAuth Integration", result.output) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_integration_show(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + ["integration", "show", INTEGRATION_GUID], server=self.connect_server, key=self.api_key + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + output = json.loads(result.output) + self.assertEqual(output["guid"], INTEGRATION_GUID) + self.assertEqual(output["name"], "My OAuth Integration") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_integration_add(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + [ + "integration", + "add", + "--template", + "custom", + "-N", + "My OAuth Integration", + "-C", + "client_id=abc123", + "-C", + "client_secret=secret", + ], + server=self.connect_server, + key=self.api_key, + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + output = json.loads(result.output) + self.assertEqual(output["template"], "custom") + body = json.loads(httpretty.last_request().body) + self.assertEqual(body["template"], "custom") + self.assertEqual(body["config"]["client_id"], "abc123") + self.assertEqual(body["config"]["client_secret"], "secret") + self.assertEqual(body["name"], "My OAuth Integration") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_integration_edit(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + [ + "integration", + "edit", + INTEGRATION_GUID, + "-N", + "Renamed Integration", + "-C", + "client_id=new_id", + ], + server=self.connect_server, + key=self.api_key, + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + body = json.loads(httpretty.last_request().body) + self.assertEqual(body["name"], "Renamed Integration") + self.assertEqual(body["config"]["client_id"], "new_id") + # Verify merge: existing key preserved from the GET response + self.assertEqual(body["config"]["client_secret"], "secret") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_integration_edit_name_only(self): + """Editing only name/description should not fetch existing integration.""" + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + ["integration", "edit", INTEGRATION_GUID, "-N", "New Name"], + server=self.connect_server, + key=self.api_key, + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + body = json.loads(httpretty.last_request().body) + self.assertEqual(body["name"], "New Name") + self.assertNotIn("config", body) + # Verify no GET to the integration endpoint occurred + integration_gets = [ + r + for r in httpretty.latest_requests() + if r.method == "GET" and f"/v1/oauth/integrations/{INTEGRATION_GUID}" in r.path + ] + self.assertEqual(len(integration_gets), 0, "Unexpected GET to integration endpoint") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_integration_remove(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + ["integration", "remove", INTEGRATION_GUID], server=self.connect_server, key=self.api_key + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("Deleted integration", result.output) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_templates_list(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args(["integration", "templates", "list"], server=self.connect_server, key=self.api_key) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("custom", result.output) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_templates_show(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + ["integration", "templates", "show", "custom"], server=self.connect_server, key=self.api_key + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + output = json.loads(result.output) + self.assertEqual(output["id"], "custom") + self.assertEqual(len(output["fields"]), 2) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_integration_add_with_permissions(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + [ + "integration", + "add", + "--template", + "custom", + "-C", + "client_id=abc", + "--allow-user", + "user-guid-1", + "--allow-user", + "user-guid-2", + "--allow-group", + "group-guid-1", + ], + server=self.connect_server, + key=self.api_key, + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + body = json.loads(httpretty.last_request().body) + self.assertEqual(len(body["permissions"]), 3) + self.assertEqual(body["permissions"][0], {"user_guid": "user-guid-1", "group_guid": None}) + self.assertEqual(body["permissions"][1], {"user_guid": "user-guid-2", "group_guid": None}) + self.assertEqual(body["permissions"][2], {"user_guid": None, "group_guid": "group-guid-1"}) + + def test_integration_add_missing_template(self): + runner = CliRunner() + args = apply_common_args( + ["integration", "add", "-C", "client_id=abc"], server=self.connect_server, key=self.api_key + ) + result = runner.invoke(cli, args) + self.assertNotEqual(result.exit_code, 0) + self.assertIn("--template", result.output) + + def test_integration_show_missing_guid(self): + runner = CliRunner() + args = apply_common_args(["integration", "show"], server=self.connect_server, key=self.api_key) + result = runner.invoke(cli, args) + self.assertNotEqual(result.exit_code, 0) + self.assertIn("GUID", result.output) diff --git a/tests/testdata/connect-responses/get-integration.json b/tests/testdata/connect-responses/get-integration.json new file mode 100644 index 00000000..2fc94e9f --- /dev/null +++ b/tests/testdata/connect-responses/get-integration.json @@ -0,0 +1,12 @@ +{ + "guid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "My OAuth Integration", + "description": "Integration for testing", + "template": "custom", + "auth_type": "Viewer", + "config": {"client_id": "abc123", "client_secret": "secret"}, + "permissions": [], + "environment_variables": ["OAUTH_CLIENT_ID"], + "created_time": "2024-01-01T00:00:00Z", + "updated_time": "2024-01-01T00:00:00Z" +} diff --git a/tests/testdata/connect-responses/get-template.json b/tests/testdata/connect-responses/get-template.json new file mode 100644 index 00000000..a3047bb8 --- /dev/null +++ b/tests/testdata/connect-responses/get-template.json @@ -0,0 +1,12 @@ +{ + "id": "custom", + "name": "Custom OAuth Integration", + "description": "A custom OAuth 2.0 integration", + "fields": [ + {"name": "client_id", "label": "Client ID", "secret": false}, + {"name": "client_secret", "label": "Client Secret", "secret": true} + ], + "options": [ + {"name": "use_pkce", "label": "Use PKCE", "type": "bool", "default": true} + ] +} diff --git a/tests/testdata/connect-responses/list-integrations.json b/tests/testdata/connect-responses/list-integrations.json new file mode 100644 index 00000000..ad8c348e --- /dev/null +++ b/tests/testdata/connect-responses/list-integrations.json @@ -0,0 +1,26 @@ +[ + { + "guid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "My OAuth Integration", + "description": "Integration for testing", + "template": "custom", + "auth_type": "Viewer", + "config": {"client_id": "abc123", "client_secret": "secret"}, + "permissions": [], + "environment_variables": ["OAUTH_CLIENT_ID"], + "created_time": "2024-01-01T00:00:00Z", + "updated_time": "2024-01-01T00:00:00Z" + }, + { + "guid": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "name": "Service Account Integration", + "description": null, + "template": "custom", + "auth_type": "Service Account", + "config": {"client_id": "def456"}, + "permissions": [{"user_guid": "820f092f-d564-4ab5-819a-0f4d2f03d11e", "group_guid": null}], + "environment_variables": [], + "created_time": "2024-02-01T00:00:00Z", + "updated_time": "2024-02-15T00:00:00Z" + } +] diff --git a/tests/testdata/connect-responses/list-templates.json b/tests/testdata/connect-responses/list-templates.json new file mode 100644 index 00000000..ece40f0f --- /dev/null +++ b/tests/testdata/connect-responses/list-templates.json @@ -0,0 +1,14 @@ +[ + { + "id": "custom", + "name": "Custom OAuth Integration", + "description": "A custom OAuth 2.0 integration", + "fields": [ + {"name": "client_id", "label": "Client ID", "secret": false}, + {"name": "client_secret", "label": "Client Secret", "secret": true} + ], + "options": [ + {"name": "use_pkce", "label": "Use PKCE", "type": "bool", "default": true} + ] + } +]