From 237959d614fbee351f18817924c8375e75af919f Mon Sep 17 00:00:00 2001 From: aayushsingh2502 Date: Mon, 29 Sep 2025 11:53:07 +0530 Subject: [PATCH 1/5] oauth client --- examples/oauth_client_complete_test.py | 416 +++++++++++++++++++++++++ src/tfe/client.py | 2 + src/tfe/errors.py | 10 + src/tfe/models/__init__.py | 14 + src/tfe/models/oauth_client.py | 173 ++++++++++ src/tfe/resources/oauth_client.py | 189 +++++++++++ src/tfe/utils.py | 68 ++++ 7 files changed, 872 insertions(+) create mode 100644 examples/oauth_client_complete_test.py create mode 100644 src/tfe/models/oauth_client.py create mode 100644 src/tfe/resources/oauth_client.py diff --git a/examples/oauth_client_complete_test.py b/examples/oauth_client_complete_test.py new file mode 100644 index 0000000..60de473 --- /dev/null +++ b/examples/oauth_client_complete_test.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +""" +Complete OAuth Client Testing Suite + +This file contains individual tests for all 8 OAuth client functions implemented in src/tfe/resources/oauth_client.py: + +PUBLIC FUNCTIONS AVAILABLE FOR TESTING: +1. list() - List all OAuth clients for an organization +2. create() - Create OAuth client with VCS provider connection +3. read() - Read an OAuth client by ID +4. read_with_options() - Read OAuth client with include options +5. update() - Update an existing OAuth client +6. delete() - Delete an OAuth client +7. add_projects() - Add projects to an OAuth client +8. remove_projects() - Remove projects from an OAuth client + +USAGE: +- Uncomment specific test sections to test individual functions +- Tests require valid TFE credentials and organization access +- Some tests require existing projects in your organization +- GitHub token required for creating GitHub OAuth clients +- Modify test data (organization, tokens, etc.) as needed for your environment + +REQUIREMENTS: +- Set TFE_ADDRESS and TFE_TOKEN environment variables +- Set OAUTH_CLIENT_GITHUB_TOKEN environment variable for GitHub tests +- Ensure you have organization access and proper permissions +""" + +import os +import sys +import time +import random + +# Add the src directory to the path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from tfe import TFEClient, TFEConfig +from tfe.errors import NotFound +from tfe.models.oauth_client import ( + OAuthClientAddProjectsOptions, + OAuthClientCreateOptions, + OAuthClientIncludeOpt, + OAuthClientListOptions, + OAuthClientReadOptions, + OAuthClientRemoveProjectsOptions, + OAuthClientUpdateOptions, + ServiceProviderType, +) + + +def main(): + """Test all OAuth client functions individually.""" + + print("=" * 80) + print("OAUTH CLIENT COMPLETE TESTING SUITE") + print("=" * 80) + print("Testing ALL 8 functions in src/tfe/resources/oauth_client.py") + print("Comprehensive test coverage for all OAuth client operations") + print("=" * 80) + + # Initialize the TFE client + client = TFEClient(TFEConfig.from_env()) + organization_name = "aayush-test" # Replace with your organization + + # Variables to store created resources for dependent tests + created_oauth_client = None + test_projects = [] + + # Check for required environment variables + github_token = os.getenv("OAUTH_CLIENT_GITHUB_TOKEN") + if not github_token: + print("\n⚠ WARNING: OAUTH_CLIENT_GITHUB_TOKEN not set. GitHub-related tests will be skipped.") + print("Set this environment variable to test OAuth client creation with GitHub.") + + # ===================================================== + # TEST 1: LIST OAUTH CLIENTS + # ===================================================== + print("\n" + "=" * 60) + print("TEST 1: list() - List all OAuth clients for organization") + print("=" * 60) + + try: + print(f"Listing OAuth clients for organization: {organization_name}") + + # Test basic list without options + oauth_clients = list(client.oauth_clients.list(organization_name)) + print(f" ✓ Found {len(oauth_clients)} OAuth clients") + + for i, oauth_client in enumerate(oauth_clients[:3], 1): + print(f" {i}. {oauth_client.id} - {oauth_client.service_provider}") + if oauth_client.name: + print(f" Name: {oauth_client.name}") + print(f" Service Provider: {oauth_client.service_provider_name}") + + # Test list with options + if len(oauth_clients) > 0: + print("\nTesting list() with options:") + options = OAuthClientListOptions( + include=[OAuthClientIncludeOpt.OAUTH_TOKENS, OAuthClientIncludeOpt.PROJECTS], + page_size=10 + ) + oauth_clients_with_options = list(client.oauth_clients.list(organization_name, options)) + print(f" ✓ Found {len(oauth_clients_with_options)} OAuth clients with options") + + if oauth_clients_with_options: + first_client = oauth_clients_with_options[0] + print(f" First client includes - OAuth Tokens: {len(first_client.oauth_tokens or [])}") + print(f" - Projects: {len(first_client.projects or [])}") + + except Exception as e: + print(f" ✗ Error listing OAuth clients: {e}") + + # ===================================================== + # TEST 2: CREATE OAUTH CLIENT + # ===================================================== + print("\n" + "=" * 60) + print("TEST 2: create() - Create OAuth client with VCS provider") + print("=" * 60) + + if github_token: + try: + unique_suffix = f"{int(time.time())}-{random.randint(1000, 9999)}" + client_name = f"test-github-client-{unique_suffix}" + + print(f"Creating GitHub OAuth client: {client_name}") + + create_options = OAuthClientCreateOptions( + name=client_name, + api_url="https://api.github.com", + http_url="https://github.com", + oauth_token=github_token, + service_provider=ServiceProviderType.GITHUB, + organization_scoped=True + ) + + created_oauth_client = client.oauth_clients.create(organization_name, create_options) + print(f" ✓ Created OAuth client: {created_oauth_client.id}") + print(f" Name: {created_oauth_client.name}") + print(f" Service Provider: {created_oauth_client.service_provider}") + print(f" API URL: {created_oauth_client.api_url}") + print(f" HTTP URL: {created_oauth_client.http_url}") + print(f" Organization Scoped: {created_oauth_client.organization_scoped}") + + except Exception as e: + print(f" ✗ Error creating OAuth client: {e}") + else: + print(" ⚠ Skipped - OAUTH_CLIENT_GITHUB_TOKEN not set") + + # ===================================================== + # TEST 3: READ OAUTH CLIENT + # ===================================================== + print("\n" + "=" * 60) + print("TEST 3: read() - Read OAuth client by ID") + print("=" * 60) + + if created_oauth_client: + try: + print(f"Reading OAuth client: {created_oauth_client.id}") + + read_oauth_client = client.oauth_clients.read(created_oauth_client.id) + print(f" ✓ Read OAuth client: {read_oauth_client.id}") + print(f" Name: {read_oauth_client.name}") + print(f" Service Provider: {read_oauth_client.service_provider}") + print(f" Created At: {read_oauth_client.created_at}") + print(f" Callback URL: {read_oauth_client.callback_url}") + print(f" Connect Path: {read_oauth_client.connect_path}") + + except Exception as e: + print(f" ✗ Error reading OAuth client: {e}") + else: + # Try to read an existing OAuth client if no client was created + try: + oauth_clients = list(client.oauth_clients.list(organization_name)) + if oauth_clients: + test_client = oauth_clients[0] + print(f"Reading existing OAuth client: {test_client.id}") + + read_oauth_client = client.oauth_clients.read(test_client.id) + print(f" ✓ Read existing OAuth client: {read_oauth_client.id}") + print(f" Service Provider: {read_oauth_client.service_provider}") + else: + print(" ⚠ No existing OAuth clients found to test read()") + except Exception as e: + print(f" ✗ Error reading existing OAuth client: {e}") + + # ===================================================== + # TEST 4: READ OAUTH CLIENT WITH OPTIONS + # ===================================================== + print("\n" + "=" * 60) + print("TEST 4: read_with_options() - Read OAuth client with includes") + print("=" * 60) + + target_client = created_oauth_client + if not target_client: + # Try to use an existing client + try: + oauth_clients = list(client.oauth_clients.list(organization_name)) + if oauth_clients: + target_client = oauth_clients[0] + except: + pass + + if target_client: + try: + print(f"Reading OAuth client with options: {target_client.id}") + + read_options = OAuthClientReadOptions( + include=[OAuthClientIncludeOpt.OAUTH_TOKENS, OAuthClientIncludeOpt.PROJECTS] + ) + + read_oauth_client = client.oauth_clients.read_with_options( + target_client.id, read_options + ) + print(f" ✓ Read OAuth client with options: {read_oauth_client.id}") + print(f" OAuth Tokens: {len(read_oauth_client.oauth_tokens or [])}") + print(f" Projects: {len(read_oauth_client.projects or [])}") + + if read_oauth_client.oauth_tokens: + print(" OAuth Token details:") + for i, token in enumerate(read_oauth_client.oauth_tokens[:2], 1): + if isinstance(token, dict): + print(f" {i}. Token ID: {token.get('id', 'N/A')}") + + except Exception as e: + print(f" ✗ Error reading OAuth client with options: {e}") + else: + print(" ⚠ No OAuth client available to test read_with_options()") + + # ===================================================== + # TEST 5: UPDATE OAUTH CLIENT + # ===================================================== + print("\n" + "=" * 60) + print("TEST 5: update() - Update existing OAuth client") + print("=" * 60) + + if created_oauth_client: + try: + print(f"Updating OAuth client: {created_oauth_client.id}") + + update_options = OAuthClientUpdateOptions( + name=f"{created_oauth_client.name}-updated", + organization_scoped=False # Toggle the organization scoped setting + ) + + updated_oauth_client = client.oauth_clients.update( + created_oauth_client.id, update_options + ) + print(f" ✓ Updated OAuth client: {updated_oauth_client.id}") + print(f" Updated Name: {updated_oauth_client.name}") + print(f" Updated Organization Scoped: {updated_oauth_client.organization_scoped}") + + # Update our reference + created_oauth_client = updated_oauth_client + + except Exception as e: + print(f" ✗ Error updating OAuth client: {e}") + else: + print(" ⚠ No OAuth client created to test update()") + + # ===================================================== + # TEST 6: PREPARE TEST PROJECTS (for project operations) + # ===================================================== + print("\n" + "=" * 60) + print("PREPARATION: Getting projects for project operations tests") + print("=" * 60) + + try: + # Try to get some existing projects + projects = list(client.projects.list(organization_name)) + if projects: + # Use first 2 projects for testing + test_projects = [ + {"type": "projects", "id": project.id} + for project in projects[:2] + ] + print(f" ✓ Found {len(projects)} projects, using {len(test_projects)} for testing:") + for i, project_ref in enumerate(test_projects, 1): + corresponding_project = projects[i-1] + print(f" {i}. {corresponding_project.name} (ID: {project_ref['id']})") + else: + print(" ⚠ No projects found - project operations tests will be skipped") + + except Exception as e: + print(f" ⚠ Error getting projects: {e}") + + # # ===================================================== + # # TEST 7: ADD PROJECTS TO OAUTH CLIENT + # # ===================================================== + # print("\n" + "=" * 60) + # print("TEST 7: add_projects() - Add projects to OAuth client") + # print("=" * 60) + + # if created_oauth_client and test_projects: + # try: + # print(f"Adding projects to OAuth client: {created_oauth_client.id}") + # print(f"Projects to add: {[p['id'] for p in test_projects]}") + + # add_options = OAuthClientAddProjectsOptions( + # projects=test_projects + # ) + + # client.oauth_clients.add_projects(created_oauth_client.id, add_options) + # print(f" ✓ Successfully added {len(test_projects)} projects to OAuth client") + + # # Verify the projects were added by reading the client with projects included + # read_options = OAuthClientReadOptions( + # include=[OAuthClientIncludeOpt.PROJECTS] + # ) + # updated_client = client.oauth_clients.read_with_options( + # created_oauth_client.id, read_options + # ) + # print(f" ✓ Verification: OAuth client now has {len(updated_client.projects or [])} projects") + + # except Exception as e: + # print(f" ✗ Error adding projects to OAuth client: {e}") + # else: + # if not created_oauth_client: + # print(" ⚠ No OAuth client created to test add_projects()") + # if not test_projects: + # print(" ⚠ No projects available to test add_projects()") + + # # ===================================================== + # # TEST 8: REMOVE PROJECTS FROM OAUTH CLIENT + # # ===================================================== + # print("\n" + "=" * 60) + # print("TEST 8: remove_projects() - Remove projects from OAuth client") + # print("=" * 60) + + # if created_oauth_client and test_projects: + # try: + # print(f"Removing projects from OAuth client: {created_oauth_client.id}") + # print(f"Projects to remove: {[p['id'] for p in test_projects]}") + + # remove_options = OAuthClientRemoveProjectsOptions( + # projects=test_projects + # ) + + # client.oauth_clients.remove_projects(created_oauth_client.id, remove_options) + # print(f" ✓ Successfully removed {len(test_projects)} projects from OAuth client") + + # # Verify the projects were removed by reading the client with projects included + # read_options = OAuthClientReadOptions( + # include=[OAuthClientIncludeOpt.PROJECTS] + # ) + # updated_client = client.oauth_clients.read_with_options( + # created_oauth_client.id, read_options + # ) + # print(f" ✓ Verification: OAuth client now has {len(updated_client.projects or [])} projects") + + # except Exception as e: + # print(f" ✗ Error removing projects from OAuth client: {e}") + # else: + # if not created_oauth_client: + # print(" ⚠ No OAuth client created to test remove_projects()") + # if not test_projects: + # print(" ⚠ No projects available to test remove_projects()") + + # # ===================================================== + # # TEST 9: DELETE OAUTH CLIENT + # # ===================================================== + # print("\n" + "=" * 60) + # print("TEST 9: delete() - Delete OAuth client") + # print("=" * 60) + + # if created_oauth_client: + # try: + # print(f"Deleting OAuth client: {created_oauth_client.id}") + + # # First, let's confirm it exists + # try: + # client.oauth_clients.read(created_oauth_client.id) + # print(" ✓ Confirmed OAuth client exists before deletion") + # except NotFound: + # print(" ⚠ OAuth client not found before deletion attempt") + + # # Delete the OAuth client + # client.oauth_clients.delete(created_oauth_client.id) + # print(f" ✓ Successfully deleted OAuth client: {created_oauth_client.id}") + + # # Verify deletion by trying to read it + # try: + # client.oauth_clients.read(created_oauth_client.id) + # print(" ⚠ Warning: OAuth client still exists after deletion") + # except NotFound: + # print(" ✓ Verification: OAuth client successfully deleted (not found)") + # except Exception as e: + # print(f" ? Verification error: {e}") + + # except Exception as e: + # print(f" ✗ Error deleting OAuth client: {e}") + # else: + # print(" ⚠ No OAuth client created to test delete()") + + # ===================================================== + # SUMMARY + # ===================================================== + print("\n" + "=" * 80) + print("OAUTH CLIENT TESTING COMPLETE") + print("=" * 80) + print("Functions tested:") + print("✓ 1. list() - List OAuth clients for organization") + print("✓ 2. create() - Create OAuth client with VCS provider") + print("✓ 3. read() - Read OAuth client by ID") + print("✓ 4. read_with_options() - Read OAuth client with includes") + print("✓ 5. update() - Update existing OAuth client") + print("✓ 6. add_projects() - Add projects to OAuth client") + print("✓ 7. remove_projects() - Remove projects from OAuth client") + print("✓ 8. delete() - Delete OAuth client") + print("\nAll OAuth client functions have been tested!") + print("Check the output above for any errors or warnings.") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/src/tfe/client.py b/src/tfe/client.py index 93e1254..2c3162f 100644 --- a/src/tfe/client.py +++ b/src/tfe/client.py @@ -2,6 +2,7 @@ from ._http import HTTPTransport from .config import TFEConfig +from .resources.oauth_client import OAuthClients from .resources.organizations import Organizations from .resources.projects import Projects from .resources.registry_module import RegistryModules @@ -33,6 +34,7 @@ def __init__(self, config: TFEConfig | None = None): proxies=cfg.proxies, ca_bundle=cfg.ca_bundle, ) + self.oauth_clients = OAuthClients(self._transport) self.organizations = Organizations(self._transport) self.projects = Projects(self._transport) self.variables = Variables(self._transport) diff --git a/src/tfe/errors.py b/src/tfe/errors.py index b78b806..2f21c6b 100644 --- a/src/tfe/errors.py +++ b/src/tfe/errors.py @@ -87,6 +87,16 @@ class ErrStateVersionUploadNotSupported(TFEError): ... ERR_REQUIRED_KEY = "key is required" ERR_REQUIRED_CATEGORY = "category is required" +# OAuth Client Error Constants +ERR_INVALID_OAUTH_CLIENT_ID = "invalid OAuth client ID" +ERR_REQUIRED_API_URL = "API URL is required" +ERR_REQUIRED_HTTP_URL = "HTTP URL is required" +ERR_REQUIRED_OAUTH_TOKEN = "OAuth token is required" +ERR_REQUIRED_SERVICE_PROVIDER = "service provider is required" +ERR_UNSUPPORTED_PRIVATE_KEY = "private key is not supported for this service provider" +ERR_REQUIRED_PROJECT = "projects are required" +ERR_PROJECT_MIN_LIMIT = "must specify at least one project" + class WorkspaceNotFound(NotFound): ... diff --git a/src/tfe/models/__init__.py b/src/tfe/models/__init__.py index 15fe592..904b798 100644 --- a/src/tfe/models/__init__.py +++ b/src/tfe/models/__init__.py @@ -4,6 +4,20 @@ import importlib.util import os +# Re-export all OAuth client types +from .oauth_client import ( + OAuthClient, + OAuthClientAddProjectsOptions, + OAuthClientCreateOptions, + OAuthClientIncludeOpt, + OAuthClientList, + OAuthClientListOptions, + OAuthClientReadOptions, + OAuthClientRemoveProjectsOptions, + OAuthClientUpdateOptions, + ServiceProviderType, +) + # Re-export all registry module types from .registry_module_types import ( AgentExecutionMode, diff --git a/src/tfe/models/oauth_client.py b/src/tfe/models/oauth_client.py new file mode 100644 index 0000000..97e6d53 --- /dev/null +++ b/src/tfe/models/oauth_client.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class ServiceProviderType(str, Enum): + """VCS service provider types.""" + + AZURE_DEVOPS_SERVER = "ado_server" + AZURE_DEVOPS_SERVICES = "ado_services" + BITBUCKET_DATA_CENTER = "bitbucket_data_center" + BITBUCKET_HOSTED = "bitbucket_hosted" + BITBUCKET_SERVER = "bitbucket_server" + BITBUCKET_SERVER_LEGACY = "bitbucket_server_legacy" + GITHUB = "github" + GITHUB_EE = "github_enterprise" + GITLAB_HOSTED = "gitlab_hosted" + GITLAB_CE = "gitlab_community_edition" + GITLAB_EE = "gitlab_enterprise_edition" + + +class OAuthClientIncludeOpt(str, Enum): + """Include options for OAuth client queries.""" + + OAUTH_TOKENS = "oauth_tokens" + PROJECTS = "projects" + + +class OAuthClient(BaseModel): + """OAuth client represents a connection between an organization and a VCS provider.""" + + id: str | None = None + api_url: str | None = Field(None, alias="api-url") + callback_url: str | None = Field(None, alias="callback-url") + connect_path: str | None = Field(None, alias="connect-path") + created_at: datetime | None = Field(None, alias="created-at") + http_url: str | None = Field(None, alias="http-url") + key: str | None = None + rsa_public_key: str | None = Field(None, alias="rsa-public-key") + name: str | None = None + secret: str | None = None + service_provider: ServiceProviderType | None = Field(None, alias="service-provider") + service_provider_name: str | None = Field(None, alias="service-provider-display-name") + organization_scoped: bool | None = Field(None, alias="organization-scoped") + + # Relations + organization: dict | None = None + oauth_tokens: list[dict] | None = Field(None, alias="oauth-tokens") + agent_pool: dict | None = Field(None, alias="agent-pool") + projects: list[dict] | None = None + + model_config = ConfigDict(populate_by_name=True) + + +class OAuthClientList(BaseModel): + """List of OAuth clients with pagination.""" + + data: list[OAuthClient] = [] + pagination: dict | None = None + + +class OAuthClientListOptions(BaseModel): + """Options for listing OAuth clients.""" + + # Pagination options + page_number: int | None = Field(None, alias="page[number]") + page_size: int | None = Field(None, alias="page[size]") + + # Include options + include: list[OAuthClientIncludeOpt] | None = None + + model_config = ConfigDict(populate_by_name=True) + + +class OAuthClientReadOptions(BaseModel): + """Options for reading an OAuth client.""" + + include: list[OAuthClientIncludeOpt] | None = None + + model_config = ConfigDict(populate_by_name=True) + + +class OAuthClientCreateOptions(BaseModel): + """Options for creating an OAuth client.""" + + # Display name for the OAuth Client + name: str | None = None + + # Required: The base URL of your VCS provider's API + api_url: str | None = Field(None, alias="api-url") + + # Required: The homepage of your VCS provider + http_url: str | None = Field(None, alias="http-url") + + # Optional: The OAuth Client key + key: str | None = None + + # Optional: The token string you were given by your VCS provider + oauth_token: str | None = Field(None, alias="oauth-token-string") + + # Optional: The initial list of projects for which the oauth client should be associated with + projects: list[dict] | None = None + + # Optional: Private key associated with this vcs provider - only available for ado_server + private_key: str | None = Field(None, alias="private-key") + + # Optional: Secret key associated with this vcs provider - only available for ado_server + secret: str | None = None + + # Optional: RSAPublicKey the text of the SSH public key associated with your + # BitBucket Data Center Application Link + rsa_public_key: str | None = Field(None, alias="rsa-public-key") + + # Required: The VCS provider being connected with + service_provider: ServiceProviderType | None = Field(None, alias="service-provider") + + # Optional: AgentPool to associate the VCS Provider with, for PrivateVCS support + agent_pool: dict | None = Field(None, alias="agent-pool") + + # Optional: Whether the OAuthClient is available to all workspaces in the organization + organization_scoped: bool | None = Field(None, alias="organization-scoped") + + model_config = ConfigDict(populate_by_name=True) + + +class OAuthClientUpdateOptions(BaseModel): + """Options for updating an OAuth client.""" + + # Optional: A display name for the OAuth Client + name: str | None = None + + # Optional: The OAuth Client key + key: str | None = None + + # Optional: Secret key associated with this vcs provider - only available for ado_server + secret: str | None = None + + # Optional: RSAPublicKey the text of the SSH public key associated with your BitBucket + # Server Application Link + rsa_public_key: str | None = Field(None, alias="rsa-public-key") + + # Optional: The token string you were given by your VCS provider + oauth_token: str | None = Field(None, alias="oauth-token-string") + + # Optional: AgentPool to associate the VCS Provider with, for PrivateVCS support + agent_pool: dict | None = Field(None, alias="agent-pool") + + # Optional: Whether the OAuthClient is available to all workspaces in the organization + organization_scoped: bool | None = Field(None, alias="organization-scoped") + + model_config = ConfigDict(populate_by_name=True) + + +class OAuthClientAddProjectsOptions(BaseModel): + """Options for adding projects to an OAuth client.""" + + # The projects to add to an OAuth client + projects: list[dict] + + model_config = ConfigDict(populate_by_name=True) + + +class OAuthClientRemoveProjectsOptions(BaseModel): + """Options for removing projects from an OAuth client.""" + + # The projects to remove from an OAuth client + projects: list[dict] + + model_config = ConfigDict(populate_by_name=True) diff --git a/src/tfe/resources/oauth_client.py b/src/tfe/resources/oauth_client.py new file mode 100644 index 0000000..25a6e19 --- /dev/null +++ b/src/tfe/resources/oauth_client.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any +from urllib.parse import quote + +from ..errors import ERR_INVALID_OAUTH_CLIENT_ID, ERR_INVALID_ORG +from ..models.oauth_client import ( + OAuthClient, + OAuthClientAddProjectsOptions, + OAuthClientCreateOptions, + OAuthClientList, + OAuthClientListOptions, + OAuthClientReadOptions, + OAuthClientRemoveProjectsOptions, + OAuthClientUpdateOptions, +) +from ..utils import ( + valid_oauth_client_id, + valid_string_id, + validate_oauth_client_add_projects_options, + validate_oauth_client_create_options, + validate_oauth_client_remove_projects_options, +) +from ._base import _Service + + +class OAuthClients(_Service): + """OAuth clients service for managing VCS provider connections.""" + + def list( + self, organization: str, options: OAuthClientListOptions | None = None + ) -> Iterator[OAuthClient]: + """List all OAuth clients for a given organization.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + path = f"/api/v2/organizations/{quote(organization)}/oauth-clients" + params = {} + + if options: + if options.page_number: + params["page[number]"] = str(options.page_number) + if options.page_size: + params["page[size]"] = str(options.page_size) + if options.include: + params["include"] = ",".join([opt.value for opt in options.include]) + + for item in self._list(path, params=params): + if item is None: + continue # type: ignore[unreachable] # Skip None items + yield self._parse_oauth_client(item) + + def create( + self, organization: str, options: OAuthClientCreateOptions + ) -> OAuthClient: + """Create an OAuth client to connect an organization and a VCS provider.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + validate_oauth_client_create_options(options) + + body = { + "data": { + "type": "oauth-clients", + "attributes": options.model_dump(exclude_none=True, by_alias=True), + } + } + + # Handle relations separately + if options.projects: + body["data"]["relationships"] = { + "projects": {"data": options.projects} + } + + if options.agent_pool: + if "relationships" not in body["data"]: + body["data"]["relationships"] = {} + body["data"]["relationships"]["agent-pool"] = {"data": options.agent_pool} + + path = f"/api/v2/organizations/{quote(organization)}/oauth-clients" + response = self.t.request("POST", path, json_body=body) + data = response.json()["data"] + + return self._parse_oauth_client(data) + + def read(self, oauth_client_id: str) -> OAuthClient: + """Read an OAuth client by its ID.""" + return self.read_with_options(oauth_client_id, None) + + def read_with_options( + self, oauth_client_id: str, options: OAuthClientReadOptions | None + ) -> OAuthClient: + """Read an OAuth client by its ID with options.""" + if not valid_oauth_client_id(oauth_client_id): + raise ValueError(ERR_INVALID_OAUTH_CLIENT_ID) + + path = f"/api/v2/oauth-clients/{quote(oauth_client_id)}" + params = {} + + if options and options.include: + params["include"] = ",".join([opt.value for opt in options.include]) + + response = self.t.request("GET", path, params=params) + data = response.json()["data"] + + return self._parse_oauth_client(data) + + def update( + self, oauth_client_id: str, options: OAuthClientUpdateOptions + ) -> OAuthClient: + """Update an OAuth client by its ID.""" + if not valid_oauth_client_id(oauth_client_id): + raise ValueError(ERR_INVALID_OAUTH_CLIENT_ID) + + body = { + "data": { + "type": "oauth-clients", + "attributes": options.model_dump(exclude_none=True, by_alias=True), + } + } + + # Handle relations separately + if options.agent_pool: + body["data"]["relationships"] = { + "agent-pool": {"data": options.agent_pool} + } + + path = f"/api/v2/oauth-clients/{quote(oauth_client_id)}" + response = self.t.request("PATCH", path, json_body=body) + data = response.json()["data"] + + return self._parse_oauth_client(data) + + def delete(self, oauth_client_id: str) -> None: + """Delete an OAuth client by its ID.""" + if not valid_oauth_client_id(oauth_client_id): + raise ValueError(ERR_INVALID_OAUTH_CLIENT_ID) + + path = f"/api/v2/oauth-clients/{quote(oauth_client_id)}" + self.t.request("DELETE", path) + + def add_projects( + self, oauth_client_id: str, options: OAuthClientAddProjectsOptions + ) -> None: + """Add projects to a given OAuth client.""" + if not valid_oauth_client_id(oauth_client_id): + raise ValueError(ERR_INVALID_OAUTH_CLIENT_ID) + + validate_oauth_client_add_projects_options(options) + + path = f"/api/v2/oauth-clients/{quote(oauth_client_id)}/relationships/projects" + self.t.request("POST", path, json_body={"data": options.projects}) + + def remove_projects( + self, oauth_client_id: str, options: OAuthClientRemoveProjectsOptions + ) -> None: + """Remove projects from an OAuth client.""" + if not valid_oauth_client_id(oauth_client_id): + raise ValueError(ERR_INVALID_OAUTH_CLIENT_ID) + + validate_oauth_client_remove_projects_options(options) + + path = f"/api/v2/oauth-clients/{quote(oauth_client_id)}/relationships/projects" + self.t.request("DELETE", path, json_body={"data": options.projects}) + + def _parse_oauth_client(self, data: dict[str, Any]) -> OAuthClient: + """Parse OAuth client data from API response.""" + oauth_client = OAuthClient( + id=data.get("id"), + **data.get("attributes", {}), + ) + + # Handle relationships + relationships = data.get("relationships", {}) + + if "organization" in relationships: + oauth_client.organization = relationships["organization"].get("data") + + if "oauth-tokens" in relationships: + oauth_client.oauth_tokens = relationships["oauth-tokens"].get("data", []) + + if "agent-pool" in relationships: + oauth_client.agent_pool = relationships["agent-pool"].get("data") + + if "projects" in relationships: + oauth_client.projects = relationships["projects"].get("data", []) + + return oauth_client diff --git a/src/tfe/utils.py b/src/tfe/utils.py index d8be76d..a12c7a4 100644 --- a/src/tfe/utils.py +++ b/src/tfe/utils.py @@ -197,3 +197,71 @@ def validate_workspace_update_options(options: WorkspaceUpdateOptions) -> None: if options.file_triggers_enabled is not None and options.file_triggers_enabled: raise UnsupportedBothTagsRegexAndFileTriggersEnabledError() + + +def valid_oauth_client_id(v: str | None) -> bool: + """Validate OAuth client ID format.""" + return valid_string_id(v) + + +def validate_oauth_client_create_options(options) -> None: + """ + Validate OAuth client create options similar to Go implementation. + Raises specific validation errors if validation fails. + """ + from .errors import ( + ERR_REQUIRED_API_URL, + ERR_REQUIRED_HTTP_URL, + ERR_REQUIRED_OAUTH_TOKEN, + ERR_REQUIRED_SERVICE_PROVIDER, + ERR_UNSUPPORTED_PRIVATE_KEY, + ) + from .models.oauth_client import ServiceProviderType + + if not valid_string(options.api_url): + raise ValueError(ERR_REQUIRED_API_URL) + + if not valid_string(options.http_url): + raise ValueError(ERR_REQUIRED_HTTP_URL) + + if options.service_provider is None: + raise ValueError(ERR_REQUIRED_SERVICE_PROVIDER) + + # OAuth token not required for Bitbucket Server and Data Center + if (not valid_string(options.oauth_token) and + options.service_provider != ServiceProviderType.BITBUCKET_SERVER and + options.service_provider != ServiceProviderType.BITBUCKET_DATA_CENTER): + raise ValueError(ERR_REQUIRED_OAUTH_TOKEN) + + # Private key only supported for Azure DevOps Server + if (valid_string(options.private_key) and + options.service_provider != ServiceProviderType.AZURE_DEVOPS_SERVER): + raise ValueError(ERR_UNSUPPORTED_PRIVATE_KEY) + + +def validate_oauth_client_add_projects_options(options) -> None: + """ + Validate OAuth client add projects options. + Raises specific validation errors if validation fails. + """ + from .errors import ERR_REQUIRED_PROJECT, ERR_PROJECT_MIN_LIMIT + + if options.projects is None: + raise ValueError(ERR_REQUIRED_PROJECT) + + if len(options.projects) == 0: + raise ValueError(ERR_PROJECT_MIN_LIMIT) + + +def validate_oauth_client_remove_projects_options(options) -> None: + """ + Validate OAuth client remove projects options. + Raises specific validation errors if validation fails. + """ + from .errors import ERR_REQUIRED_PROJECT, ERR_PROJECT_MIN_LIMIT + + if options.projects is None: + raise ValueError(ERR_REQUIRED_PROJECT) + + if len(options.projects) == 0: + raise ValueError(ERR_PROJECT_MIN_LIMIT) From 5876c8c09b9e9463a90fdbd4eccde91c00a67eab Mon Sep 17 00:00:00 2001 From: aayushsingh2502 Date: Fri, 3 Oct 2025 09:32:09 +0530 Subject: [PATCH 2/5] oauth client sdk update --- examples/oauth_client_complete_test.py | 345 +++++++------- src/tfe/models/__init__.py | 11 + src/tfe/models/oauth_client.py | 61 +-- src/tfe/resources/oauth_client.py | 21 +- src/tfe/utils.py | 55 ++- tests/units/test_oauth_client.py | 604 +++++++++++++++++++++++++ 6 files changed, 879 insertions(+), 218 deletions(-) create mode 100644 tests/units/test_oauth_client.py diff --git a/examples/oauth_client_complete_test.py b/examples/oauth_client_complete_test.py index 60de473..d09867a 100644 --- a/examples/oauth_client_complete_test.py +++ b/examples/oauth_client_complete_test.py @@ -28,9 +28,9 @@ """ import os +import random import sys import time -import random # Add the src directory to the path sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) @@ -70,8 +70,12 @@ def main(): # Check for required environment variables github_token = os.getenv("OAUTH_CLIENT_GITHUB_TOKEN") if not github_token: - print("\n⚠ WARNING: OAUTH_CLIENT_GITHUB_TOKEN not set. GitHub-related tests will be skipped.") - print("Set this environment variable to test OAuth client creation with GitHub.") + print( + "\n⚠ WARNING: OAUTH_CLIENT_GITHUB_TOKEN not set. GitHub-related tests will be skipped." + ) + print( + "Set this environment variable to test OAuth client creation with GitHub." + ) # ===================================================== # TEST 1: LIST OAUTH CLIENTS @@ -79,34 +83,45 @@ def main(): print("\n" + "=" * 60) print("TEST 1: list() - List all OAuth clients for organization") print("=" * 60) - + try: print(f"Listing OAuth clients for organization: {organization_name}") - + # Test basic list without options oauth_clients = list(client.oauth_clients.list(organization_name)) print(f" ✓ Found {len(oauth_clients)} OAuth clients") - + for i, oauth_client in enumerate(oauth_clients[:3], 1): print(f" {i}. {oauth_client.id} - {oauth_client.service_provider}") if oauth_client.name: print(f" Name: {oauth_client.name}") print(f" Service Provider: {oauth_client.service_provider_name}") - + # Test list with options if len(oauth_clients) > 0: print("\nTesting list() with options:") options = OAuthClientListOptions( - include=[OAuthClientIncludeOpt.OAUTH_TOKENS, OAuthClientIncludeOpt.PROJECTS], - page_size=10 + include=[ + OAuthClientIncludeOpt.OAUTH_TOKENS, + OAuthClientIncludeOpt.PROJECTS, + ], + page_size=10, ) - oauth_clients_with_options = list(client.oauth_clients.list(organization_name, options)) - print(f" ✓ Found {len(oauth_clients_with_options)} OAuth clients with options") - + oauth_clients_with_options = list( + client.oauth_clients.list(organization_name, options) + ) + print( + f" ✓ Found {len(oauth_clients_with_options)} OAuth clients with options" + ) + if oauth_clients_with_options: first_client = oauth_clients_with_options[0] - print(f" First client includes - OAuth Tokens: {len(first_client.oauth_tokens or [])}") - print(f" - Projects: {len(first_client.projects or [])}") + print( + f" First client includes - OAuth Tokens: {len(first_client.oauth_tokens or [])}" + ) + print( + f" - Projects: {len(first_client.projects or [])}" + ) except Exception as e: print(f" ✗ Error listing OAuth clients: {e}") @@ -117,31 +132,35 @@ def main(): print("\n" + "=" * 60) print("TEST 2: create() - Create OAuth client with VCS provider") print("=" * 60) - + if github_token: try: unique_suffix = f"{int(time.time())}-{random.randint(1000, 9999)}" client_name = f"test-github-client-{unique_suffix}" - + print(f"Creating GitHub OAuth client: {client_name}") - + create_options = OAuthClientCreateOptions( name=client_name, api_url="https://api.github.com", http_url="https://github.com", oauth_token=github_token, service_provider=ServiceProviderType.GITHUB, - organization_scoped=True + organization_scoped=True, + ) + + created_oauth_client = client.oauth_clients.create( + organization_name, create_options ) - - created_oauth_client = client.oauth_clients.create(organization_name, create_options) print(f" ✓ Created OAuth client: {created_oauth_client.id}") print(f" Name: {created_oauth_client.name}") print(f" Service Provider: {created_oauth_client.service_provider}") print(f" API URL: {created_oauth_client.api_url}") print(f" HTTP URL: {created_oauth_client.http_url}") - print(f" Organization Scoped: {created_oauth_client.organization_scoped}") - + print( + f" Organization Scoped: {created_oauth_client.organization_scoped}" + ) + except Exception as e: print(f" ✗ Error creating OAuth client: {e}") else: @@ -153,11 +172,11 @@ def main(): print("\n" + "=" * 60) print("TEST 3: read() - Read OAuth client by ID") print("=" * 60) - + if created_oauth_client: try: print(f"Reading OAuth client: {created_oauth_client.id}") - + read_oauth_client = client.oauth_clients.read(created_oauth_client.id) print(f" ✓ Read OAuth client: {read_oauth_client.id}") print(f" Name: {read_oauth_client.name}") @@ -165,7 +184,7 @@ def main(): print(f" Created At: {read_oauth_client.created_at}") print(f" Callback URL: {read_oauth_client.callback_url}") print(f" Connect Path: {read_oauth_client.connect_path}") - + except Exception as e: print(f" ✗ Error reading OAuth client: {e}") else: @@ -175,7 +194,7 @@ def main(): if oauth_clients: test_client = oauth_clients[0] print(f"Reading existing OAuth client: {test_client.id}") - + read_oauth_client = client.oauth_clients.read(test_client.id) print(f" ✓ Read existing OAuth client: {read_oauth_client.id}") print(f" Service Provider: {read_oauth_client.service_provider}") @@ -190,7 +209,7 @@ def main(): print("\n" + "=" * 60) print("TEST 4: read_with_options() - Read OAuth client with includes") print("=" * 60) - + target_client = created_oauth_client if not target_client: # Try to use an existing client @@ -198,30 +217,33 @@ def main(): oauth_clients = list(client.oauth_clients.list(organization_name)) if oauth_clients: target_client = oauth_clients[0] - except: + except Exception: pass - + if target_client: try: print(f"Reading OAuth client with options: {target_client.id}") - + read_options = OAuthClientReadOptions( - include=[OAuthClientIncludeOpt.OAUTH_TOKENS, OAuthClientIncludeOpt.PROJECTS] + include=[ + OAuthClientIncludeOpt.OAUTH_TOKENS, + OAuthClientIncludeOpt.PROJECTS, + ] ) - + read_oauth_client = client.oauth_clients.read_with_options( target_client.id, read_options ) print(f" ✓ Read OAuth client with options: {read_oauth_client.id}") print(f" OAuth Tokens: {len(read_oauth_client.oauth_tokens or [])}") print(f" Projects: {len(read_oauth_client.projects or [])}") - + if read_oauth_client.oauth_tokens: print(" OAuth Token details:") for i, token in enumerate(read_oauth_client.oauth_tokens[:2], 1): if isinstance(token, dict): print(f" {i}. Token ID: {token.get('id', 'N/A')}") - + except Exception as e: print(f" ✗ Error reading OAuth client with options: {e}") else: @@ -233,26 +255,28 @@ def main(): print("\n" + "=" * 60) print("TEST 5: update() - Update existing OAuth client") print("=" * 60) - + if created_oauth_client: try: print(f"Updating OAuth client: {created_oauth_client.id}") - + update_options = OAuthClientUpdateOptions( name=f"{created_oauth_client.name}-updated", - organization_scoped=False # Toggle the organization scoped setting + organization_scoped=False, # Toggle the organization scoped setting ) - + updated_oauth_client = client.oauth_clients.update( created_oauth_client.id, update_options ) print(f" ✓ Updated OAuth client: {updated_oauth_client.id}") print(f" Updated Name: {updated_oauth_client.name}") - print(f" Updated Organization Scoped: {updated_oauth_client.organization_scoped}") - + print( + f" Updated Organization Scoped: {updated_oauth_client.organization_scoped}" + ) + # Update our reference created_oauth_client = updated_oauth_client - + except Exception as e: print(f" ✗ Error updating OAuth client: {e}") else: @@ -264,133 +288,144 @@ def main(): print("\n" + "=" * 60) print("PREPARATION: Getting projects for project operations tests") print("=" * 60) - + try: # Try to get some existing projects projects = list(client.projects.list(organization_name)) if projects: # Use first 2 projects for testing test_projects = [ - {"type": "projects", "id": project.id} - for project in projects[:2] + {"type": "projects", "id": project.id} for project in projects[:2] ] - print(f" ✓ Found {len(projects)} projects, using {len(test_projects)} for testing:") + print( + f" ✓ Found {len(projects)} projects, using {len(test_projects)} for testing:" + ) for i, project_ref in enumerate(test_projects, 1): - corresponding_project = projects[i-1] - print(f" {i}. {corresponding_project.name} (ID: {project_ref['id']})") + corresponding_project = projects[i - 1] + print( + f" {i}. {corresponding_project.name} (ID: {project_ref['id']})" + ) else: print(" ⚠ No projects found - project operations tests will be skipped") - + except Exception as e: print(f" ⚠ Error getting projects: {e}") - # # ===================================================== - # # TEST 7: ADD PROJECTS TO OAUTH CLIENT - # # ===================================================== - # print("\n" + "=" * 60) - # print("TEST 7: add_projects() - Add projects to OAuth client") - # print("=" * 60) - - # if created_oauth_client and test_projects: - # try: - # print(f"Adding projects to OAuth client: {created_oauth_client.id}") - # print(f"Projects to add: {[p['id'] for p in test_projects]}") - - # add_options = OAuthClientAddProjectsOptions( - # projects=test_projects - # ) - - # client.oauth_clients.add_projects(created_oauth_client.id, add_options) - # print(f" ✓ Successfully added {len(test_projects)} projects to OAuth client") - - # # Verify the projects were added by reading the client with projects included - # read_options = OAuthClientReadOptions( - # include=[OAuthClientIncludeOpt.PROJECTS] - # ) - # updated_client = client.oauth_clients.read_with_options( - # created_oauth_client.id, read_options - # ) - # print(f" ✓ Verification: OAuth client now has {len(updated_client.projects or [])} projects") - - # except Exception as e: - # print(f" ✗ Error adding projects to OAuth client: {e}") - # else: - # if not created_oauth_client: - # print(" ⚠ No OAuth client created to test add_projects()") - # if not test_projects: - # print(" ⚠ No projects available to test add_projects()") - - # # ===================================================== - # # TEST 8: REMOVE PROJECTS FROM OAUTH CLIENT - # # ===================================================== - # print("\n" + "=" * 60) - # print("TEST 8: remove_projects() - Remove projects from OAuth client") - # print("=" * 60) - - # if created_oauth_client and test_projects: - # try: - # print(f"Removing projects from OAuth client: {created_oauth_client.id}") - # print(f"Projects to remove: {[p['id'] for p in test_projects]}") - - # remove_options = OAuthClientRemoveProjectsOptions( - # projects=test_projects - # ) - - # client.oauth_clients.remove_projects(created_oauth_client.id, remove_options) - # print(f" ✓ Successfully removed {len(test_projects)} projects from OAuth client") - - # # Verify the projects were removed by reading the client with projects included - # read_options = OAuthClientReadOptions( - # include=[OAuthClientIncludeOpt.PROJECTS] - # ) - # updated_client = client.oauth_clients.read_with_options( - # created_oauth_client.id, read_options - # ) - # print(f" ✓ Verification: OAuth client now has {len(updated_client.projects or [])} projects") - - # except Exception as e: - # print(f" ✗ Error removing projects from OAuth client: {e}") - # else: - # if not created_oauth_client: - # print(" ⚠ No OAuth client created to test remove_projects()") - # if not test_projects: - # print(" ⚠ No projects available to test remove_projects()") - - # # ===================================================== - # # TEST 9: DELETE OAUTH CLIENT - # # ===================================================== - # print("\n" + "=" * 60) - # print("TEST 9: delete() - Delete OAuth client") - # print("=" * 60) - - # if created_oauth_client: - # try: - # print(f"Deleting OAuth client: {created_oauth_client.id}") - - # # First, let's confirm it exists - # try: - # client.oauth_clients.read(created_oauth_client.id) - # print(" ✓ Confirmed OAuth client exists before deletion") - # except NotFound: - # print(" ⚠ OAuth client not found before deletion attempt") - - # # Delete the OAuth client - # client.oauth_clients.delete(created_oauth_client.id) - # print(f" ✓ Successfully deleted OAuth client: {created_oauth_client.id}") - - # # Verify deletion by trying to read it - # try: - # client.oauth_clients.read(created_oauth_client.id) - # print(" ⚠ Warning: OAuth client still exists after deletion") - # except NotFound: - # print(" ✓ Verification: OAuth client successfully deleted (not found)") - # except Exception as e: - # print(f" ? Verification error: {e}") - - # except Exception as e: - # print(f" ✗ Error deleting OAuth client: {e}") - # else: - # print(" ⚠ No OAuth client created to test delete()") + # ===================================================== + # TEST 7: ADD PROJECTS TO OAUTH CLIENT + # ===================================================== + print("\n" + "=" * 60) + print("TEST 7: add_projects() - Add projects to OAuth client") + print("=" * 60) + + if created_oauth_client and test_projects: + try: + print(f"Adding projects to OAuth client: {created_oauth_client.id}") + print(f"Projects to add: {[p['id'] for p in test_projects]}") + + add_options = OAuthClientAddProjectsOptions(projects=test_projects) + + client.oauth_clients.add_projects(created_oauth_client.id, add_options) + print( + f" ✓ Successfully added {len(test_projects)} projects to OAuth client" + ) + + # Verify the projects were added by reading the client with projects included + read_options = OAuthClientReadOptions( + include=[OAuthClientIncludeOpt.PROJECTS] + ) + updated_client = client.oauth_clients.read_with_options( + created_oauth_client.id, read_options + ) + print( + f" ✓ Verification: OAuth client now has {len(updated_client.projects or [])} projects" + ) + + except Exception as e: + print(f" ✗ Error adding projects to OAuth client: {e}") + else: + if not created_oauth_client: + print(" ⚠ No OAuth client created to test add_projects()") + if not test_projects: + print(" ⚠ No projects available to test add_projects()") + + # ===================================================== + # TEST 8: REMOVE PROJECTS FROM OAUTH CLIENT + # ===================================================== + print("\n" + "=" * 60) + print("TEST 8: remove_projects() - Remove projects from OAuth client") + print("=" * 60) + + if created_oauth_client and test_projects: + try: + print(f"Removing projects from OAuth client: {created_oauth_client.id}") + print(f"Projects to remove: {[p['id'] for p in test_projects]}") + + remove_options = OAuthClientRemoveProjectsOptions(projects=test_projects) + + client.oauth_clients.remove_projects( + created_oauth_client.id, remove_options + ) + print( + f" ✓ Successfully removed {len(test_projects)} projects from OAuth client" + ) + + # Verify the projects were removed by reading the client with projects included + read_options = OAuthClientReadOptions( + include=[OAuthClientIncludeOpt.PROJECTS] + ) + updated_client = client.oauth_clients.read_with_options( + created_oauth_client.id, read_options + ) + print( + f" ✓ Verification: OAuth client now has {len(updated_client.projects or [])} projects" + ) + + except Exception as e: + print(f" ✗ Error removing projects from OAuth client: {e}") + else: + if not created_oauth_client: + print(" ⚠ No OAuth client created to test remove_projects()") + if not test_projects: + print(" ⚠ No projects available to test remove_projects()") + + # ===================================================== + # TEST 9: DELETE OAUTH CLIENT + # ===================================================== + print("\n" + "=" * 60) + print("TEST 9: delete() - Delete OAuth client") + print("=" * 60) + + if created_oauth_client: + try: + print(f"Deleting OAuth client: {created_oauth_client.id}") + + # First, let's confirm it exists + try: + client.oauth_clients.read(created_oauth_client.id) + print(" ✓ Confirmed OAuth client exists before deletion") + except NotFound: + print(" ⚠ OAuth client not found before deletion attempt") + + # Delete the OAuth client + client.oauth_clients.delete(created_oauth_client.id) + print(f" ✓ Successfully deleted OAuth client: {created_oauth_client.id}") + + # Verify deletion by trying to read it + try: + client.oauth_clients.read(created_oauth_client.id) + print(" ⚠ Warning: OAuth client still exists after deletion") + except NotFound: + print( + " ✓ Verification: OAuth client successfully deleted (not found)" + ) + except Exception as e: + print(f" ? Verification error: {e}") + + except Exception as e: + print(f" ✗ Error deleting OAuth client: {e}") + else: + print(" ⚠ No OAuth client created to test delete()") # ===================================================== # SUMMARY diff --git a/src/tfe/models/__init__.py b/src/tfe/models/__init__.py index 904b798..53b68bf 100644 --- a/src/tfe/models/__init__.py +++ b/src/tfe/models/__init__.py @@ -65,6 +65,17 @@ # Define what should be available when importing with * __all__ = [ + # OAuth client types + "OAuthClient", + "OAuthClientAddProjectsOptions", + "OAuthClientCreateOptions", + "OAuthClientIncludeOpt", + "OAuthClientList", + "OAuthClientListOptions", + "OAuthClientReadOptions", + "OAuthClientRemoveProjectsOptions", + "OAuthClientUpdateOptions", + "ServiceProviderType", # Registry module types "AgentExecutionMode", "Commit", diff --git a/src/tfe/models/oauth_client.py b/src/tfe/models/oauth_client.py index 97e6d53..4a74ee4 100644 --- a/src/tfe/models/oauth_client.py +++ b/src/tfe/models/oauth_client.py @@ -2,14 +2,13 @@ from datetime import datetime from enum import Enum -from typing import Any from pydantic import BaseModel, ConfigDict, Field class ServiceProviderType(str, Enum): """VCS service provider types.""" - + AZURE_DEVOPS_SERVER = "ado_server" AZURE_DEVOPS_SERVICES = "ado_services" BITBUCKET_DATA_CENTER = "bitbucket_data_center" @@ -25,14 +24,14 @@ class ServiceProviderType(str, Enum): class OAuthClientIncludeOpt(str, Enum): """Include options for OAuth client queries.""" - + OAUTH_TOKENS = "oauth_tokens" PROJECTS = "projects" class OAuthClient(BaseModel): """OAuth client represents a connection between an organization and a VCS provider.""" - + id: str | None = None api_url: str | None = Field(None, alias="api-url") callback_url: str | None = Field(None, alias="callback-url") @@ -44,7 +43,9 @@ class OAuthClient(BaseModel): name: str | None = None secret: str | None = None service_provider: ServiceProviderType | None = Field(None, alias="service-provider") - service_provider_name: str | None = Field(None, alias="service-provider-display-name") + service_provider_name: str | None = Field( + None, alias="service-provider-display-name" + ) organization_scoped: bool | None = Field(None, alias="organization-scoped") # Relations @@ -58,18 +59,18 @@ class OAuthClient(BaseModel): class OAuthClientList(BaseModel): """List of OAuth clients with pagination.""" - + data: list[OAuthClient] = [] pagination: dict | None = None class OAuthClientListOptions(BaseModel): """Options for listing OAuth clients.""" - + # Pagination options page_number: int | None = Field(None, alias="page[number]") page_size: int | None = Field(None, alias="page[size]") - + # Include options include: list[OAuthClientIncludeOpt] | None = None @@ -78,7 +79,7 @@ class OAuthClientListOptions(BaseModel): class OAuthClientReadOptions(BaseModel): """Options for reading an OAuth client.""" - + include: list[OAuthClientIncludeOpt] | None = None model_config = ConfigDict(populate_by_name=True) @@ -86,41 +87,41 @@ class OAuthClientReadOptions(BaseModel): class OAuthClientCreateOptions(BaseModel): """Options for creating an OAuth client.""" - + # Display name for the OAuth Client name: str | None = None - + # Required: The base URL of your VCS provider's API api_url: str | None = Field(None, alias="api-url") - + # Required: The homepage of your VCS provider http_url: str | None = Field(None, alias="http-url") - + # Optional: The OAuth Client key key: str | None = None - + # Optional: The token string you were given by your VCS provider oauth_token: str | None = Field(None, alias="oauth-token-string") - + # Optional: The initial list of projects for which the oauth client should be associated with projects: list[dict] | None = None - + # Optional: Private key associated with this vcs provider - only available for ado_server private_key: str | None = Field(None, alias="private-key") - + # Optional: Secret key associated with this vcs provider - only available for ado_server secret: str | None = None - + # Optional: RSAPublicKey the text of the SSH public key associated with your # BitBucket Data Center Application Link rsa_public_key: str | None = Field(None, alias="rsa-public-key") - + # Required: The VCS provider being connected with service_provider: ServiceProviderType | None = Field(None, alias="service-provider") - + # Optional: AgentPool to associate the VCS Provider with, for PrivateVCS support agent_pool: dict | None = Field(None, alias="agent-pool") - + # Optional: Whether the OAuthClient is available to all workspaces in the organization organization_scoped: bool | None = Field(None, alias="organization-scoped") @@ -129,26 +130,26 @@ class OAuthClientCreateOptions(BaseModel): class OAuthClientUpdateOptions(BaseModel): """Options for updating an OAuth client.""" - + # Optional: A display name for the OAuth Client name: str | None = None - + # Optional: The OAuth Client key key: str | None = None - + # Optional: Secret key associated with this vcs provider - only available for ado_server secret: str | None = None - + # Optional: RSAPublicKey the text of the SSH public key associated with your BitBucket # Server Application Link rsa_public_key: str | None = Field(None, alias="rsa-public-key") - + # Optional: The token string you were given by your VCS provider oauth_token: str | None = Field(None, alias="oauth-token-string") - + # Optional: AgentPool to associate the VCS Provider with, for PrivateVCS support agent_pool: dict | None = Field(None, alias="agent-pool") - + # Optional: Whether the OAuthClient is available to all workspaces in the organization organization_scoped: bool | None = Field(None, alias="organization-scoped") @@ -157,7 +158,7 @@ class OAuthClientUpdateOptions(BaseModel): class OAuthClientAddProjectsOptions(BaseModel): """Options for adding projects to an OAuth client.""" - + # The projects to add to an OAuth client projects: list[dict] @@ -166,7 +167,7 @@ class OAuthClientAddProjectsOptions(BaseModel): class OAuthClientRemoveProjectsOptions(BaseModel): """Options for removing projects from an OAuth client.""" - + # The projects to remove from an OAuth client projects: list[dict] diff --git a/src/tfe/resources/oauth_client.py b/src/tfe/resources/oauth_client.py index 25a6e19..f41626e 100644 --- a/src/tfe/resources/oauth_client.py +++ b/src/tfe/resources/oauth_client.py @@ -9,7 +9,6 @@ OAuthClient, OAuthClientAddProjectsOptions, OAuthClientCreateOptions, - OAuthClientList, OAuthClientListOptions, OAuthClientReadOptions, OAuthClientRemoveProjectsOptions, @@ -60,7 +59,7 @@ def create( validate_oauth_client_create_options(options) - body = { + body: dict[str, Any] = { "data": { "type": "oauth-clients", "attributes": options.model_dump(exclude_none=True, by_alias=True), @@ -69,10 +68,8 @@ def create( # Handle relations separately if options.projects: - body["data"]["relationships"] = { - "projects": {"data": options.projects} - } - + body["data"]["relationships"] = {"projects": {"data": options.projects}} + if options.agent_pool: if "relationships" not in body["data"]: body["data"]["relationships"] = {} @@ -122,9 +119,7 @@ def update( # Handle relations separately if options.agent_pool: - body["data"]["relationships"] = { - "agent-pool": {"data": options.agent_pool} - } + body["data"]["relationships"] = {"agent-pool": {"data": options.agent_pool}} path = f"/api/v2/oauth-clients/{quote(oauth_client_id)}" response = self.t.request("PATCH", path, json_body=body) @@ -173,16 +168,16 @@ def _parse_oauth_client(self, data: dict[str, Any]) -> OAuthClient: # Handle relationships relationships = data.get("relationships", {}) - + if "organization" in relationships: oauth_client.organization = relationships["organization"].get("data") - + if "oauth-tokens" in relationships: oauth_client.oauth_tokens = relationships["oauth-tokens"].get("data", []) - + if "agent-pool" in relationships: oauth_client.agent_pool = relationships["agent-pool"].get("data") - + if "projects" in relationships: oauth_client.projects = relationships["projects"].get("data", []) diff --git a/src/tfe/utils.py b/src/tfe/utils.py index a12c7a4..e8c4642 100644 --- a/src/tfe/utils.py +++ b/src/tfe/utils.py @@ -3,7 +3,14 @@ import re import time from collections.abc import Callable, Mapping -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .models.oauth_client import ( + OAuthClientAddProjectsOptions, + OAuthClientCreateOptions, + OAuthClientRemoveProjectsOptions, + ) from .errors import ( InvalidNameError, @@ -204,7 +211,7 @@ def valid_oauth_client_id(v: str | None) -> bool: return valid_string_id(v) -def validate_oauth_client_create_options(options) -> None: +def validate_oauth_client_create_options(options: OAuthClientCreateOptions) -> None: """ Validate OAuth client create options similar to Go implementation. Raises specific validation errors if validation fails. @@ -217,51 +224,59 @@ def validate_oauth_client_create_options(options) -> None: ERR_UNSUPPORTED_PRIVATE_KEY, ) from .models.oauth_client import ServiceProviderType - + if not valid_string(options.api_url): raise ValueError(ERR_REQUIRED_API_URL) - + if not valid_string(options.http_url): raise ValueError(ERR_REQUIRED_HTTP_URL) - + if options.service_provider is None: raise ValueError(ERR_REQUIRED_SERVICE_PROVIDER) - + # OAuth token not required for Bitbucket Server and Data Center - if (not valid_string(options.oauth_token) and - options.service_provider != ServiceProviderType.BITBUCKET_SERVER and - options.service_provider != ServiceProviderType.BITBUCKET_DATA_CENTER): + if ( + not valid_string(options.oauth_token) + and options.service_provider != ServiceProviderType.BITBUCKET_SERVER + and options.service_provider != ServiceProviderType.BITBUCKET_DATA_CENTER + ): raise ValueError(ERR_REQUIRED_OAUTH_TOKEN) - + # Private key only supported for Azure DevOps Server - if (valid_string(options.private_key) and - options.service_provider != ServiceProviderType.AZURE_DEVOPS_SERVER): + if ( + valid_string(options.private_key) + and options.service_provider != ServiceProviderType.AZURE_DEVOPS_SERVER + ): raise ValueError(ERR_UNSUPPORTED_PRIVATE_KEY) -def validate_oauth_client_add_projects_options(options) -> None: +def validate_oauth_client_add_projects_options( + options: OAuthClientAddProjectsOptions, +) -> None: """ Validate OAuth client add projects options. Raises specific validation errors if validation fails. """ - from .errors import ERR_REQUIRED_PROJECT, ERR_PROJECT_MIN_LIMIT - + from .errors import ERR_PROJECT_MIN_LIMIT, ERR_REQUIRED_PROJECT + if options.projects is None: raise ValueError(ERR_REQUIRED_PROJECT) - + if len(options.projects) == 0: raise ValueError(ERR_PROJECT_MIN_LIMIT) -def validate_oauth_client_remove_projects_options(options) -> None: +def validate_oauth_client_remove_projects_options( + options: OAuthClientRemoveProjectsOptions, +) -> None: """ Validate OAuth client remove projects options. Raises specific validation errors if validation fails. """ - from .errors import ERR_REQUIRED_PROJECT, ERR_PROJECT_MIN_LIMIT - + from .errors import ERR_PROJECT_MIN_LIMIT, ERR_REQUIRED_PROJECT + if options.projects is None: raise ValueError(ERR_REQUIRED_PROJECT) - + if len(options.projects) == 0: raise ValueError(ERR_PROJECT_MIN_LIMIT) diff --git a/tests/units/test_oauth_client.py b/tests/units/test_oauth_client.py new file mode 100644 index 0000000..14820ee --- /dev/null +++ b/tests/units/test_oauth_client.py @@ -0,0 +1,604 @@ +""" +Comprehensive unit tests for OAuth client operations in the Python TFE SDK. + +This test suite covers all OAuth client methods including CRUD operations, +project management, and validation. +""" + +from unittest.mock import Mock, patch + +import pytest + +from src.tfe._http import HTTPTransport +from src.tfe.errors import ( + ERR_INVALID_OAUTH_CLIENT_ID, + ERR_INVALID_ORG, +) +from src.tfe.models.oauth_client import ( + OAuthClientAddProjectsOptions, + OAuthClientCreateOptions, + OAuthClientIncludeOpt, + OAuthClientListOptions, + OAuthClientReadOptions, + OAuthClientRemoveProjectsOptions, + OAuthClientUpdateOptions, + ServiceProviderType, +) +from src.tfe.resources.oauth_client import OAuthClients + + +class TestOAuthClientParsing: + """Test the OAuth client parsing functionality.""" + + @pytest.fixture + def oauth_clients_service(self): + """Create an OAuthClients service for testing parsing.""" + mock_transport = Mock(spec=HTTPTransport) + return OAuthClients(mock_transport) + + def test_parse_oauth_client_minimal(self, oauth_clients_service): + """Test _parse_oauth_client with minimal data.""" + data = { + "id": "oc-test123", + "attributes": { + "name": "Test OAuth Client", + "service-provider": "github", + }, + } + + result = oauth_clients_service._parse_oauth_client(data) + + assert result.id == "oc-test123" + assert result.name == "Test OAuth Client" + assert result.service_provider == ServiceProviderType.GITHUB + assert result.api_url is None + assert result.callback_url is None + assert result.connect_path is None + assert result.created_at is None + assert result.oauth_tokens is None + assert result.projects is None + + def test_parse_oauth_client_comprehensive(self, oauth_clients_service): + """Test _parse_oauth_client with comprehensive data.""" + data = { + "id": "oc-test123", + "attributes": { + "name": "Test GitHub Client", + "api-url": "https://api.github.com", + "callback-url": "https://app.terraform.io/auth/callback", + "connect-path": "/auth/connect", + "created-at": "2023-10-02T10:30:00.000Z", + "http-url": "https://github.com", + "key": "test-key", + "rsa-public-key": "ssh-rsa AAAAB3...", + "secret": "test-secret", + "service-provider": "github", + "service-provider-display-name": "GitHub", + "organization-scoped": True, + }, + "relationships": { + "oauth-tokens": { + "data": [ + {"id": "ot-token1", "type": "oauth-tokens"}, + {"id": "ot-token2", "type": "oauth-tokens"}, + ] + }, + "projects": { + "data": [ + {"id": "prj-proj1", "type": "projects"}, + {"id": "prj-proj2", "type": "projects"}, + ] + }, + }, + } + + result = oauth_clients_service._parse_oauth_client(data) + + assert result.id == "oc-test123" + assert result.name == "Test GitHub Client" + assert result.api_url == "https://api.github.com" + assert result.callback_url == "https://app.terraform.io/auth/callback" + assert result.connect_path == "/auth/connect" + # Note: datetime parsing may need adjustment based on actual implementation + assert result.http_url == "https://github.com" + assert result.key == "test-key" + assert result.rsa_public_key == "ssh-rsa AAAAB3..." + assert result.secret == "test-secret" + assert result.service_provider == ServiceProviderType.GITHUB + assert result.service_provider_name == "GitHub" + assert result.organization_scoped is True + assert len(result.oauth_tokens) == 2 + assert result.oauth_tokens[0]["id"] == "ot-token1" + assert result.oauth_tokens[1]["id"] == "ot-token2" + assert len(result.projects) == 2 + assert result.projects[0]["id"] == "prj-proj1" + assert result.projects[1]["id"] == "prj-proj2" + + def test_parse_oauth_client_empty_relationships(self, oauth_clients_service): + """Test _parse_oauth_client with empty relationships.""" + data = { + "id": "oc-test123", + "attributes": { + "name": "Test Client", + "service-provider": "gitlab_hosted", + }, + "relationships": { + "oauth-tokens": {"data": []}, + "projects": {"data": []}, + }, + } + + result = oauth_clients_service._parse_oauth_client(data) + + assert result.id == "oc-test123" + assert result.name == "Test Client" + assert result.service_provider == ServiceProviderType.GITLAB_HOSTED + assert result.oauth_tokens == [] + assert result.projects == [] + + def test_parse_oauth_client_no_relationships(self, oauth_clients_service): + """Test _parse_oauth_client with no relationships section.""" + data = { + "id": "oc-test123", + "attributes": { + "name": "Test Client", + "service-provider": "bitbucket_hosted", + "organization-scoped": False, + }, + } + + result = oauth_clients_service._parse_oauth_client(data) + + assert result.id == "oc-test123" + assert result.name == "Test Client" + assert result.service_provider == ServiceProviderType.BITBUCKET_HOSTED + assert result.organization_scoped is False + assert result.oauth_tokens is None + assert result.projects is None + + +class TestOAuthClients: + """Test the OAuthClients service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def oauth_clients_service(self, mock_transport): + """Create an OAuthClients service with mocked transport.""" + return OAuthClients(mock_transport) + + def test_list_oauth_clients_basic(self, oauth_clients_service, mock_transport): + """Test listing OAuth clients without options.""" + # Mock response data + mock_response_data = { + "data": [ + { + "id": "oc-test1", + "attributes": { + "name": "GitHub Client 1", + "service-provider": "github", + }, + }, + { + "id": "oc-test2", + "attributes": { + "name": "GitLab Client 1", + "service-provider": "gitlab_hosted", + }, + }, + ] + } + + with patch.object(oauth_clients_service, "_list") as mock_list: + mock_list.return_value = mock_response_data["data"] + + result = list(oauth_clients_service.list("test-org")) + + assert len(result) == 2 + assert result[0].id == "oc-test1" + assert result[0].name == "GitHub Client 1" + assert result[0].service_provider == ServiceProviderType.GITHUB + assert result[1].id == "oc-test2" + assert result[1].name == "GitLab Client 1" + assert result[1].service_provider == ServiceProviderType.GITLAB_HOSTED + + mock_list.assert_called_once_with( + "/api/v2/organizations/test-org/oauth-clients", params={} + ) + + def test_list_oauth_clients_with_options( + self, oauth_clients_service, mock_transport + ): + """Test listing OAuth clients with options.""" + options = OAuthClientListOptions( + page_number=2, + page_size=50, + include=[ + OAuthClientIncludeOpt.OAUTH_TOKENS, + OAuthClientIncludeOpt.PROJECTS, + ], + ) + + with patch.object(oauth_clients_service, "_list") as mock_list: + mock_list.return_value = [] + + list(oauth_clients_service.list("test-org", options)) + + expected_params = { + "page[number]": "2", + "page[size]": "50", + "include": "oauth_tokens,projects", + } + mock_list.assert_called_once_with( + "/api/v2/organizations/test-org/oauth-clients", params=expected_params + ) + + def test_list_oauth_clients_invalid_org(self, oauth_clients_service): + """Test listing OAuth clients with invalid organization.""" + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + list(oauth_clients_service.list("")) + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + list(oauth_clients_service.list(None)) + + def test_create_oauth_client_success(self, oauth_clients_service, mock_transport): + """Test creating an OAuth client successfully.""" + create_options = OAuthClientCreateOptions( + name="Test GitHub Client", + api_url="https://api.github.com", + http_url="https://github.com", + oauth_token="ghp_test_token", + service_provider=ServiceProviderType.GITHUB, + organization_scoped=True, + ) + + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "oc-created", + "attributes": { + "name": "Test GitHub Client", + "api-url": "https://api.github.com", + "http-url": "https://github.com", + "service-provider": "github", + "organization-scoped": True, + }, + } + } + mock_transport.request.return_value = mock_response + + result = oauth_clients_service.create("test-org", create_options) + + assert result.id == "oc-created" + assert result.name == "Test GitHub Client" + assert result.api_url == "https://api.github.com" + assert result.http_url == "https://github.com" + assert result.service_provider == ServiceProviderType.GITHUB + assert result.organization_scoped is True + + # Verify the request was made correctly + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "POST" + assert call_args[0][1] == "/api/v2/organizations/test-org/oauth-clients" + + def test_create_oauth_client_with_projects( + self, oauth_clients_service, mock_transport + ): + """Test creating an OAuth client with projects.""" + create_options = OAuthClientCreateOptions( + name="Test Client with Projects", + api_url="https://api.github.com", + http_url="https://github.com", + oauth_token="ghp_test_token", + service_provider=ServiceProviderType.GITHUB, + projects=[{"type": "projects", "id": "prj-test1"}], + ) + + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "oc-with-projects", + "attributes": { + "name": "Test Client with Projects", + "service-provider": "github", + }, + } + } + mock_transport.request.return_value = mock_response + + result = oauth_clients_service.create("test-org", create_options) + + assert result.id == "oc-with-projects" + assert result.name == "Test Client with Projects" + + # Verify the request included projects in relationships + call_args = mock_transport.request.call_args + json_body = call_args[1]["json_body"] + assert "relationships" in json_body["data"] + assert "projects" in json_body["data"]["relationships"] + assert json_body["data"]["relationships"]["projects"]["data"] == [ + {"type": "projects", "id": "prj-test1"} + ] + + def test_create_oauth_client_invalid_org(self, oauth_clients_service): + """Test creating OAuth client with invalid organization.""" + create_options = OAuthClientCreateOptions( + name="Test", + api_url="https://api.github.com", + http_url="https://github.com", + oauth_token="token", + service_provider=ServiceProviderType.GITHUB, + ) + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + oauth_clients_service.create("", create_options) + + def test_read_oauth_client_success(self, oauth_clients_service, mock_transport): + """Test reading an OAuth client successfully.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "oc-test123", + "attributes": { + "name": "Test OAuth Client", + "service-provider": "github", + "created-at": "2023-10-02T10:30:00.000Z", + }, + } + } + mock_transport.request.return_value = mock_response + + result = oauth_clients_service.read("oc-test123") + + assert result.id == "oc-test123" + assert result.name == "Test OAuth Client" + assert result.service_provider == ServiceProviderType.GITHUB + assert result.created_at is not None + assert result.created_at.year == 2023 + assert result.created_at.month == 10 + assert result.created_at.day == 2 + + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/oauth-clients/oc-test123", params={} + ) + + def test_read_oauth_client_with_options( + self, oauth_clients_service, mock_transport + ): + """Test reading an OAuth client with options.""" + read_options = OAuthClientReadOptions( + include=[OAuthClientIncludeOpt.OAUTH_TOKENS, OAuthClientIncludeOpt.PROJECTS] + ) + + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "oc-test123", + "attributes": { + "name": "Test OAuth Client", + "service-provider": "github", + }, + "relationships": { + "oauth-tokens": { + "data": [{"id": "ot-token1", "type": "oauth-tokens"}] + }, + "projects": {"data": [{"id": "prj-proj1", "type": "projects"}]}, + }, + } + } + mock_transport.request.return_value = mock_response + + result = oauth_clients_service.read_with_options("oc-test123", read_options) + + assert result.id == "oc-test123" + assert result.name == "Test OAuth Client" + assert len(result.oauth_tokens) == 1 + assert result.oauth_tokens[0]["id"] == "ot-token1" + assert len(result.projects) == 1 + assert result.projects[0]["id"] == "prj-proj1" + + expected_params = {"include": "oauth_tokens,projects"} + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/oauth-clients/oc-test123", params=expected_params + ) + + def test_read_oauth_client_invalid_id(self, oauth_clients_service): + """Test reading OAuth client with invalid ID.""" + with pytest.raises(ValueError, match=ERR_INVALID_OAUTH_CLIENT_ID): + oauth_clients_service.read("") + + with pytest.raises(ValueError, match=ERR_INVALID_OAUTH_CLIENT_ID): + oauth_clients_service.read_with_options("", None) + + def test_update_oauth_client_success(self, oauth_clients_service, mock_transport): + """Test updating an OAuth client successfully.""" + update_options = OAuthClientUpdateOptions( + name="Updated OAuth Client", + organization_scoped=False, + ) + + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "oc-test123", + "attributes": { + "name": "Updated OAuth Client", + "service-provider": "github", + "organization-scoped": False, + }, + } + } + mock_transport.request.return_value = mock_response + + result = oauth_clients_service.update("oc-test123", update_options) + + assert result.id == "oc-test123" + assert result.name == "Updated OAuth Client" + assert result.organization_scoped is False + + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "PATCH" + assert call_args[0][1] == "/api/v2/oauth-clients/oc-test123" + + def test_update_oauth_client_invalid_id(self, oauth_clients_service): + """Test updating OAuth client with invalid ID.""" + update_options = OAuthClientUpdateOptions(name="Test") + + with pytest.raises(ValueError, match=ERR_INVALID_OAUTH_CLIENT_ID): + oauth_clients_service.update("", update_options) + + def test_delete_oauth_client_success(self, oauth_clients_service, mock_transport): + """Test deleting an OAuth client successfully.""" + oauth_clients_service.delete("oc-test123") + + mock_transport.request.assert_called_once_with( + "DELETE", "/api/v2/oauth-clients/oc-test123" + ) + + def test_delete_oauth_client_invalid_id(self, oauth_clients_service): + """Test deleting OAuth client with invalid ID.""" + with pytest.raises(ValueError, match=ERR_INVALID_OAUTH_CLIENT_ID): + oauth_clients_service.delete("") + + def test_add_projects_success(self, oauth_clients_service, mock_transport): + """Test adding projects to an OAuth client successfully.""" + add_options = OAuthClientAddProjectsOptions( + projects=[ + {"type": "projects", "id": "prj-test1"}, + {"type": "projects", "id": "prj-test2"}, + ] + ) + + oauth_clients_service.add_projects("oc-test123", add_options) + + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "POST" + assert ( + call_args[0][1] == "/api/v2/oauth-clients/oc-test123/relationships/projects" + ) + + json_body = call_args[1]["json_body"] + assert json_body["data"] == [ + {"type": "projects", "id": "prj-test1"}, + {"type": "projects", "id": "prj-test2"}, + ] + + def test_add_projects_invalid_id(self, oauth_clients_service): + """Test adding projects with invalid OAuth client ID.""" + add_options = OAuthClientAddProjectsOptions( + projects=[{"type": "projects", "id": "prj-test1"}] + ) + + with pytest.raises(ValueError, match=ERR_INVALID_OAUTH_CLIENT_ID): + oauth_clients_service.add_projects("", add_options) + + def test_remove_projects_success(self, oauth_clients_service, mock_transport): + """Test removing projects from an OAuth client successfully.""" + remove_options = OAuthClientRemoveProjectsOptions( + projects=[ + {"type": "projects", "id": "prj-test1"}, + {"type": "projects", "id": "prj-test2"}, + ] + ) + + oauth_clients_service.remove_projects("oc-test123", remove_options) + + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "DELETE" + assert ( + call_args[0][1] == "/api/v2/oauth-clients/oc-test123/relationships/projects" + ) + + json_body = call_args[1]["json_body"] + assert json_body["data"] == [ + {"type": "projects", "id": "prj-test1"}, + {"type": "projects", "id": "prj-test2"}, + ] + + def test_remove_projects_invalid_id(self, oauth_clients_service): + """Test removing projects with invalid OAuth client ID.""" + remove_options = OAuthClientRemoveProjectsOptions( + projects=[{"type": "projects", "id": "prj-test1"}] + ) + + with pytest.raises(ValueError, match=ERR_INVALID_OAUTH_CLIENT_ID): + oauth_clients_service.remove_projects("", remove_options) + + +class TestOAuthClientValidation: + """Test OAuth client validation functions.""" + + def test_oauth_client_create_options_validation(self): + """Test validation of OAuthClientCreateOptions.""" + # Valid options + valid_options = OAuthClientCreateOptions( + name="Test Client", + api_url="https://api.github.com", + http_url="https://github.com", + oauth_token="ghp_test_token", + service_provider=ServiceProviderType.GITHUB, + ) + assert valid_options.name == "Test Client" + assert valid_options.api_url == "https://api.github.com" + assert valid_options.service_provider == ServiceProviderType.GITHUB + + # Test various service provider types + for provider in ServiceProviderType: + options = OAuthClientCreateOptions( + name="Test", + api_url="https://api.example.com", + http_url="https://example.com", + oauth_token="token", + service_provider=provider, + ) + assert options.service_provider == provider + + def test_oauth_client_update_options(self): + """Test OAuthClientUpdateOptions.""" + update_options = OAuthClientUpdateOptions( + name="Updated Name", + organization_scoped=True, + ) + assert update_options.name == "Updated Name" + assert update_options.organization_scoped is True + + # Test with minimal options + minimal_options = OAuthClientUpdateOptions() + assert minimal_options.name is None + assert minimal_options.organization_scoped is None + + def test_oauth_client_project_options(self): + """Test project-related options.""" + projects = [ + {"type": "projects", "id": "prj-test1"}, + {"type": "projects", "id": "prj-test2"}, + ] + + add_options = OAuthClientAddProjectsOptions(projects=projects) + assert len(add_options.projects) == 2 + assert add_options.projects[0]["id"] == "prj-test1" + + remove_options = OAuthClientRemoveProjectsOptions(projects=projects) + assert len(remove_options.projects) == 2 + assert remove_options.projects[1]["id"] == "prj-test2" + + def test_oauth_client_include_options(self): + """Test include options.""" + list_options = OAuthClientListOptions( + include=[OAuthClientIncludeOpt.OAUTH_TOKENS] + ) + assert OAuthClientIncludeOpt.OAUTH_TOKENS in list_options.include + + read_options = OAuthClientReadOptions( + include=[OAuthClientIncludeOpt.PROJECTS, OAuthClientIncludeOpt.OAUTH_TOKENS] + ) + assert len(read_options.include) == 2 + assert OAuthClientIncludeOpt.PROJECTS in read_options.include + assert OAuthClientIncludeOpt.OAUTH_TOKENS in read_options.include From e635ea1c1871f459e7b8c1803b9f9ac2781a436e Mon Sep 17 00:00:00 2001 From: aayushsingh2502 Date: Fri, 3 Oct 2025 09:49:33 +0530 Subject: [PATCH 3/5] lint issue fix --- src/tfe/client.py | 2 +- src/tfe/models/__init__.py | 28 ++++++++++++++-------------- src/tfe/utils.py | 2 +- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/tfe/client.py b/src/tfe/client.py index b39574c..7122448 100644 --- a/src/tfe/client.py +++ b/src/tfe/client.py @@ -2,11 +2,11 @@ from ._http import HTTPTransport from .config import TFEConfig -from .resources.oauth_client import OAuthClients from .resources.agent_pools import AgentPools from .resources.agents import Agents, AgentTokens from .resources.apply import Applies from .resources.configuration_version import ConfigurationVersions +from .resources.oauth_client import OAuthClients from .resources.organizations import Organizations from .resources.plan import Plans from .resources.projects import Projects diff --git a/src/tfe/models/__init__.py b/src/tfe/models/__init__.py index 4ee3317..3d25df1 100644 --- a/src/tfe/models/__init__.py +++ b/src/tfe/models/__init__.py @@ -4,20 +4,6 @@ import importlib.util import os -# Re-export all OAuth client types -from .oauth_client import ( - OAuthClient, - OAuthClientAddProjectsOptions, - OAuthClientCreateOptions, - OAuthClientIncludeOpt, - OAuthClientList, - OAuthClientListOptions, - OAuthClientReadOptions, - OAuthClientRemoveProjectsOptions, - OAuthClientUpdateOptions, - ServiceProviderType, -) - # Re-export all agent and agent pool types from .agent import ( Agent, @@ -51,6 +37,20 @@ IngressAttributes, ) +# Re-export all OAuth client types +from .oauth_client import ( + OAuthClient, + OAuthClientAddProjectsOptions, + OAuthClientCreateOptions, + OAuthClientIncludeOpt, + OAuthClientList, + OAuthClientListOptions, + OAuthClientReadOptions, + OAuthClientRemoveProjectsOptions, + OAuthClientUpdateOptions, + ServiceProviderType, +) + # Re-export all query run types from .query_run import ( QueryRun, diff --git a/src/tfe/utils.py b/src/tfe/utils.py index d739224..3aad3b8 100644 --- a/src/tfe/utils.py +++ b/src/tfe/utils.py @@ -12,7 +12,7 @@ OAuthClientCreateOptions, OAuthClientRemoveProjectsOptions, ) -from typing import Any + from urllib.parse import urlparse try: From 0ed56da85b4a53060c75936a0b86ebd8a92b50c8 Mon Sep 17 00:00:00 2001 From: aayushsingh2502 Date: Fri, 3 Oct 2025 09:56:45 +0530 Subject: [PATCH 4/5] lint fix --- src/tfe/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tfe/utils.py b/src/tfe/utils.py index 3aad3b8..0d03236 100644 --- a/src/tfe/utils.py +++ b/src/tfe/utils.py @@ -288,6 +288,8 @@ def validate_oauth_client_remove_projects_options( if len(options.projects) == 0: raise ValueError(ERR_PROJECT_MIN_LIMIT) + + def pack_contents(path: str) -> io.BytesIO: """ Pack directory contents into a tar.gz archive suitable for upload. From e468fd97befe4a7ec6b8cf917a28828e8adc72e4 Mon Sep 17 00:00:00 2001 From: aayushsingh2502 Date: Fri, 3 Oct 2025 14:18:05 +0530 Subject: [PATCH 5/5] redundant function fix --- src/tfe/resources/oauth_client.py | 11 +++++------ src/tfe/utils.py | 5 ----- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/tfe/resources/oauth_client.py b/src/tfe/resources/oauth_client.py index f41626e..2321785 100644 --- a/src/tfe/resources/oauth_client.py +++ b/src/tfe/resources/oauth_client.py @@ -15,7 +15,6 @@ OAuthClientUpdateOptions, ) from ..utils import ( - valid_oauth_client_id, valid_string_id, validate_oauth_client_add_projects_options, validate_oauth_client_create_options, @@ -89,7 +88,7 @@ def read_with_options( self, oauth_client_id: str, options: OAuthClientReadOptions | None ) -> OAuthClient: """Read an OAuth client by its ID with options.""" - if not valid_oauth_client_id(oauth_client_id): + if not valid_string_id(oauth_client_id): raise ValueError(ERR_INVALID_OAUTH_CLIENT_ID) path = f"/api/v2/oauth-clients/{quote(oauth_client_id)}" @@ -107,7 +106,7 @@ def update( self, oauth_client_id: str, options: OAuthClientUpdateOptions ) -> OAuthClient: """Update an OAuth client by its ID.""" - if not valid_oauth_client_id(oauth_client_id): + if not valid_string_id(oauth_client_id): raise ValueError(ERR_INVALID_OAUTH_CLIENT_ID) body = { @@ -129,7 +128,7 @@ def update( def delete(self, oauth_client_id: str) -> None: """Delete an OAuth client by its ID.""" - if not valid_oauth_client_id(oauth_client_id): + if not valid_string_id(oauth_client_id): raise ValueError(ERR_INVALID_OAUTH_CLIENT_ID) path = f"/api/v2/oauth-clients/{quote(oauth_client_id)}" @@ -139,7 +138,7 @@ def add_projects( self, oauth_client_id: str, options: OAuthClientAddProjectsOptions ) -> None: """Add projects to a given OAuth client.""" - if not valid_oauth_client_id(oauth_client_id): + if not valid_string_id(oauth_client_id): raise ValueError(ERR_INVALID_OAUTH_CLIENT_ID) validate_oauth_client_add_projects_options(options) @@ -151,7 +150,7 @@ def remove_projects( self, oauth_client_id: str, options: OAuthClientRemoveProjectsOptions ) -> None: """Remove projects from an OAuth client.""" - if not valid_oauth_client_id(oauth_client_id): + if not valid_string_id(oauth_client_id): raise ValueError(ERR_INVALID_OAUTH_CLIENT_ID) validate_oauth_client_remove_projects_options(options) diff --git a/src/tfe/utils.py b/src/tfe/utils.py index 0d03236..4b48fde 100644 --- a/src/tfe/utils.py +++ b/src/tfe/utils.py @@ -214,11 +214,6 @@ def validate_workspace_update_options(options: WorkspaceUpdateOptions) -> None: raise UnsupportedBothTagsRegexAndFileTriggersEnabledError() -def valid_oauth_client_id(v: str | None) -> bool: - """Validate OAuth client ID format.""" - return valid_string_id(v) - - def validate_oauth_client_create_options(options: OAuthClientCreateOptions) -> None: """ Validate OAuth client create options similar to Go implementation.