From 59b572e444e006a3a912983f30f5cc361c502df3 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:32:28 -0600 Subject: [PATCH 1/9] Add the rest of the Audit Logs implementation --- src/workos/async_client.py | 10 +- src/workos/audit_logs.py | 458 +++++++++++++++++- src/workos/types/audit_logs/__init__.py | 5 + .../types/audit_logs/audit_log_action.py | 23 + .../audit_logs/audit_log_configuration.py | 41 ++ .../types/audit_logs/audit_log_retention.py | 11 + .../types/audit_logs/audit_log_schema.py | 49 ++ src/workos/types/audit_logs/list_filters.py | 13 + src/workos/types/list_resource.py | 3 + tests/test_audit_logs.py | 416 ++++++++++++++++ 10 files changed, 1014 insertions(+), 15 deletions(-) create mode 100644 src/workos/types/audit_logs/audit_log_action.py create mode 100644 src/workos/types/audit_logs/audit_log_configuration.py create mode 100644 src/workos/types/audit_logs/audit_log_retention.py create mode 100644 src/workos/types/audit_logs/audit_log_schema.py create mode 100644 src/workos/types/audit_logs/list_filters.py diff --git a/src/workos/async_client.py b/src/workos/async_client.py index 38bf13fb..11cec994 100644 --- a/src/workos/async_client.py +++ b/src/workos/async_client.py @@ -2,7 +2,7 @@ from importlib.metadata import version from workos._base_client import BaseClient from workos.api_keys import AsyncApiKeys -from workos.audit_logs import AuditLogsModule +from workos.audit_logs import AsyncAuditLogs from workos.directory_sync import AsyncDirectorySync from workos.events import AsyncEvents from workos.fga import FGAModule @@ -64,10 +64,10 @@ def sso(self) -> AsyncSSO: return self._sso @property - def audit_logs(self) -> AuditLogsModule: - raise NotImplementedError( - "Audit logs APIs are not yet supported in the async client." - ) + def audit_logs(self) -> AsyncAuditLogs: + if not getattr(self, "_audit_logs", None): + self._audit_logs = AsyncAuditLogs(self._http_client) + return self._audit_logs @property def directory_sync(self) -> AsyncDirectorySync: diff --git a/src/workos/audit_logs.py b/src/workos/audit_logs.py index 73bacff8..b3e69d16 100644 --- a/src/workos/audit_logs.py +++ b/src/workos/audit_logs.py @@ -1,12 +1,38 @@ -from typing import Optional, Protocol, Sequence +from typing import Any, Dict, Literal, Mapping, Optional, Protocol, Sequence -from workos.types.audit_logs import AuditLogExport +from workos.types.audit_logs import ( + AuditLogAction, + AuditLogConfiguration, + AuditLogExport, + AuditLogRetention, + AuditLogSchema, + AuditLogSchemaListFilters, + AuditLogActionListFilters, +) from workos.types.audit_logs.audit_log_event import AuditLogEvent -from workos.utils.http_client import SyncHTTPClient -from workos.utils.request_helper import REQUEST_METHOD_GET, REQUEST_METHOD_POST +from workos.types.list_resource import ListMetadata, ListPage, WorkOSListResource +from workos.typing.sync_or_async import SyncOrAsync +from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient +from workos.utils.pagination_order import PaginationOrder +from workos.utils.request_helper import ( + DEFAULT_LIST_RESPONSE_LIMIT, + REQUEST_METHOD_GET, + REQUEST_METHOD_POST, + REQUEST_METHOD_PUT, +) EVENTS_PATH = "audit_logs/events" EXPORTS_PATH = "audit_logs/exports" +ACTIONS_PATH = "audit_logs/actions" + + +AuditLogActionsListResource = WorkOSListResource[ + AuditLogAction, AuditLogActionListFilters, ListMetadata +] + +AuditLogSchemasListResource = WorkOSListResource[ + AuditLogSchema, AuditLogSchemaListFilters, ListMetadata +] class AuditLogsModule(Protocol): @@ -18,7 +44,7 @@ def create_event( organization_id: str, event: AuditLogEvent, idempotency_key: Optional[str] = None, - ) -> None: + ) -> SyncOrAsync[None]: """Create an Audit Logs event. Kwargs: @@ -40,7 +66,7 @@ def create_export( targets: Optional[Sequence[str]] = None, actor_names: Optional[Sequence[str]] = None, actor_ids: Optional[Sequence[str]] = None, - ) -> AuditLogExport: + ) -> SyncOrAsync[AuditLogExport]: """Trigger the creation of an export of audit logs. Kwargs: @@ -49,6 +75,7 @@ def create_export( range_end (str): End date of the date range filter. actions (list): Optional list of actions to filter. (Optional) actor_names (list): Optional list of actors to filter by name. (Optional) + actor_ids (list): Optional list of actors to filter by ID. (Optional) targets (list): Optional list of targets to filter. (Optional) Returns: @@ -56,15 +83,125 @@ def create_export( """ ... - def get_export(self, audit_log_export_id: str) -> AuditLogExport: - """Retrieve an created export. + def get_export(self, audit_log_export_id: str) -> SyncOrAsync[AuditLogExport]: + """Retrieve a created export. + Args: audit_log_export_id (str): Audit log export unique identifier. + Returns: AuditLogExport: Object that describes the audit log export """ ... + def create_schema( + self, + *, + action: str, + targets: Sequence[Mapping[str, Any]], + actor: Optional[Mapping[str, Any]] = None, + metadata: Optional[Mapping[str, Any]] = None, + idempotency_key: Optional[str] = None, + ) -> SyncOrAsync[AuditLogSchema]: + """Create an Audit Log schema for an action. + + Kwargs: + action (str): The action name for the schema (e.g., 'user.signed_in'). + targets (list): List of target definitions with type and optional metadata. + actor (dict): Optional actor definition with metadata schema. (Optional) + metadata (dict): Optional event-level metadata schema. (Optional) + idempotency_key (str): Idempotency key. (Optional) + + Returns: + AuditLogSchema: The created audit log schema + """ + ... + + def list_schemas( + self, + *, + action: str, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> SyncOrAsync[AuditLogSchemasListResource]: + """List all schemas for an Audit Log action. + + Kwargs: + action (str): The action name to list schemas for. + limit (int): Maximum number of records to return. (Optional) + before (str): Pagination cursor to receive records before a provided ID. (Optional) + after (str): Pagination cursor to receive records after a provided ID. (Optional) + order (Literal["asc","desc"]): Sort order by created_at timestamp. (Optional) + + Returns: + AuditLogSchemasListResource: Paginated list of audit log schemas + """ + ... + + def list_actions( + self, + *, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> SyncOrAsync[AuditLogActionsListResource]: + """List all registered Audit Log actions. + + Kwargs: + limit (int): Maximum number of records to return. (Optional) + before (str): Pagination cursor to receive records before a provided ID. (Optional) + after (str): Pagination cursor to receive records after a provided ID. (Optional) + order (Literal["asc","desc"]): Sort order by created_at timestamp. (Optional) + + Returns: + AuditLogActionsListResource: Paginated list of audit log actions + """ + ... + + def get_retention(self, organization_id: str) -> SyncOrAsync[AuditLogRetention]: + """Get the event retention period for an organization. + + Args: + organization_id (str): Organization's unique identifier. + + Returns: + AuditLogRetention: The retention configuration + """ + ... + + def set_retention( + self, + *, + organization_id: str, + retention_period_in_days: Literal[30, 365], + ) -> SyncOrAsync[AuditLogRetention]: + """Set the event retention period for an organization. + + Kwargs: + organization_id (str): Organization's unique identifier. + retention_period_in_days (int): The number of days to retain events (30 or 365). + + Returns: + AuditLogRetention: The updated retention configuration + """ + ... + + def get_configuration( + self, organization_id: str + ) -> SyncOrAsync[AuditLogConfiguration]: + """Get the audit log configuration for an organization. + + Args: + organization_id (str): Organization's unique identifier. + + Returns: + AuditLogConfiguration: The complete audit log configuration + """ + ... + class AuditLogs(AuditLogsModule): _http_client: SyncHTTPClient @@ -81,7 +218,7 @@ def create_event( ) -> None: json = {"organization_id": organization_id, "event": event} - headers = {} + headers: Dict[str, str] = {} if idempotency_key: headers["idempotency-key"] = idempotency_key @@ -118,8 +255,309 @@ def create_export( def get_export(self, audit_log_export_id: str) -> AuditLogExport: response = self._http_client.request( - "{0}/{1}".format(EXPORTS_PATH, audit_log_export_id), + f"{EXPORTS_PATH}/{audit_log_export_id}", + method=REQUEST_METHOD_GET, + ) + + return AuditLogExport.model_validate(response) + + def create_schema( + self, + *, + action: str, + targets: Sequence[Mapping[str, Any]], + actor: Optional[Mapping[str, Any]] = None, + metadata: Optional[Mapping[str, Any]] = None, + idempotency_key: Optional[str] = None, + ) -> AuditLogSchema: + json: Dict[str, Any] = { + "targets": list(targets), + } + if actor is not None: + json["actor"] = actor + if metadata is not None: + json["metadata"] = metadata + + headers: Dict[str, str] = {} + if idempotency_key: + headers["idempotency-key"] = idempotency_key + + response = self._http_client.request( + f"{ACTIONS_PATH}/{action}/schemas", + method=REQUEST_METHOD_POST, + json=json, + headers=headers, + ) + + return AuditLogSchema.model_validate(response) + + def list_schemas( + self, + *, + action: str, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> AuditLogSchemasListResource: + list_params: AuditLogSchemaListFilters = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + response = self._http_client.request( + f"{ACTIONS_PATH}/{action}/schemas", + method=REQUEST_METHOD_GET, + params=list_params, + ) + + return WorkOSListResource[ + AuditLogSchema, AuditLogSchemaListFilters, ListMetadata + ]( + list_method=lambda **kwargs: self.list_schemas(action=action, **kwargs), + list_args=list_params, + **ListPage[AuditLogSchema](**response).model_dump(), + ) + + def list_actions( + self, + *, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> AuditLogActionsListResource: + list_params: AuditLogActionListFilters = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + response = self._http_client.request( + ACTIONS_PATH, + method=REQUEST_METHOD_GET, + params=list_params, + ) + + return WorkOSListResource[ + AuditLogAction, AuditLogActionListFilters, ListMetadata + ]( + list_method=self.list_actions, + list_args=list_params, + **ListPage[AuditLogAction](**response).model_dump(), + ) + + def get_retention(self, organization_id: str) -> AuditLogRetention: + response = self._http_client.request( + f"organizations/{organization_id}/audit_logs_retention", + method=REQUEST_METHOD_GET, + ) + + return AuditLogRetention.model_validate(response) + + def set_retention( + self, + *, + organization_id: str, + retention_period_in_days: Literal[30, 365], + ) -> AuditLogRetention: + json = {"retention_period_in_days": retention_period_in_days} + + response = self._http_client.request( + f"organizations/{organization_id}/audit_logs_retention", + method=REQUEST_METHOD_PUT, + json=json, + ) + + return AuditLogRetention.model_validate(response) + + def get_configuration(self, organization_id: str) -> AuditLogConfiguration: + response = self._http_client.request( + f"organizations/{organization_id}/audit_log_configuration", + method=REQUEST_METHOD_GET, + ) + + return AuditLogConfiguration.model_validate(response) + + +class AsyncAuditLogs(AuditLogsModule): + _http_client: AsyncHTTPClient + + def __init__(self, http_client: AsyncHTTPClient): + self._http_client = http_client + + async def create_event( + self, + *, + organization_id: str, + event: AuditLogEvent, + idempotency_key: Optional[str] = None, + ) -> None: + json = {"organization_id": organization_id, "event": event} + + headers: Dict[str, str] = {} + if idempotency_key: + headers["idempotency-key"] = idempotency_key + + await self._http_client.request( + EVENTS_PATH, method=REQUEST_METHOD_POST, json=json, headers=headers + ) + + async def create_export( + self, + *, + organization_id: str, + range_start: str, + range_end: str, + actions: Optional[Sequence[str]] = None, + targets: Optional[Sequence[str]] = None, + actor_names: Optional[Sequence[str]] = None, + actor_ids: Optional[Sequence[str]] = None, + ) -> AuditLogExport: + json = { + "actions": actions, + "actor_ids": actor_ids, + "actor_names": actor_names, + "organization_id": organization_id, + "range_start": range_start, + "range_end": range_end, + "targets": targets, + } + + response = await self._http_client.request( + EXPORTS_PATH, method=REQUEST_METHOD_POST, json=json + ) + + return AuditLogExport.model_validate(response) + + async def get_export(self, audit_log_export_id: str) -> AuditLogExport: + response = await self._http_client.request( + f"{EXPORTS_PATH}/{audit_log_export_id}", method=REQUEST_METHOD_GET, ) return AuditLogExport.model_validate(response) + + async def create_schema( + self, + *, + action: str, + targets: Sequence[Mapping[str, Any]], + actor: Optional[Mapping[str, Any]] = None, + metadata: Optional[Mapping[str, Any]] = None, + idempotency_key: Optional[str] = None, + ) -> AuditLogSchema: + json: Dict[str, Any] = { + "targets": list(targets), + } + if actor is not None: + json["actor"] = actor + if metadata is not None: + json["metadata"] = metadata + + headers: Dict[str, str] = {} + if idempotency_key: + headers["idempotency-key"] = idempotency_key + + response = await self._http_client.request( + f"{ACTIONS_PATH}/{action}/schemas", + method=REQUEST_METHOD_POST, + json=json, + headers=headers, + ) + + return AuditLogSchema.model_validate(response) + + async def list_schemas( + self, + *, + action: str, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> AuditLogSchemasListResource: + list_params: AuditLogSchemaListFilters = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + response = await self._http_client.request( + f"{ACTIONS_PATH}/{action}/schemas", + method=REQUEST_METHOD_GET, + params=list_params, + ) + + return WorkOSListResource[ + AuditLogSchema, AuditLogSchemaListFilters, ListMetadata + ]( + list_method=lambda **kwargs: self.list_schemas(action=action, **kwargs), + list_args=list_params, + **ListPage[AuditLogSchema](**response).model_dump(), + ) + + async def list_actions( + self, + *, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> AuditLogActionsListResource: + list_params: AuditLogActionListFilters = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + response = await self._http_client.request( + ACTIONS_PATH, + method=REQUEST_METHOD_GET, + params=list_params, + ) + + return WorkOSListResource[ + AuditLogAction, AuditLogActionListFilters, ListMetadata + ]( + list_method=self.list_actions, + list_args=list_params, + **ListPage[AuditLogAction](**response).model_dump(), + ) + + async def get_retention(self, organization_id: str) -> AuditLogRetention: + response = await self._http_client.request( + f"organizations/{organization_id}/audit_logs_retention", + method=REQUEST_METHOD_GET, + ) + + return AuditLogRetention.model_validate(response) + + async def set_retention( + self, + *, + organization_id: str, + retention_period_in_days: Literal[30, 365], + ) -> AuditLogRetention: + json = {"retention_period_in_days": retention_period_in_days} + + response = await self._http_client.request( + f"organizations/{organization_id}/audit_logs_retention", + method=REQUEST_METHOD_PUT, + json=json, + ) + + return AuditLogRetention.model_validate(response) + + async def get_configuration(self, organization_id: str) -> AuditLogConfiguration: + response = await self._http_client.request( + f"organizations/{organization_id}/audit_log_configuration", + method=REQUEST_METHOD_GET, + ) + + return AuditLogConfiguration.model_validate(response) diff --git a/src/workos/types/audit_logs/__init__.py b/src/workos/types/audit_logs/__init__.py index ed83cdb7..edf14112 100644 --- a/src/workos/types/audit_logs/__init__.py +++ b/src/workos/types/audit_logs/__init__.py @@ -1,6 +1,11 @@ +from .audit_log_action import * +from .audit_log_configuration import * from .audit_log_event_actor import * from .audit_log_event_context import * from .audit_log_event_target import * from .audit_log_event import * from .audit_log_export import * from .audit_log_metadata import * +from .audit_log_retention import * +from .audit_log_schema import * +from .list_filters import * diff --git a/src/workos/types/audit_logs/audit_log_action.py b/src/workos/types/audit_logs/audit_log_action.py new file mode 100644 index 00000000..e0e2889c --- /dev/null +++ b/src/workos/types/audit_logs/audit_log_action.py @@ -0,0 +1,23 @@ +from typing import Literal + +from pydantic import ConfigDict, Field + +from workos.types.audit_logs.audit_log_schema import AuditLogSchema +from workos.types.workos_model import WorkOSModel + + +class AuditLogAction(WorkOSModel): + """Representation of a WorkOS audit log action. + + An audit log action represents a configured action type that can be + used in audit log events. Each action has an associated schema that + defines the structure of events for that action. + """ + + model_config = ConfigDict(populate_by_name=True) + + object: Literal["audit_log_action"] + name: str + action_schema: AuditLogSchema = Field(alias="schema") + created_at: str + updated_at: str diff --git a/src/workos/types/audit_logs/audit_log_configuration.py b/src/workos/types/audit_logs/audit_log_configuration.py new file mode 100644 index 00000000..3bff5a03 --- /dev/null +++ b/src/workos/types/audit_logs/audit_log_configuration.py @@ -0,0 +1,41 @@ +from typing import Literal, Optional + +from workos.types.workos_model import WorkOSModel +from workos.typing.literals import LiteralOrUntyped + + +AuditLogStreamType = Literal[ + "Datadog", "Splunk", "S3", "GoogleCloudStorage", "GenericHttps" +] + +AuditLogStreamState = Literal["active", "inactive", "error", "invalid"] + +AuditLogTrailState = Literal["active", "inactive", "disabled"] + + +class AuditLogStream(WorkOSModel): + """Representation of a WorkOS audit log stream. + + An audit log stream sends audit log events to an external destination + such as Datadog, Splunk, S3, Google Cloud Storage, or a custom HTTPS endpoint. + """ + + id: str + type: LiteralOrUntyped[AuditLogStreamType] + state: LiteralOrUntyped[AuditLogStreamState] + last_synced_at: Optional[str] = None + created_at: str + + +class AuditLogConfiguration(WorkOSModel): + """Representation of a WorkOS audit log configuration for an organization. + + The audit log configuration provides a single view of an organization's + audit logging setup, including retention settings, state, and optional + log stream configuration. + """ + + organization_id: str + retention_period_in_days: int + state: LiteralOrUntyped[AuditLogTrailState] + log_stream: Optional[AuditLogStream] = None diff --git a/src/workos/types/audit_logs/audit_log_retention.py b/src/workos/types/audit_logs/audit_log_retention.py new file mode 100644 index 00000000..1864f5fc --- /dev/null +++ b/src/workos/types/audit_logs/audit_log_retention.py @@ -0,0 +1,11 @@ +from workos.types.workos_model import WorkOSModel + + +class AuditLogRetention(WorkOSModel): + """Representation of a WorkOS audit log retention configuration. + + Specifies how long audit log events are retained for an organization. + Valid values are 30 and 365 days. + """ + + retention_period_in_days: int diff --git a/src/workos/types/audit_logs/audit_log_schema.py b/src/workos/types/audit_logs/audit_log_schema.py new file mode 100644 index 00000000..775ac5cb --- /dev/null +++ b/src/workos/types/audit_logs/audit_log_schema.py @@ -0,0 +1,49 @@ +from typing import Dict, Literal, Optional, Sequence + +from workos.types.workos_model import WorkOSModel + + +class AuditLogSchemaMetadataProperty(WorkOSModel): + """A property definition within an audit log schema metadata object.""" + + type: Literal["string", "boolean", "number"] + + +class AuditLogSchemaMetadata(WorkOSModel): + """The metadata definition for an audit log schema. + + Represents a JSON Schema object type with property definitions. + """ + + type: Literal["object"] + properties: Optional[Dict[str, AuditLogSchemaMetadataProperty]] = None + + +class AuditLogSchemaTarget(WorkOSModel): + """A target definition within an audit log schema.""" + + type: str + metadata: Optional[AuditLogSchemaMetadata] = None + + +class AuditLogSchemaActor(WorkOSModel): + """The actor definition within an audit log schema.""" + + metadata: AuditLogSchemaMetadata + + +class AuditLogSchema(WorkOSModel): + """Representation of a WorkOS audit log schema. + + Audit log schemas define the structure and validation rules + for audit log events, including the allowed targets, actor metadata, + and event-level metadata. + """ + + object: Literal["audit_log_schema"] = "audit_log_schema" + version: int + targets: Sequence[AuditLogSchemaTarget] + actor: AuditLogSchemaActor + metadata: Optional[AuditLogSchemaMetadata] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None diff --git a/src/workos/types/audit_logs/list_filters.py b/src/workos/types/audit_logs/list_filters.py new file mode 100644 index 00000000..705c7cc6 --- /dev/null +++ b/src/workos/types/audit_logs/list_filters.py @@ -0,0 +1,13 @@ +from workos.types.list_resource import ListArgs + + +class AuditLogActionListFilters(ListArgs, total=False): + """Filters for listing audit log actions.""" + + pass + + +class AuditLogSchemaListFilters(ListArgs, total=False): + """Filters for listing audit log schemas.""" + + pass diff --git a/src/workos/types/list_resource.py b/src/workos/types/list_resource.py index e2ece480..19820cbd 100644 --- a/src/workos/types/list_resource.py +++ b/src/workos/types/list_resource.py @@ -17,6 +17,7 @@ cast, ) from typing_extensions import Required, TypedDict +from workos.types.audit_logs import AuditLogAction, AuditLogSchema from workos.types.directory_sync import ( Directory, DirectoryGroup, @@ -42,6 +43,8 @@ ListableResource = TypeVar( # add all possible generics of List Resource "ListableResource", + AuditLogAction, + AuditLogSchema, AuthenticationFactor, ConnectionWithDomains, Directory, diff --git a/tests/test_audit_logs.py b/tests/test_audit_logs.py index 390536f8..9caefe80 100644 --- a/tests/test_audit_logs.py +++ b/tests/test_audit_logs.py @@ -288,3 +288,419 @@ def test_throws_unauthorized_excpetion(self, mock_http_client_with_response): assert "(message=Unauthorized, request_id=a-request-id)" == str( excinfo.value ) + + class TestCreateSchema(_TestSetup): + def test_succeeds(self, capture_and_mock_http_client_request): + action = "user.signed_in" + + expected_payload = { + "object": "audit_log_schema", + "version": 1, + "targets": [{"type": "user"}], + "actor": {"metadata": {"type": "object", "properties": {}}}, + "metadata": None, + "created_at": "2024-10-14T15:09:44.537Z", + } + + request_kwargs = capture_and_mock_http_client_request( + self.http_client, expected_payload, 201 + ) + + response = self.audit_logs.create_schema( + action=action, + targets=[{"type": "user"}], + ) + + assert request_kwargs["url"].endswith( + f"/audit_logs/actions/{action}/schemas" + ) + assert request_kwargs["method"] == "post" + assert request_kwargs["json"] == {"targets": [{"type": "user"}]} + assert response.version == 1 + assert response.targets[0].type == "user" + + def test_sends_idempotency_key(self, capture_and_mock_http_client_request): + action = "user.signed_in" + idempotency_key = "test_123456789" + + expected_payload = { + "object": "audit_log_schema", + "version": 1, + "targets": [{"type": "user"}], + "actor": {"metadata": {"type": "object", "properties": {}}}, + "created_at": "2024-10-14T15:09:44.537Z", + } + + request_kwargs = capture_and_mock_http_client_request( + self.http_client, expected_payload, 201 + ) + + self.audit_logs.create_schema( + action=action, + targets=[{"type": "user"}], + idempotency_key=idempotency_key, + ) + + assert request_kwargs["headers"]["idempotency-key"] == idempotency_key + + def test_with_actor_and_metadata(self, capture_and_mock_http_client_request): + action = "user.viewed_invoice" + + expected_payload = { + "object": "audit_log_schema", + "version": 1, + "targets": [ + { + "type": "invoice", + "metadata": { + "type": "object", + "properties": {"status": {"type": "string"}}, + }, + } + ], + "actor": { + "metadata": { + "type": "object", + "properties": {"role": {"type": "string"}}, + } + }, + "metadata": { + "type": "object", + "properties": {"transactionId": {"type": "string"}}, + }, + "created_at": "2024-10-14T15:09:44.537Z", + } + + request_kwargs = capture_and_mock_http_client_request( + self.http_client, expected_payload, 201 + ) + + response = self.audit_logs.create_schema( + action=action, + targets=[ + { + "type": "invoice", + "metadata": { + "type": "object", + "properties": {"status": {"type": "string"}}, + }, + } + ], + actor={ + "metadata": { + "type": "object", + "properties": {"role": {"type": "string"}}, + } + }, + metadata={ + "type": "object", + "properties": {"transactionId": {"type": "string"}}, + }, + ) + + assert request_kwargs["json"]["actor"] is not None + assert request_kwargs["json"]["metadata"] is not None + assert response.metadata is not None + + def test_throws_unauthorized_exception(self, mock_http_client_with_response): + mock_http_client_with_response( + self.http_client, + {"message": "Unauthorized"}, + 401, + {"X-Request-ID": "a-request-id"}, + ) + + with pytest.raises(AuthenticationException) as excinfo: + self.audit_logs.create_schema( + action="user.signed_in", + targets=[{"type": "user"}], + ) + + assert "(message=Unauthorized, request_id=a-request-id)" == str( + excinfo.value + ) + + class TestListSchemas(_TestSetup): + def test_succeeds(self, capture_and_mock_http_client_request): + action = "user.viewed_invoice" + + expected_payload = { + "object": "list", + "data": [ + { + "version": 1, + "actor": { + "metadata": { + "type": "object", + "properties": {"role": {"type": "string"}}, + } + }, + "targets": [ + { + "type": "invoice", + "metadata": { + "type": "object", + "properties": {"status": {"type": "string"}}, + }, + } + ], + "metadata": { + "type": "object", + "properties": {"transactionId": {"type": "string"}}, + }, + "updated_at": "2021-06-25T19:07:33.155Z", + } + ], + "list_metadata": {"before": None, "after": None}, + } + + request_kwargs = capture_and_mock_http_client_request( + self.http_client, expected_payload, 200 + ) + + response = self.audit_logs.list_schemas(action=action) + + assert request_kwargs["url"].endswith( + f"/audit_logs/actions/{action}/schemas" + ) + assert request_kwargs["method"] == "get" + assert len(response.data) == 1 + assert response.data[0].version == 1 + + def test_with_pagination_params(self, capture_and_mock_http_client_request): + action = "user.signed_in" + + expected_payload = { + "object": "list", + "data": [], + "list_metadata": {"before": None, "after": None}, + } + + request_kwargs = capture_and_mock_http_client_request( + self.http_client, expected_payload, 200 + ) + + self.audit_logs.list_schemas( + action=action, + limit=5, + order="asc", + ) + + assert request_kwargs["params"]["limit"] == 5 + assert request_kwargs["params"]["order"] == "asc" + + class TestListActions(_TestSetup): + def test_succeeds(self, capture_and_mock_http_client_request): + expected_payload = { + "object": "list", + "data": [ + { + "object": "audit_log_action", + "name": "user.viewed_invoice", + "schema": { + "object": "audit_log_schema", + "version": 1, + "actor": { + "metadata": { + "type": "object", + "properties": {"role": {"type": "string"}}, + } + }, + "targets": [ + { + "type": "invoice", + "metadata": { + "type": "object", + "properties": {"status": {"type": "string"}}, + }, + } + ], + "metadata": { + "type": "object", + "properties": {"transactionId": {"type": "string"}}, + }, + "updated_at": "2021-06-25T19:07:33.155Z", + }, + "created_at": "2021-06-25T19:07:33.155Z", + "updated_at": "2021-06-25T19:07:33.155Z", + } + ], + "list_metadata": {"before": None, "after": None}, + } + + request_kwargs = capture_and_mock_http_client_request( + self.http_client, expected_payload, 200 + ) + + response = self.audit_logs.list_actions() + + assert request_kwargs["url"].endswith("/audit_logs/actions") + assert request_kwargs["method"] == "get" + assert len(response.data) == 1 + assert response.data[0].name == "user.viewed_invoice" + assert response.data[0].action_schema.version == 1 + + def test_with_pagination_params(self, capture_and_mock_http_client_request): + expected_payload = { + "object": "list", + "data": [], + "list_metadata": {"before": None, "after": None}, + } + + request_kwargs = capture_and_mock_http_client_request( + self.http_client, expected_payload, 200 + ) + + self.audit_logs.list_actions( + limit=10, + order="asc", + after="cursor_123", + ) + + assert request_kwargs["params"]["limit"] == 10 + assert request_kwargs["params"]["order"] == "asc" + assert request_kwargs["params"]["after"] == "cursor_123" + + class TestGetRetention(_TestSetup): + def test_succeeds(self, capture_and_mock_http_client_request): + organization_id = "org_123456789" + + expected_payload = {"retention_period_in_days": 30} + + request_kwargs = capture_and_mock_http_client_request( + self.http_client, expected_payload, 200 + ) + + response = self.audit_logs.get_retention(organization_id) + + assert request_kwargs["url"].endswith( + f"/organizations/{organization_id}/audit_logs_retention" + ) + assert request_kwargs["method"] == "get" + assert response.retention_period_in_days == 30 + + def test_throws_unauthorized_exception(self, mock_http_client_with_response): + mock_http_client_with_response( + self.http_client, + {"message": "Unauthorized"}, + 401, + {"X-Request-ID": "a-request-id"}, + ) + + with pytest.raises(AuthenticationException) as excinfo: + self.audit_logs.get_retention("org_123456789") + + assert "(message=Unauthorized, request_id=a-request-id)" == str( + excinfo.value + ) + + class TestSetRetention(_TestSetup): + def test_succeeds(self, capture_and_mock_http_client_request): + organization_id = "org_123456789" + + expected_payload = {"retention_period_in_days": 365} + + request_kwargs = capture_and_mock_http_client_request( + self.http_client, expected_payload, 200 + ) + + response = self.audit_logs.set_retention( + organization_id=organization_id, + retention_period_in_days=365, + ) + + assert request_kwargs["url"].endswith( + f"/organizations/{organization_id}/audit_logs_retention" + ) + assert request_kwargs["method"] == "put" + assert request_kwargs["json"] == {"retention_period_in_days": 365} + assert response.retention_period_in_days == 365 + + def test_throws_unauthorized_exception(self, mock_http_client_with_response): + mock_http_client_with_response( + self.http_client, + {"message": "Unauthorized"}, + 401, + {"X-Request-ID": "a-request-id"}, + ) + + with pytest.raises(AuthenticationException) as excinfo: + self.audit_logs.set_retention( + organization_id="org_123456789", + retention_period_in_days=30, + ) + + assert "(message=Unauthorized, request_id=a-request-id)" == str( + excinfo.value + ) + + class TestGetConfiguration(_TestSetup): + def test_succeeds_with_log_stream(self, capture_and_mock_http_client_request): + organization_id = "org_123456789" + + expected_payload = { + "organization_id": organization_id, + "retention_period_in_days": 30, + "state": "active", + "log_stream": { + "id": "audit_log_stream_01HQJW5XBQZ8Y4R9S3T5V6W7X8", + "type": "Datadog", + "state": "active", + "last_synced_at": "2024-01-15T10:30:00.000Z", + "created_at": "2024-01-15T10:30:00.000Z", + }, + } + + request_kwargs = capture_and_mock_http_client_request( + self.http_client, expected_payload, 200 + ) + + response = self.audit_logs.get_configuration(organization_id) + + assert request_kwargs["url"].endswith( + f"/organizations/{organization_id}/audit_log_configuration" + ) + assert request_kwargs["method"] == "get" + assert response.organization_id == organization_id + assert response.retention_period_in_days == 30 + assert response.state == "active" + assert response.log_stream is not None + assert response.log_stream.type == "Datadog" + assert response.log_stream.state == "active" + + def test_succeeds_without_log_stream( + self, capture_and_mock_http_client_request + ): + organization_id = "org_123456789" + + expected_payload = { + "organization_id": organization_id, + "retention_period_in_days": 30, + "state": "inactive", + } + + capture_and_mock_http_client_request( + self.http_client, expected_payload, 200 + ) + + response = self.audit_logs.get_configuration(organization_id) + + assert response.organization_id == organization_id + assert response.retention_period_in_days == 30 + assert response.state == "inactive" + assert response.log_stream is None + + def test_throws_unauthorized_exception(self, mock_http_client_with_response): + mock_http_client_with_response( + self.http_client, + {"message": "Unauthorized"}, + 401, + {"X-Request-ID": "a-request-id"}, + ) + + with pytest.raises(AuthenticationException) as excinfo: + self.audit_logs.get_configuration("org_123456789") + + assert "(message=Unauthorized, request_id=a-request-id)" == str( + excinfo.value + ) From 787a6db4044032041d7d6e3627c4f32f7b95c07a Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:34:09 -0600 Subject: [PATCH 2/9] Replace asyncio.iscoroutinefunction with inspect Starting in Python 3.14, use of `asyncio.iscoroutinefunction` emits a deprecation warning (c.f. python/cpython#122858). The recommendation is to use inspect.iscoroutinefunction instead. --- tests/conftest.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9ebe4a14..b7dfdb35 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,9 +13,10 @@ from unittest.mock import AsyncMock, MagicMock import urllib.parse +import inspect + import httpx import pytest -import asyncio from functools import wraps from tests.utils.client_configuration import ClientConfiguration @@ -345,6 +346,6 @@ def sync_wrapper(*args, **kwargs): return func(*args, **kwargs) # Return appropriate wrapper based on whether the function is async or not - if asyncio.iscoroutinefunction(func): + if inspect.iscoroutinefunction(func): return async_wrapper return sync_wrapper From 61bb3b7d4e1aeaf6f5cf9d9715e427c721cf19d0 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Wed, 14 Jan 2026 09:45:29 -0600 Subject: [PATCH 3/9] Use 'schema' field and handle pydantic's warning --- src/workos/types/audit_logs/audit_log_action.py | 15 ++++++++++----- tests/test_audit_logs.py | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/workos/types/audit_logs/audit_log_action.py b/src/workos/types/audit_logs/audit_log_action.py index e0e2889c..a342f143 100644 --- a/src/workos/types/audit_logs/audit_log_action.py +++ b/src/workos/types/audit_logs/audit_log_action.py @@ -1,10 +1,17 @@ +import warnings from typing import Literal -from pydantic import ConfigDict, Field - from workos.types.audit_logs.audit_log_schema import AuditLogSchema from workos.types.workos_model import WorkOSModel +# Suppress Pydantic warning about 'schema' shadowing BaseModel.schema() +# (a deprecated method replaced by model_json_schema() in Pydantic v2) +warnings.filterwarnings( + "ignore", + message='Field name "schema" in "AuditLogAction" shadows an attribute', + category=UserWarning, +) + class AuditLogAction(WorkOSModel): """Representation of a WorkOS audit log action. @@ -14,10 +21,8 @@ class AuditLogAction(WorkOSModel): defines the structure of events for that action. """ - model_config = ConfigDict(populate_by_name=True) - object: Literal["audit_log_action"] name: str - action_schema: AuditLogSchema = Field(alias="schema") + schema: AuditLogSchema # type: ignore[assignment] created_at: str updated_at: str diff --git a/tests/test_audit_logs.py b/tests/test_audit_logs.py index 9caefe80..e25b3463 100644 --- a/tests/test_audit_logs.py +++ b/tests/test_audit_logs.py @@ -538,7 +538,7 @@ def test_succeeds(self, capture_and_mock_http_client_request): assert request_kwargs["method"] == "get" assert len(response.data) == 1 assert response.data[0].name == "user.viewed_invoice" - assert response.data[0].action_schema.version == 1 + assert response.data[0].schema.version == 1 def test_with_pagination_params(self, capture_and_mock_http_client_request): expected_payload = { From 733353f7261fa48f810a8d9e726b1bd0ae1ee6e0 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:38:18 -0600 Subject: [PATCH 4/9] Test sync and async variants propertly --- tests/test_audit_logs.py | 411 +++++++++++++++++++++++++-------------- 1 file changed, 265 insertions(+), 146 deletions(-) diff --git a/tests/test_audit_logs.py b/tests/test_audit_logs.py index e25b3463..bae24783 100644 --- a/tests/test_audit_logs.py +++ b/tests/test_audit_logs.py @@ -1,17 +1,15 @@ from datetime import datetime +from typing import Union import pytest -from workos.audit_logs import AuditLogEvent, AuditLogs +from tests.utils.syncify import syncify +from workos.audit_logs import AuditLogEvent, AuditLogs, AsyncAuditLogs from workos.exceptions import AuthenticationException, BadRequestException -class _TestSetup: - @pytest.fixture(autouse=True) - def setup(self, sync_http_client_for_test): - self.http_client = sync_http_client_for_test - self.audit_logs = AuditLogs(http_client=self.http_client) - +@pytest.mark.sync_and_async(AuditLogs, AsyncAuditLogs) +class TestAuditLogs: @pytest.fixture def mock_audit_log_event(self) -> AuditLogEvent: return { @@ -37,10 +35,12 @@ def mock_audit_log_event(self) -> AuditLogEvent: }, } - -class TestAuditLogs: - class TestCreateEvent(_TestSetup): - def test_succeeds(self, capture_and_mock_http_client_request): + class TestCreateEvent: + def test_succeeds( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): organization_id = "org_123456789" event: AuditLogEvent = { @@ -67,15 +67,17 @@ def test_succeeds(self, capture_and_mock_http_client_request): } request_kwargs = capture_and_mock_http_client_request( - http_client=self.http_client, + http_client=module_instance._http_client, response_dict={"success": True}, status_code=200, ) - response = self.audit_logs.create_event( - organization_id=organization_id, - event=event, - idempotency_key="test_123456", + response = syncify( + module_instance.create_event( + organization_id=organization_id, + event=event, + idempotency_key="test_123456", + ) ) assert request_kwargs["url"].endswith("/audit_logs/events") @@ -87,52 +89,65 @@ def test_succeeds(self, capture_and_mock_http_client_request): assert response is None def test_sends_idempotency_key( - self, mock_audit_log_event, capture_and_mock_http_client_request + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_audit_log_event, + capture_and_mock_http_client_request, ): idempotency_key = "test_123456789" organization_id = "org_123456789" request_kwargs = capture_and_mock_http_client_request( - self.http_client, {"success": True}, 200 + module_instance._http_client, {"success": True}, 200 ) - response = self.audit_logs.create_event( - organization_id=organization_id, - event=mock_audit_log_event, - idempotency_key=idempotency_key, + response = syncify( + module_instance.create_event( + organization_id=organization_id, + event=mock_audit_log_event, + idempotency_key=idempotency_key, + ) ) assert request_kwargs["headers"]["idempotency-key"] == idempotency_key assert response is None def test_throws_unauthorized_exception( - self, mock_audit_log_event, mock_http_client_with_response + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_audit_log_event, + mock_http_client_with_response, ): organization_id = "org_123456789" mock_http_client_with_response( - self.http_client, + module_instance._http_client, {"message": "Unauthorized"}, 401, {"X-Request-ID": "a-request-id"}, ) with pytest.raises(AuthenticationException) as excinfo: - self.audit_logs.create_event( - organization_id=organization_id, event=mock_audit_log_event + syncify( + module_instance.create_event( + organization_id=organization_id, event=mock_audit_log_event + ) ) assert "(message=Unauthorized, request_id=a-request-id)" == str( excinfo.value ) def test_throws_badrequest_excpetion( - self, mock_audit_log_event, mock_http_client_with_response + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_audit_log_event, + mock_http_client_with_response, ): organization_id = "org_123456789" mock_http_client_with_response( - self.http_client, + module_instance._http_client, { "message": "Audit Log could not be processed due to missing or incorrect data.", "code": "invalid_audit_log", @@ -142,8 +157,10 @@ def test_throws_badrequest_excpetion( ) with pytest.raises(BadRequestException) as excinfo: - self.audit_logs.create_event( - organization_id=organization_id, event=mock_audit_log_event + syncify( + module_instance.create_event( + organization_id=organization_id, event=mock_audit_log_event + ) ) assert excinfo.code == "invalid_audit_log" assert excinfo.errors == ["error in a field"] @@ -152,8 +169,12 @@ def test_throws_badrequest_excpetion( == "Audit Log could not be processed due to missing or incorrect data." ) - class TestCreateExport(_TestSetup): - def test_succeeds(self, mock_http_client_with_response): + class TestCreateExport: + def test_succeeds( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_http_client_with_response, + ): organization_id = "org_123456789" now = datetime.now().isoformat() range_start = now @@ -168,18 +189,24 @@ def test_succeeds(self, mock_http_client_with_response): "updated_at": now, } - mock_http_client_with_response(self.http_client, expected_payload, 201) + mock_http_client_with_response( + module_instance._http_client, expected_payload, 201 + ) - response = self.audit_logs.create_export( - organization_id=organization_id, - range_start=range_start, - range_end=range_end, + response = syncify( + module_instance.create_export( + organization_id=organization_id, + range_start=range_start, + range_end=range_end, + ) ) assert response.dict() == expected_payload def test_succeeds_with_additional_filters( - self, capture_and_mock_http_client_request + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, ): now = datetime.now().isoformat() organization_id = "org_123456789" @@ -200,17 +227,19 @@ def test_succeeds_with_additional_filters( } request_kwargs = capture_and_mock_http_client_request( - self.http_client, expected_payload, 201 + module_instance._http_client, expected_payload, 201 ) - response = self.audit_logs.create_export( - actions=actions, - organization_id=organization_id, - range_end=range_end, - range_start=range_start, - targets=targets, - actor_names=actor_names, - actor_ids=actor_ids, + response = syncify( + module_instance.create_export( + actions=actions, + organization_id=organization_id, + range_end=range_end, + range_start=range_start, + targets=targets, + actor_names=actor_names, + actor_ids=actor_ids, + ) ) assert request_kwargs["url"].endswith("/audit_logs/exports") @@ -226,30 +255,40 @@ def test_succeeds_with_additional_filters( } assert response.dict() == expected_payload - def test_throws_unauthorized_excpetion(self, mock_http_client_with_response): + def test_throws_unauthorized_excpetion( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_http_client_with_response, + ): organization_id = "org_123456789" range_start = datetime.now().isoformat() range_end = datetime.now().isoformat() mock_http_client_with_response( - self.http_client, + module_instance._http_client, {"message": "Unauthorized"}, 401, {"X-Request-ID": "a-request-id"}, ) with pytest.raises(AuthenticationException) as excinfo: - self.audit_logs.create_export( - organization_id=organization_id, - range_start=range_start, - range_end=range_end, + syncify( + module_instance.create_export( + organization_id=organization_id, + range_start=range_start, + range_end=range_end, + ) ) assert "(message=Unauthorized, request_id=a-request-id)" == str( excinfo.value ) - class TestGetExport(_TestSetup): - def test_succeeds(self, capture_and_mock_http_client_request): + class TestGetExport: + def test_succeeds( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): now = datetime.now().isoformat() expected_payload = { "object": "audit_log_export", @@ -261,11 +300,13 @@ def test_succeeds(self, capture_and_mock_http_client_request): } request_kwargs = capture_and_mock_http_client_request( - self.http_client, expected_payload, 200 + module_instance._http_client, expected_payload, 200 ) - response = self.audit_logs.get_export( - expected_payload["id"], + response = syncify( + module_instance.get_export( + expected_payload["id"], + ) ) assert request_kwargs["url"].endswith( @@ -274,23 +315,31 @@ def test_succeeds(self, capture_and_mock_http_client_request): assert request_kwargs["method"] == "get" assert response.dict() == expected_payload - def test_throws_unauthorized_excpetion(self, mock_http_client_with_response): + def test_throws_unauthorized_excpetion( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_http_client_with_response, + ): mock_http_client_with_response( - self.http_client, + module_instance._http_client, {"message": "Unauthorized"}, 401, {"X-Request-ID": "a-request-id"}, ) with pytest.raises(AuthenticationException) as excinfo: - self.audit_logs.get_export("audit_log_export_1234") + syncify(module_instance.get_export("audit_log_export_1234")) assert "(message=Unauthorized, request_id=a-request-id)" == str( excinfo.value ) - class TestCreateSchema(_TestSetup): - def test_succeeds(self, capture_and_mock_http_client_request): + class TestCreateSchema: + def test_succeeds( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): action = "user.signed_in" expected_payload = { @@ -303,12 +352,14 @@ def test_succeeds(self, capture_and_mock_http_client_request): } request_kwargs = capture_and_mock_http_client_request( - self.http_client, expected_payload, 201 + module_instance._http_client, expected_payload, 201 ) - response = self.audit_logs.create_schema( - action=action, - targets=[{"type": "user"}], + response = syncify( + module_instance.create_schema( + action=action, + targets=[{"type": "user"}], + ) ) assert request_kwargs["url"].endswith( @@ -319,7 +370,11 @@ def test_succeeds(self, capture_and_mock_http_client_request): assert response.version == 1 assert response.targets[0].type == "user" - def test_sends_idempotency_key(self, capture_and_mock_http_client_request): + def test_sends_idempotency_key( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): action = "user.signed_in" idempotency_key = "test_123456789" @@ -332,18 +387,24 @@ def test_sends_idempotency_key(self, capture_and_mock_http_client_request): } request_kwargs = capture_and_mock_http_client_request( - self.http_client, expected_payload, 201 + module_instance._http_client, expected_payload, 201 ) - self.audit_logs.create_schema( - action=action, - targets=[{"type": "user"}], - idempotency_key=idempotency_key, + syncify( + module_instance.create_schema( + action=action, + targets=[{"type": "user"}], + idempotency_key=idempotency_key, + ) ) assert request_kwargs["headers"]["idempotency-key"] == idempotency_key - def test_with_actor_and_metadata(self, capture_and_mock_http_client_request): + def test_with_actor_and_metadata( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): action = "user.viewed_invoice" expected_payload = { @@ -372,56 +433,68 @@ def test_with_actor_and_metadata(self, capture_and_mock_http_client_request): } request_kwargs = capture_and_mock_http_client_request( - self.http_client, expected_payload, 201 + module_instance._http_client, expected_payload, 201 ) - response = self.audit_logs.create_schema( - action=action, - targets=[ - { - "type": "invoice", + response = syncify( + module_instance.create_schema( + action=action, + targets=[ + { + "type": "invoice", + "metadata": { + "type": "object", + "properties": {"status": {"type": "string"}}, + }, + } + ], + actor={ "metadata": { "type": "object", - "properties": {"status": {"type": "string"}}, - }, - } - ], - actor={ - "metadata": { + "properties": {"role": {"type": "string"}}, + } + }, + metadata={ "type": "object", - "properties": {"role": {"type": "string"}}, - } - }, - metadata={ - "type": "object", - "properties": {"transactionId": {"type": "string"}}, - }, + "properties": {"transactionId": {"type": "string"}}, + }, + ) ) assert request_kwargs["json"]["actor"] is not None assert request_kwargs["json"]["metadata"] is not None assert response.metadata is not None - def test_throws_unauthorized_exception(self, mock_http_client_with_response): + def test_throws_unauthorized_exception( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_http_client_with_response, + ): mock_http_client_with_response( - self.http_client, + module_instance._http_client, {"message": "Unauthorized"}, 401, {"X-Request-ID": "a-request-id"}, ) with pytest.raises(AuthenticationException) as excinfo: - self.audit_logs.create_schema( - action="user.signed_in", - targets=[{"type": "user"}], + syncify( + module_instance.create_schema( + action="user.signed_in", + targets=[{"type": "user"}], + ) ) assert "(message=Unauthorized, request_id=a-request-id)" == str( excinfo.value ) - class TestListSchemas(_TestSetup): - def test_succeeds(self, capture_and_mock_http_client_request): + class TestListSchemas: + def test_succeeds( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): action = "user.viewed_invoice" expected_payload = { @@ -455,10 +528,10 @@ def test_succeeds(self, capture_and_mock_http_client_request): } request_kwargs = capture_and_mock_http_client_request( - self.http_client, expected_payload, 200 + module_instance._http_client, expected_payload, 200 ) - response = self.audit_logs.list_schemas(action=action) + response = syncify(module_instance.list_schemas(action=action)) assert request_kwargs["url"].endswith( f"/audit_logs/actions/{action}/schemas" @@ -467,7 +540,11 @@ def test_succeeds(self, capture_and_mock_http_client_request): assert len(response.data) == 1 assert response.data[0].version == 1 - def test_with_pagination_params(self, capture_and_mock_http_client_request): + def test_with_pagination_params( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): action = "user.signed_in" expected_payload = { @@ -477,20 +554,26 @@ def test_with_pagination_params(self, capture_and_mock_http_client_request): } request_kwargs = capture_and_mock_http_client_request( - self.http_client, expected_payload, 200 + module_instance._http_client, expected_payload, 200 ) - self.audit_logs.list_schemas( - action=action, - limit=5, - order="asc", + syncify( + module_instance.list_schemas( + action=action, + limit=5, + order="asc", + ) ) assert request_kwargs["params"]["limit"] == 5 assert request_kwargs["params"]["order"] == "asc" - class TestListActions(_TestSetup): - def test_succeeds(self, capture_and_mock_http_client_request): + class TestListActions: + def test_succeeds( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): expected_payload = { "object": "list", "data": [ @@ -529,10 +612,10 @@ def test_succeeds(self, capture_and_mock_http_client_request): } request_kwargs = capture_and_mock_http_client_request( - self.http_client, expected_payload, 200 + module_instance._http_client, expected_payload, 200 ) - response = self.audit_logs.list_actions() + response = syncify(module_instance.list_actions()) assert request_kwargs["url"].endswith("/audit_logs/actions") assert request_kwargs["method"] == "get" @@ -540,7 +623,11 @@ def test_succeeds(self, capture_and_mock_http_client_request): assert response.data[0].name == "user.viewed_invoice" assert response.data[0].schema.version == 1 - def test_with_pagination_params(self, capture_and_mock_http_client_request): + def test_with_pagination_params( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): expected_payload = { "object": "list", "data": [], @@ -548,30 +635,36 @@ def test_with_pagination_params(self, capture_and_mock_http_client_request): } request_kwargs = capture_and_mock_http_client_request( - self.http_client, expected_payload, 200 + module_instance._http_client, expected_payload, 200 ) - self.audit_logs.list_actions( - limit=10, - order="asc", - after="cursor_123", + syncify( + module_instance.list_actions( + limit=10, + order="asc", + after="cursor_123", + ) ) assert request_kwargs["params"]["limit"] == 10 assert request_kwargs["params"]["order"] == "asc" assert request_kwargs["params"]["after"] == "cursor_123" - class TestGetRetention(_TestSetup): - def test_succeeds(self, capture_and_mock_http_client_request): + class TestGetRetention: + def test_succeeds( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): organization_id = "org_123456789" expected_payload = {"retention_period_in_days": 30} request_kwargs = capture_and_mock_http_client_request( - self.http_client, expected_payload, 200 + module_instance._http_client, expected_payload, 200 ) - response = self.audit_logs.get_retention(organization_id) + response = syncify(module_instance.get_retention(organization_id)) assert request_kwargs["url"].endswith( f"/organizations/{organization_id}/audit_logs_retention" @@ -579,34 +672,44 @@ def test_succeeds(self, capture_and_mock_http_client_request): assert request_kwargs["method"] == "get" assert response.retention_period_in_days == 30 - def test_throws_unauthorized_exception(self, mock_http_client_with_response): + def test_throws_unauthorized_exception( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_http_client_with_response, + ): mock_http_client_with_response( - self.http_client, + module_instance._http_client, {"message": "Unauthorized"}, 401, {"X-Request-ID": "a-request-id"}, ) with pytest.raises(AuthenticationException) as excinfo: - self.audit_logs.get_retention("org_123456789") + syncify(module_instance.get_retention("org_123456789")) assert "(message=Unauthorized, request_id=a-request-id)" == str( excinfo.value ) - class TestSetRetention(_TestSetup): - def test_succeeds(self, capture_and_mock_http_client_request): + class TestSetRetention: + def test_succeeds( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): organization_id = "org_123456789" expected_payload = {"retention_period_in_days": 365} request_kwargs = capture_and_mock_http_client_request( - self.http_client, expected_payload, 200 + module_instance._http_client, expected_payload, 200 ) - response = self.audit_logs.set_retention( - organization_id=organization_id, - retention_period_in_days=365, + response = syncify( + module_instance.set_retention( + organization_id=organization_id, + retention_period_in_days=365, + ) ) assert request_kwargs["url"].endswith( @@ -616,26 +719,36 @@ def test_succeeds(self, capture_and_mock_http_client_request): assert request_kwargs["json"] == {"retention_period_in_days": 365} assert response.retention_period_in_days == 365 - def test_throws_unauthorized_exception(self, mock_http_client_with_response): + def test_throws_unauthorized_exception( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_http_client_with_response, + ): mock_http_client_with_response( - self.http_client, + module_instance._http_client, {"message": "Unauthorized"}, 401, {"X-Request-ID": "a-request-id"}, ) with pytest.raises(AuthenticationException) as excinfo: - self.audit_logs.set_retention( - organization_id="org_123456789", - retention_period_in_days=30, + syncify( + module_instance.set_retention( + organization_id="org_123456789", + retention_period_in_days=30, + ) ) assert "(message=Unauthorized, request_id=a-request-id)" == str( excinfo.value ) - class TestGetConfiguration(_TestSetup): - def test_succeeds_with_log_stream(self, capture_and_mock_http_client_request): + class TestGetConfiguration: + def test_succeeds_with_log_stream( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): organization_id = "org_123456789" expected_payload = { @@ -652,10 +765,10 @@ def test_succeeds_with_log_stream(self, capture_and_mock_http_client_request): } request_kwargs = capture_and_mock_http_client_request( - self.http_client, expected_payload, 200 + module_instance._http_client, expected_payload, 200 ) - response = self.audit_logs.get_configuration(organization_id) + response = syncify(module_instance.get_configuration(organization_id)) assert request_kwargs["url"].endswith( f"/organizations/{organization_id}/audit_log_configuration" @@ -669,7 +782,9 @@ def test_succeeds_with_log_stream(self, capture_and_mock_http_client_request): assert response.log_stream.state == "active" def test_succeeds_without_log_stream( - self, capture_and_mock_http_client_request + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, ): organization_id = "org_123456789" @@ -680,26 +795,30 @@ def test_succeeds_without_log_stream( } capture_and_mock_http_client_request( - self.http_client, expected_payload, 200 + module_instance._http_client, expected_payload, 200 ) - response = self.audit_logs.get_configuration(organization_id) + response = syncify(module_instance.get_configuration(organization_id)) assert response.organization_id == organization_id assert response.retention_period_in_days == 30 assert response.state == "inactive" assert response.log_stream is None - def test_throws_unauthorized_exception(self, mock_http_client_with_response): + def test_throws_unauthorized_exception( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_http_client_with_response, + ): mock_http_client_with_response( - self.http_client, + module_instance._http_client, {"message": "Unauthorized"}, 401, {"X-Request-ID": "a-request-id"}, ) with pytest.raises(AuthenticationException) as excinfo: - self.audit_logs.get_configuration("org_123456789") + syncify(module_instance.get_configuration("org_123456789")) assert "(message=Unauthorized, request_id=a-request-id)" == str( excinfo.value From e251b8fc056029f08939be1c48d9f0ac35e599b0 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 21 Jan 2026 15:50:45 -0500 Subject: [PATCH 5/9] it's in now --- tests/smoke_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/smoke_test.py b/tests/smoke_test.py index a4ede46c..71cbc32a 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -112,6 +112,7 @@ def test_async_client_modules_accessible() -> None: # Modules fully supported in async client supported_modules = [ "api_keys", + "audit_logs", "directory_sync", "events", "organizations", @@ -123,7 +124,6 @@ def test_async_client_modules_accessible() -> None: # Modules that exist but raise NotImplementedError not_implemented_modules = [ - "audit_logs", "fga", "mfa", "passwordless", From b06dd5f1f9a628f7d769f2db1d8f2f4a82e0e8ef Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Wed, 21 Jan 2026 19:19:09 -0600 Subject: [PATCH 6/9] feat(audit-logs): Add typed inputs for create_schema Introduces TypedDict definitions for the simplified schema input format: - AuditLogSchemaTargetInput for target definitions - AuditLogSchemaActorInput for actor definitions - MetadataSchemaInput for metadata field type mappings Also adds serialize_schema_options() to transform the simplified format (e.g., {"status": "string"}) to the full JSON Schema format expected by the API. --- src/workos/types/audit_logs/__init__.py | 1 + .../audit_logs/audit_log_schema_input.py | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/workos/types/audit_logs/audit_log_schema_input.py diff --git a/src/workos/types/audit_logs/__init__.py b/src/workos/types/audit_logs/__init__.py index edf14112..6f36daea 100644 --- a/src/workos/types/audit_logs/__init__.py +++ b/src/workos/types/audit_logs/__init__.py @@ -8,4 +8,5 @@ from .audit_log_metadata import * from .audit_log_retention import * from .audit_log_schema import * +from .audit_log_schema_input import * from .list_filters import * diff --git a/src/workos/types/audit_logs/audit_log_schema_input.py b/src/workos/types/audit_logs/audit_log_schema_input.py new file mode 100644 index 00000000..ebba0f25 --- /dev/null +++ b/src/workos/types/audit_logs/audit_log_schema_input.py @@ -0,0 +1,78 @@ +from typing import Any, Dict, Literal, Mapping, Optional, Sequence + +from typing_extensions import NotRequired, TypedDict + +MetadataSchemaInput = Mapping[str, Literal["string", "number", "boolean"]] + + +class AuditLogSchemaTargetInput(TypedDict): + """Input type for target definitions when creating an audit log schema. + + Attributes: + type: The target type identifier (e.g., "team", "user", "document"). + metadata: Optional simplified metadata schema mapping property names to types. + """ + + type: str + metadata: NotRequired[MetadataSchemaInput] + + +class AuditLogSchemaActorInput(TypedDict): + """Input type for actor definition when creating an audit log schema. + + Attributes: + metadata: Simplified metadata schema mapping property names to types. + """ + + metadata: MetadataSchemaInput + + +def _serialize_metadata( + metadata: Optional[MetadataSchemaInput], +) -> Optional[Dict[str, Any]]: + """Transform simplified metadata to full JSON Schema format. + + Transforms {"role": "string"} to: + {"type": "object", "properties": {"role": {"type": "string"}}} + """ + if not metadata: + return None + + properties: Dict[str, Dict[str, str]] = {} + for key, type_value in metadata.items(): + properties[key] = {"type": type_value} + + return {"type": "object", "properties": properties} + + +def serialize_schema_options( + targets: Sequence[AuditLogSchemaTargetInput], + actor: Optional[AuditLogSchemaActorInput] = None, + metadata: Optional[MetadataSchemaInput] = None, +) -> Dict[str, Any]: + """Serialize schema options from simplified format to API format. + + Transforms the simplified input format (matching JS SDK ergonomics) + to the full JSON Schema format expected by the API. + """ + result: Dict[str, Any] = { + "targets": [ + { + "type": target["type"], + **( + {"metadata": _serialize_metadata(target.get("metadata"))} + if target.get("metadata") + else {} + ), + } + for target in targets + ], + } + + if actor is not None: + result["actor"] = {"metadata": _serialize_metadata(actor["metadata"])} + + if metadata is not None: + result["metadata"] = _serialize_metadata(metadata) + + return result From 800d57750db4970528d8a2bf28495d29c679062b Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Wed, 21 Jan 2026 19:19:20 -0600 Subject: [PATCH 7/9] refactor(audit-logs): Use typed inputs in create_schema Replaces generic Mapping[str, Any] parameters with the new typed inputs. Uses shared serialize_schema_options() function instead of inline JSON building, reducing duplication between sync and async implementations. --- src/workos/audit_logs.py | 46 +++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/src/workos/audit_logs.py b/src/workos/audit_logs.py index b3e69d16..1b5a865f 100644 --- a/src/workos/audit_logs.py +++ b/src/workos/audit_logs.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Literal, Mapping, Optional, Protocol, Sequence +from typing import Dict, Literal, Optional, Protocol, Sequence from workos.types.audit_logs import ( AuditLogAction, @@ -9,6 +9,12 @@ AuditLogSchemaListFilters, AuditLogActionListFilters, ) +from workos.types.audit_logs.audit_log_schema_input import ( + AuditLogSchemaActorInput, + AuditLogSchemaTargetInput, + MetadataSchemaInput, + serialize_schema_options, +) from workos.types.audit_logs.audit_log_event import AuditLogEvent from workos.types.list_resource import ListMetadata, ListPage, WorkOSListResource from workos.typing.sync_or_async import SyncOrAsync @@ -98,9 +104,9 @@ def create_schema( self, *, action: str, - targets: Sequence[Mapping[str, Any]], - actor: Optional[Mapping[str, Any]] = None, - metadata: Optional[Mapping[str, Any]] = None, + targets: Sequence[AuditLogSchemaTargetInput], + actor: Optional[AuditLogSchemaActorInput] = None, + metadata: Optional[MetadataSchemaInput] = None, idempotency_key: Optional[str] = None, ) -> SyncOrAsync[AuditLogSchema]: """Create an Audit Log schema for an action. @@ -108,8 +114,12 @@ def create_schema( Kwargs: action (str): The action name for the schema (e.g., 'user.signed_in'). targets (list): List of target definitions with type and optional metadata. + Each target has a 'type' and optional 'metadata' mapping property + names to types (e.g., {"status": "string"}). actor (dict): Optional actor definition with metadata schema. (Optional) + The metadata maps property names to types (e.g., {"role": "string"}). metadata (dict): Optional event-level metadata schema. (Optional) + Maps property names to types (e.g., {"invoice_id": "string"}). idempotency_key (str): Idempotency key. (Optional) Returns: @@ -265,18 +275,12 @@ def create_schema( self, *, action: str, - targets: Sequence[Mapping[str, Any]], - actor: Optional[Mapping[str, Any]] = None, - metadata: Optional[Mapping[str, Any]] = None, + targets: Sequence[AuditLogSchemaTargetInput], + actor: Optional[AuditLogSchemaActorInput] = None, + metadata: Optional[MetadataSchemaInput] = None, idempotency_key: Optional[str] = None, ) -> AuditLogSchema: - json: Dict[str, Any] = { - "targets": list(targets), - } - if actor is not None: - json["actor"] = actor - if metadata is not None: - json["metadata"] = metadata + json = serialize_schema_options(targets, actor, metadata) headers: Dict[str, str] = {} if idempotency_key: @@ -445,18 +449,12 @@ async def create_schema( self, *, action: str, - targets: Sequence[Mapping[str, Any]], - actor: Optional[Mapping[str, Any]] = None, - metadata: Optional[Mapping[str, Any]] = None, + targets: Sequence[AuditLogSchemaTargetInput], + actor: Optional[AuditLogSchemaActorInput] = None, + metadata: Optional[MetadataSchemaInput] = None, idempotency_key: Optional[str] = None, ) -> AuditLogSchema: - json: Dict[str, Any] = { - "targets": list(targets), - } - if actor is not None: - json["actor"] = actor - if metadata is not None: - json["metadata"] = metadata + json = serialize_schema_options(targets, actor, metadata) headers: Dict[str, str] = {} if idempotency_key: From 9393a52798b1bf743c8fbf569e8ca542d826151d Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Wed, 21 Jan 2026 19:19:29 -0600 Subject: [PATCH 8/9] fix(audit-logs): Make optional fields nullable in response models The API can return null for actor in AuditLogSchema (when no custom metadata defined) and for retention_period_in_days in AuditLogRetention (when not configured). Updated models to accept None. --- src/workos/types/audit_logs/audit_log_retention.py | 6 ++++-- src/workos/types/audit_logs/audit_log_schema.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/workos/types/audit_logs/audit_log_retention.py b/src/workos/types/audit_logs/audit_log_retention.py index 1864f5fc..70f5b4d3 100644 --- a/src/workos/types/audit_logs/audit_log_retention.py +++ b/src/workos/types/audit_logs/audit_log_retention.py @@ -1,3 +1,5 @@ +from typing import Optional + from workos.types.workos_model import WorkOSModel @@ -5,7 +7,7 @@ class AuditLogRetention(WorkOSModel): """Representation of a WorkOS audit log retention configuration. Specifies how long audit log events are retained for an organization. - Valid values are 30 and 365 days. + Valid values are 30 and 365 days, or None if not configured. """ - retention_period_in_days: int + retention_period_in_days: Optional[int] = None diff --git a/src/workos/types/audit_logs/audit_log_schema.py b/src/workos/types/audit_logs/audit_log_schema.py index 775ac5cb..a34427fa 100644 --- a/src/workos/types/audit_logs/audit_log_schema.py +++ b/src/workos/types/audit_logs/audit_log_schema.py @@ -43,7 +43,7 @@ class AuditLogSchema(WorkOSModel): object: Literal["audit_log_schema"] = "audit_log_schema" version: int targets: Sequence[AuditLogSchemaTarget] - actor: AuditLogSchemaActor + actor: Optional[AuditLogSchemaActor] = None metadata: Optional[AuditLogSchemaMetadata] = None created_at: Optional[str] = None updated_at: Optional[str] = None From 9eb6738eab5e1d8b2d7817cb9208a7ffcf73e30d Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Wed, 21 Jan 2026 19:19:37 -0600 Subject: [PATCH 9/9] test(audit-logs): Update tests for simplified input format Updates create_schema tests to use the new simplified input format and verifies the serialization to full JSON Schema format. Adds edge case tests for nullable fields (actor in schema, retention_period_in_days). --- tests/test_audit_logs.py | 88 +++++++++++++++++++++++++++++++++------- 1 file changed, 73 insertions(+), 15 deletions(-) diff --git a/tests/test_audit_logs.py b/tests/test_audit_logs.py index bae24783..1ff7929d 100644 --- a/tests/test_audit_logs.py +++ b/tests/test_audit_logs.py @@ -407,6 +407,7 @@ def test_with_actor_and_metadata( ): action = "user.viewed_invoice" + # Response from API uses full JSON Schema format expected_payload = { "object": "audit_log_schema", "version": 1, @@ -436,34 +437,73 @@ def test_with_actor_and_metadata( module_instance._http_client, expected_payload, 201 ) + # Input uses simplified format (like JS SDK) response = syncify( module_instance.create_schema( action=action, targets=[ { "type": "invoice", - "metadata": { - "type": "object", - "properties": {"status": {"type": "string"}}, - }, + "metadata": {"status": "string"}, } ], - actor={ - "metadata": { - "type": "object", - "properties": {"role": {"type": "string"}}, - } - }, - metadata={ + actor={"metadata": {"role": "string"}}, + metadata={"transactionId": "string"}, + ) + ) + + # Verify serialization transforms to full JSON Schema format + assert request_kwargs["json"]["actor"] == { + "metadata": { + "type": "object", + "properties": {"role": {"type": "string"}}, + } + } + assert request_kwargs["json"]["metadata"] == { + "type": "object", + "properties": {"transactionId": {"type": "string"}}, + } + assert request_kwargs["json"]["targets"] == [ + { + "type": "invoice", + "metadata": { "type": "object", - "properties": {"transactionId": {"type": "string"}}, + "properties": {"status": {"type": "string"}}, }, + } + ] + assert response.metadata is not None + + def test_without_actor_in_response( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): + """Test that schema can be parsed when actor is not in the response.""" + action = "user.signed_in" + + # Response without actor field (backend omits when no custom metadata) + expected_payload = { + "object": "audit_log_schema", + "version": 1, + "targets": [{"type": "user"}], + "metadata": None, + "created_at": "2024-10-14T15:09:44.537Z", + } + + capture_and_mock_http_client_request( + module_instance._http_client, expected_payload, 201 + ) + + response = syncify( + module_instance.create_schema( + action=action, + targets=[{"type": "user"}], ) ) - assert request_kwargs["json"]["actor"] is not None - assert request_kwargs["json"]["metadata"] is not None - assert response.metadata is not None + assert response.version == 1 + assert response.actor is None def test_throws_unauthorized_exception( self, @@ -672,6 +712,24 @@ def test_succeeds( assert request_kwargs["method"] == "get" assert response.retention_period_in_days == 30 + def test_succeeds_with_null_retention( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): + """Test that retention can be parsed when retention_period_in_days is null.""" + organization_id = "org_123456789" + + expected_payload = {"retention_period_in_days": None} + + capture_and_mock_http_client_request( + module_instance._http_client, expected_payload, 200 + ) + + response = syncify(module_instance.get_retention(organization_id)) + + assert response.retention_period_in_days is None + def test_throws_unauthorized_exception( self, module_instance: Union[AuditLogs, AsyncAuditLogs],