diff --git a/.oagen-manifest.json b/.oagen-manifest.json index b8c8d278..518993d7 100644 --- a/.oagen-manifest.json +++ b/.oagen-manifest.json @@ -1,7 +1,7 @@ { "version": 1, "language": "python", - "generatedAt": "2026-04-20T20:55:24.754Z", + "generatedAt": "2026-04-22T22:10:13.399Z", "files": [ "src/workos/_client.py", "src/workos/admin_portal/__init__.py", @@ -814,7 +814,6 @@ "tests/fixtures/event_context_actor.json", "tests/fixtures/event_context_google_analytics_session.json", "tests/fixtures/event_list_list_metadata.json", - "tests/fixtures/event_schema.json", "tests/fixtures/external_auth_complete_response.json", "tests/fixtures/feature_flag.json", "tests/fixtures/feature_flag_owner.json", @@ -885,7 +884,6 @@ "tests/fixtures/list_directory.json", "tests/fixtures/list_directory_group.json", "tests/fixtures/list_directory_user_with_groups.json", - "tests/fixtures/list_event_schema.json", "tests/fixtures/list_flag.json", "tests/fixtures/list_organization.json", "tests/fixtures/list_role_assignment.json", diff --git a/scripts/ci b/scripts/ci new file mode 100755 index 00000000..edfa1015 --- /dev/null +++ b/scripts/ci @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -e + +./scripts/test.sh --ci diff --git a/src/workos/events/_resource.py b/src/workos/events/_resource.py index c90ca33a..4271433b 100644 --- a/src/workos/events/_resource.py +++ b/src/workos/events/_resource.py @@ -2,13 +2,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List, Optional, Union +from typing import TYPE_CHECKING, List, Optional, Union, cast if TYPE_CHECKING: from .._client import AsyncWorkOSClient, WorkOSClient from .._types import RequestOptions, enum_value -from .models import EventSchema +from .models import EventSchema, EventSchemaVariant from .models import EventsOrder from .._pagination import AsyncPage, SyncPage @@ -31,7 +31,7 @@ def list_events( range_end: Optional[str] = None, organization_id: Optional[str] = None, request_options: Optional[RequestOptions] = None, - ) -> SyncPage[EventSchema]: + ) -> SyncPage[EventSchemaVariant]: """List events List events for the current environment. @@ -48,7 +48,7 @@ def list_events( request_options: Per-request options. Supports extra_headers, timeout, max_retries, and base_url override. Returns: - SyncPage[EventSchema] + SyncPage[EventSchemaVariant] Raises: BadRequestError: If the request is malformed (400). @@ -73,12 +73,15 @@ def list_events( }.items() if v is not None } - return self._client.request_page( - method="get", - path="events", - model=EventSchema, - params=params, - request_options=request_options, + return cast( + SyncPage[EventSchemaVariant], + self._client.request_page( + method="get", + path="events", + model=EventSchema, # type: ignore[arg-type] # dispatcher; pagination only calls from_dict + params=params, + request_options=request_options, + ), ) @@ -100,7 +103,7 @@ async def list_events( range_end: Optional[str] = None, organization_id: Optional[str] = None, request_options: Optional[RequestOptions] = None, - ) -> AsyncPage[EventSchema]: + ) -> AsyncPage[EventSchemaVariant]: """List events List events for the current environment. @@ -117,7 +120,7 @@ async def list_events( request_options: Per-request options. Supports extra_headers, timeout, max_retries, and base_url override. Returns: - AsyncPage[EventSchema] + AsyncPage[EventSchemaVariant] Raises: BadRequestError: If the request is malformed (400). @@ -142,10 +145,13 @@ async def list_events( }.items() if v is not None } - return await self._client.request_page( - method="get", - path="events", - model=EventSchema, - params=params, - request_options=request_options, + return cast( + AsyncPage[EventSchemaVariant], + await self._client.request_page( + method="get", + path="events", + model=EventSchema, # type: ignore[arg-type] # dispatcher; pagination only calls from_dict + params=params, + request_options=request_options, + ), ) diff --git a/src/workos/events/models/__init__.py b/src/workos/events/models/__init__.py index 565b2f2e..0f13cad7 100644 --- a/src/workos/events/models/__init__.py +++ b/src/workos/events/models/__init__.py @@ -2,4 +2,6 @@ from .event_list_list_metadata import EventListListMetadata as EventListListMetadata from .event_schema import EventSchema as EventSchema +from .event_schema import EventSchemaUnknown as EventSchemaUnknown +from .event_schema import EventSchemaVariant as EventSchemaVariant from .events_order import EventsOrder as EventsOrder diff --git a/src/workos/events/models/event_schema.py b/src/workos/events/models/event_schema.py index 7ebd7a28..55d806f0 100644 --- a/src/workos/events/models/event_schema.py +++ b/src/workos/events/models/event_schema.py @@ -3,52 +3,355 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime -from typing import Any, Dict, Literal, Optional +from typing import Any, ClassVar, Dict, Union, cast from workos._types import _raise_deserialize_error -from workos._types import _format_datetime, _parse_datetime + +from workos.common.models.action_authentication_denied import ActionAuthenticationDenied +from workos.common.models.action_user_registration_denied import ( + ActionUserRegistrationDenied, +) +from workos.common.models.api_key_created import ApiKeyCreated +from workos.common.models.api_key_revoked import ApiKeyRevoked +from workos.common.models.authentication_email_verification_failed import ( + AuthenticationEmailVerificationFailed, +) +from workos.common.models.authentication_email_verification_succeeded import ( + AuthenticationEmailVerificationSucceeded, +) +from workos.common.models.authentication_magic_auth_failed import ( + AuthenticationMagicAuthFailed, +) +from workos.common.models.authentication_magic_auth_succeeded import ( + AuthenticationMagicAuthSucceeded, +) +from workos.common.models.authentication_mfa_failed import AuthenticationMFAFailed +from workos.common.models.authentication_mfa_succeeded import AuthenticationMFASucceeded +from workos.common.models.authentication_oauth_failed import AuthenticationOAuthFailed +from workos.common.models.authentication_oauth_succeeded import ( + AuthenticationOAuthSucceeded, +) +from workos.common.models.authentication_passkey_failed import ( + AuthenticationPasskeyFailed, +) +from workos.common.models.authentication_passkey_succeeded import ( + AuthenticationPasskeySucceeded, +) +from workos.common.models.authentication_password_failed import ( + AuthenticationPasswordFailed, +) +from workos.common.models.authentication_password_succeeded import ( + AuthenticationPasswordSucceeded, +) +from workos.common.models.authentication_radar_risk_detected import ( + AuthenticationRadarRiskDetected, +) +from workos.common.models.authentication_sso_failed import AuthenticationSSOFailed +from workos.common.models.authentication_sso_started import AuthenticationSSOStarted +from workos.common.models.authentication_sso_succeeded import AuthenticationSSOSucceeded +from workos.common.models.authentication_sso_timed_out import AuthenticationSSOTimedOut +from workos.common.models.connection_activated import ConnectionActivated +from workos.common.models.connection_deactivated import ConnectionDeactivated +from workos.common.models.connection_deleted import ConnectionDeleted +from workos.common.models.connection_saml_certificate_renewal_required import ( + ConnectionSAMLCertificateRenewalRequired, +) +from workos.common.models.connection_saml_certificate_renewed import ( + ConnectionSAMLCertificateRenewed, +) +from workos.common.models.dsync_activated import DsyncActivated +from workos.common.models.dsync_deactivated import DsyncDeactivated +from workos.common.models.dsync_deleted import DsyncDeleted +from workos.common.models.dsync_group_created import DsyncGroupCreated +from workos.common.models.dsync_group_deleted import DsyncGroupDeleted +from workos.common.models.dsync_group_updated import DsyncGroupUpdated +from workos.common.models.dsync_group_user_added import DsyncGroupUserAdded +from workos.common.models.dsync_group_user_removed import DsyncGroupUserRemoved +from workos.common.models.dsync_user_created import DsyncUserCreated +from workos.common.models.dsync_user_deleted import DsyncUserDeleted +from workos.common.models.dsync_user_updated import DsyncUserUpdated +from workos.common.models.email_verification_created import EmailVerificationCreated +from workos.common.models.flag_created import FlagCreated +from workos.common.models.flag_deleted import FlagDeleted +from workos.common.models.flag_rule_updated import FlagRuleUpdated +from workos.common.models.flag_updated import FlagUpdated +from workos.common.models.group_created import GroupCreated +from workos.common.models.group_deleted import GroupDeleted +from workos.common.models.group_member_added import GroupMemberAdded +from workos.common.models.group_member_removed import GroupMemberRemoved +from workos.common.models.group_updated import GroupUpdated +from workos.common.models.invitation_accepted import InvitationAccepted +from workos.common.models.invitation_created import InvitationCreated +from workos.common.models.invitation_resent import InvitationResent +from workos.common.models.invitation_revoked import InvitationRevoked +from workos.common.models.magic_auth_created import MagicAuthCreated +from workos.common.models.organization_created import OrganizationCreated +from workos.common.models.organization_deleted import OrganizationDeleted +from workos.common.models.organization_domain_created import OrganizationDomainCreated +from workos.common.models.organization_domain_deleted import OrganizationDomainDeleted +from workos.common.models.organization_domain_updated import OrganizationDomainUpdated +from workos.common.models.organization_domain_verification_failed import ( + OrganizationDomainVerificationFailed, +) +from workos.common.models.organization_domain_verified import OrganizationDomainVerified +from workos.common.models.organization_membership_created import ( + OrganizationMembershipCreated, +) +from workos.common.models.organization_membership_deleted import ( + OrganizationMembershipDeleted, +) +from workos.common.models.organization_membership_updated import ( + OrganizationMembershipUpdated, +) +from workos.common.models.organization_role_created import OrganizationRoleCreated +from workos.common.models.organization_role_deleted import OrganizationRoleDeleted +from workos.common.models.organization_role_updated import OrganizationRoleUpdated +from workos.common.models.organization_updated import OrganizationUpdated +from workos.common.models.password_reset_created import PasswordResetCreated +from workos.common.models.password_reset_succeeded import PasswordResetSucceeded +from workos.common.models.permission_created import PermissionCreated +from workos.common.models.permission_deleted import PermissionDeleted +from workos.common.models.permission_updated import PermissionUpdated +from workos.common.models.role_created import RoleCreated +from workos.common.models.role_deleted import RoleDeleted +from workos.common.models.role_updated import RoleUpdated +from workos.common.models.session_created import SessionCreated +from workos.common.models.session_revoked import SessionRevoked +from workos.common.models.user_created import UserCreated +from workos.common.models.user_deleted import UserDeleted +from workos.common.models.user_updated import UserUpdated +from workos.common.models.vault_byok_key_verification_completed import ( + VaultByokKeyVerificationCompleted, +) +from workos.common.models.vault_data_created import VaultDataCreated +from workos.common.models.vault_data_deleted import VaultDataDeleted +from workos.common.models.vault_data_read import VaultDataRead +from workos.common.models.vault_data_updated import VaultDataUpdated +from workos.common.models.vault_dek_decrypted import VaultDekDecrypted +from workos.common.models.vault_dek_read import VaultDekRead +from workos.common.models.vault_kek_created import VaultKekCreated +from workos.common.models.vault_metadata_read import VaultMetadataRead +from workos.common.models.vault_names_listed import VaultNamesListed @dataclass(slots=True) +class EventSchemaUnknown: + """Unknown variant of EventSchema not yet recognized by this SDK version.""" + + raw_data: Dict[str, Any] + """The raw payload, preserved so callers can still inspect the data.""" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "EventSchemaUnknown": + """Wrap raw data in an unknown variant.""" + return cls(raw_data=data) + + def to_dict(self) -> Dict[str, Any]: + """Return the original raw data.""" + return dict(self.raw_data) + + +EventSchemaVariant = Union[ + ActionAuthenticationDenied, + ActionUserRegistrationDenied, + ApiKeyCreated, + ApiKeyRevoked, + AuthenticationEmailVerificationFailed, + AuthenticationEmailVerificationSucceeded, + AuthenticationMagicAuthFailed, + AuthenticationMagicAuthSucceeded, + AuthenticationMFAFailed, + AuthenticationMFASucceeded, + AuthenticationOAuthFailed, + AuthenticationOAuthSucceeded, + AuthenticationPasskeyFailed, + AuthenticationPasskeySucceeded, + AuthenticationPasswordFailed, + AuthenticationPasswordSucceeded, + AuthenticationRadarRiskDetected, + AuthenticationSSOFailed, + AuthenticationSSOStarted, + AuthenticationSSOSucceeded, + AuthenticationSSOTimedOut, + ConnectionActivated, + ConnectionDeactivated, + ConnectionDeleted, + ConnectionSAMLCertificateRenewalRequired, + ConnectionSAMLCertificateRenewed, + DsyncActivated, + DsyncDeactivated, + DsyncDeleted, + DsyncGroupCreated, + DsyncGroupDeleted, + DsyncGroupUpdated, + DsyncGroupUserAdded, + DsyncGroupUserRemoved, + DsyncUserCreated, + DsyncUserDeleted, + DsyncUserUpdated, + EmailVerificationCreated, + FlagCreated, + FlagDeleted, + FlagRuleUpdated, + FlagUpdated, + GroupCreated, + GroupDeleted, + GroupMemberAdded, + GroupMemberRemoved, + GroupUpdated, + InvitationAccepted, + InvitationCreated, + InvitationResent, + InvitationRevoked, + MagicAuthCreated, + OrganizationCreated, + OrganizationDeleted, + OrganizationDomainCreated, + OrganizationDomainDeleted, + OrganizationDomainUpdated, + OrganizationDomainVerificationFailed, + OrganizationDomainVerified, + OrganizationMembershipCreated, + OrganizationMembershipDeleted, + OrganizationMembershipUpdated, + OrganizationRoleCreated, + OrganizationRoleDeleted, + OrganizationRoleUpdated, + OrganizationUpdated, + PasswordResetCreated, + PasswordResetSucceeded, + PermissionCreated, + PermissionDeleted, + PermissionUpdated, + RoleCreated, + RoleDeleted, + RoleUpdated, + SessionCreated, + SessionRevoked, + UserCreated, + UserDeleted, + UserUpdated, + VaultByokKeyVerificationCompleted, + VaultDataCreated, + VaultDataDeleted, + VaultDataRead, + VaultDataUpdated, + VaultDekDecrypted, + VaultDekRead, + VaultKekCreated, + VaultMetadataRead, + VaultNamesListed, + EventSchemaUnknown, +] + + class EventSchema: """An event emitted by WorkOS.""" - object: Literal["event"] - """Distinguishes the Event object.""" - id: str - """Unique identifier for the Event.""" - event: str - """The type of event that occurred.""" - data: Dict[str, Any] - """The event payload.""" - created_at: datetime - """An ISO 8601 timestamp.""" - context: Optional[Dict[str, Any]] = None - """Additional context about the event.""" + _DISPATCH: ClassVar[Dict[str, type]] = { + "action.authentication.denied": ActionAuthenticationDenied, + "action.user_registration.denied": ActionUserRegistrationDenied, + "api_key.created": ApiKeyCreated, + "api_key.revoked": ApiKeyRevoked, + "authentication.email_verification_failed": AuthenticationEmailVerificationFailed, + "authentication.email_verification_succeeded": AuthenticationEmailVerificationSucceeded, + "authentication.magic_auth_failed": AuthenticationMagicAuthFailed, + "authentication.magic_auth_succeeded": AuthenticationMagicAuthSucceeded, + "authentication.mfa_failed": AuthenticationMFAFailed, + "authentication.mfa_succeeded": AuthenticationMFASucceeded, + "authentication.oauth_failed": AuthenticationOAuthFailed, + "authentication.oauth_succeeded": AuthenticationOAuthSucceeded, + "authentication.passkey_failed": AuthenticationPasskeyFailed, + "authentication.passkey_succeeded": AuthenticationPasskeySucceeded, + "authentication.password_failed": AuthenticationPasswordFailed, + "authentication.password_succeeded": AuthenticationPasswordSucceeded, + "authentication.radar_risk_detected": AuthenticationRadarRiskDetected, + "authentication.sso_failed": AuthenticationSSOFailed, + "authentication.sso_started": AuthenticationSSOStarted, + "authentication.sso_succeeded": AuthenticationSSOSucceeded, + "authentication.sso_timed_out": AuthenticationSSOTimedOut, + "connection.activated": ConnectionActivated, + "connection.deactivated": ConnectionDeactivated, + "connection.deleted": ConnectionDeleted, + "connection.saml_certificate_renewal_required": ConnectionSAMLCertificateRenewalRequired, + "connection.saml_certificate_renewed": ConnectionSAMLCertificateRenewed, + "dsync.activated": DsyncActivated, + "dsync.deactivated": DsyncDeactivated, + "dsync.deleted": DsyncDeleted, + "dsync.group.created": DsyncGroupCreated, + "dsync.group.deleted": DsyncGroupDeleted, + "dsync.group.updated": DsyncGroupUpdated, + "dsync.group.user_added": DsyncGroupUserAdded, + "dsync.group.user_removed": DsyncGroupUserRemoved, + "dsync.user.created": DsyncUserCreated, + "dsync.user.deleted": DsyncUserDeleted, + "dsync.user.updated": DsyncUserUpdated, + "email_verification.created": EmailVerificationCreated, + "flag.created": FlagCreated, + "flag.deleted": FlagDeleted, + "flag.rule_updated": FlagRuleUpdated, + "flag.updated": FlagUpdated, + "group.created": GroupCreated, + "group.deleted": GroupDeleted, + "group.member_added": GroupMemberAdded, + "group.member_removed": GroupMemberRemoved, + "group.updated": GroupUpdated, + "invitation.accepted": InvitationAccepted, + "invitation.created": InvitationCreated, + "invitation.resent": InvitationResent, + "invitation.revoked": InvitationRevoked, + "magic_auth.created": MagicAuthCreated, + "organization_domain.created": OrganizationDomainCreated, + "organization_domain.deleted": OrganizationDomainDeleted, + "organization_domain.updated": OrganizationDomainUpdated, + "organization_domain.verification_failed": OrganizationDomainVerificationFailed, + "organization_domain.verified": OrganizationDomainVerified, + "organization_membership.created": OrganizationMembershipCreated, + "organization_membership.deleted": OrganizationMembershipDeleted, + "organization_membership.updated": OrganizationMembershipUpdated, + "organization_role.created": OrganizationRoleCreated, + "organization_role.deleted": OrganizationRoleDeleted, + "organization_role.updated": OrganizationRoleUpdated, + "organization.created": OrganizationCreated, + "organization.deleted": OrganizationDeleted, + "organization.updated": OrganizationUpdated, + "password_reset.created": PasswordResetCreated, + "password_reset.succeeded": PasswordResetSucceeded, + "permission.created": PermissionCreated, + "permission.deleted": PermissionDeleted, + "permission.updated": PermissionUpdated, + "role.created": RoleCreated, + "role.deleted": RoleDeleted, + "role.updated": RoleUpdated, + "session.created": SessionCreated, + "session.revoked": SessionRevoked, + "user.created": UserCreated, + "user.deleted": UserDeleted, + "user.updated": UserUpdated, + "vault.byok_key.verification_completed": VaultByokKeyVerificationCompleted, + "vault.data.created": VaultDataCreated, + "vault.data.deleted": VaultDataDeleted, + "vault.data.read": VaultDataRead, + "vault.data.updated": VaultDataUpdated, + "vault.dek.decrypted": VaultDekDecrypted, + "vault.dek.read": VaultDekRead, + "vault.kek.created": VaultKekCreated, + "vault.metadata.read": VaultMetadataRead, + "vault.names.listed": VaultNamesListed, + } @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "EventSchema": - """Deserialize from a dictionary.""" - try: - return cls( - object=data["object"], - id=data["id"], - event=data["event"], - data=data["data"], - created_at=_parse_datetime(data["created_at"]), - context=data.get("context"), + def from_dict(cls, data: Dict[str, Any]) -> "EventSchemaVariant": + """Deserialize from a dictionary, dispatching to the correct variant.""" + if "event" not in data: + _raise_deserialize_error( + "EventSchema", ValueError("Missing required field 'event'") ) - except (KeyError, ValueError) as e: - _raise_deserialize_error("EventSchema", e) - - def to_dict(self) -> Dict[str, Any]: - """Serialize to a dictionary.""" - result: Dict[str, Any] = {} - result["object"] = self.object - result["id"] = self.id - result["event"] = self.event - result["data"] = self.data - result["created_at"] = _format_datetime(self.created_at) - if self.context is not None: - result["context"] = self.context - return result + disc_value = data["event"] + if disc_value is None: + _raise_deserialize_error( + "EventSchema", ValueError("event must not be None") + ) + dispatch_cls = cls._DISPATCH.get(disc_value) + if dispatch_cls is not None: + return cast("EventSchemaVariant", dispatch_cls.from_dict(data)) + return EventSchemaUnknown.from_dict(data) diff --git a/src/workos/webhooks/_resource.py b/src/workos/webhooks/_resource.py index 311e53ce..7f0a27f2 100644 --- a/src/workos/webhooks/_resource.py +++ b/src/workos/webhooks/_resource.py @@ -200,7 +200,7 @@ def verify_event( event_signature: str, secret: str, tolerance: Optional[int] = None, - ) -> "EventSchema": + ) -> "EventSchemaVariant": """Verify and deserialize the signature of a Webhook event. Args: @@ -210,7 +210,7 @@ def verify_event( tolerance: Maximum age of the event in seconds. Defaults to 180. Returns: - EventSchema: The deserialized webhook event. + EventSchemaVariant: The deserialized webhook event. Raises: ValueError: If the signature is invalid or the event is too old. @@ -457,7 +457,7 @@ def verify_event( event_signature: str, secret: str, tolerance: Optional[int] = None, - ) -> "EventSchema": + ) -> "EventSchemaVariant": """Verify and deserialize the signature of a Webhook event. Args: @@ -467,7 +467,7 @@ def verify_event( tolerance: Maximum age of the event in seconds. Defaults to 180. Returns: - EventSchema: The deserialized webhook event. + EventSchemaVariant: The deserialized webhook event. Raises: ValueError: If the signature is invalid or the event is too old. @@ -542,5 +542,5 @@ def verify_header( # @oagen-ignore-start if TYPE_CHECKING: - from workos.events.models import EventSchema + from workos.events.models import EventSchemaVariant # @oagen-ignore-end diff --git a/src/workos/webhooks/_verification.py b/src/workos/webhooks/_verification.py index 4387a5e1..3dc99582 100644 --- a/src/workos/webhooks/_verification.py +++ b/src/workos/webhooks/_verification.py @@ -8,10 +8,13 @@ import hmac import json import time -from typing import Optional, Union +from typing import TYPE_CHECKING, Optional, Union from workos.events.models import EventSchema +if TYPE_CHECKING: + from workos.events.models import EventSchemaVariant + WebhookPayload = Union[bytes, bytearray] DEFAULT_TOLERANCE = 180 # seconds @@ -23,7 +26,7 @@ def verify_event( event_signature: str, secret: str, tolerance: Optional[int] = DEFAULT_TOLERANCE, -) -> EventSchema: +) -> EventSchemaVariant: """Verify and deserialize the signature of a Webhook event. Args: @@ -33,7 +36,7 @@ def verify_event( tolerance: The number of seconds the Webhook event is valid for. (Optional) Returns: - EventSchema: The deserialized webhook event. + EventSchemaVariant: The deserialized webhook event. Raises: ValueError: If the signature cannot be verified or the timestamp is out of range. diff --git a/tests/test_events.py b/tests/test_events.py index 034e3951..387146f2 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -5,6 +5,7 @@ from workos import WorkOSClient, AsyncWorkOSClient from tests.generated_helpers import load_fixture +from workos.common.models import ActionAuthenticationDenied from workos.events.models import EventsOrder from workos._pagination import AsyncPage, SyncPage from workos._errors import ( @@ -20,17 +21,15 @@ class TestEvents: def test_list_events(self, workos, httpx_mock): httpx_mock.add_response( - json=load_fixture("list_event_schema.json"), + json={ + "data": [load_fixture("action_authentication_denied.json")], + "list_metadata": {}, + }, ) page = workos.events.list_events() assert isinstance(page, SyncPage) - assert isinstance(page.data, list) - - def test_list_events_empty_page(self, workos, httpx_mock): - httpx_mock.add_response(json={"data": [], "list_metadata": {}}) - page = workos.events.list_events() - assert isinstance(page, SyncPage) - assert page.data == [] + assert len(page.data) == 1 + assert isinstance(page.data[0], ActionAuthenticationDenied) def test_list_events_encodes_query_params(self, workos, httpx_mock): httpx_mock.add_response(json={"data": [], "list_metadata": {}}) @@ -133,17 +132,16 @@ def test_list_events_unprocessable(self, httpx_mock): class TestAsyncEvents: @pytest.mark.asyncio async def test_list_events(self, async_workos, httpx_mock): - httpx_mock.add_response(json=load_fixture("list_event_schema.json")) - page = await async_workos.events.list_events() - assert isinstance(page, AsyncPage) - assert isinstance(page.data, list) - - @pytest.mark.asyncio - async def test_list_events_empty_page(self, async_workos, httpx_mock): - httpx_mock.add_response(json={"data": [], "list_metadata": {}}) + httpx_mock.add_response( + json={ + "data": [load_fixture("action_authentication_denied.json")], + "list_metadata": {}, + }, + ) page = await async_workos.events.list_events() assert isinstance(page, AsyncPage) - assert page.data == [] + assert len(page.data) == 1 + assert isinstance(page.data[0], ActionAuthenticationDenied) @pytest.mark.asyncio async def test_list_events_encodes_query_params(self, async_workos, httpx_mock): diff --git a/tests/test_models_round_trip.py b/tests/test_models_round_trip.py index 9a7b84bb..8ee7ba69 100644 --- a/tests/test_models_round_trip.py +++ b/tests/test_models_round_trip.py @@ -2,6 +2,8 @@ """Model round-trip tests: from_dict(to_dict()) preserves data.""" +import pytest + from tests.generated_helpers import load_fixture from workos.admin_portal.models import ( @@ -288,7 +290,7 @@ DirectoryUserWithGroups, DirectoryUserWithGroupsEmail, ) -from workos.events.models import EventListListMetadata, EventSchema +from workos.events.models import EventListListMetadata, EventSchema, EventSchemaUnknown from workos.feature_flags.models import FeatureFlag, FeatureFlagOwner, Flag, FlagOwner from workos.multi_factor_auth.models import ( AuthenticationChallenge, @@ -2187,78 +2189,6 @@ def test_user_preserves_nullable_fields(self): assert serialized["last_sign_in_at"] is None assert serialized["locale"] is None - def test_event_schema_round_trip(self): - data = load_fixture("event_schema.json") - instance = EventSchema.from_dict(data) - serialized = instance.to_dict() - assert serialized == data - restored = EventSchema.from_dict(serialized) - assert restored.to_dict() == serialized - - def test_event_schema_minimal_payload(self): - data = { - "object": "event", - "id": "event_01EHZNVPK3SFK441A1RGBFSHRT", - "event": "dsync.user.created", - "data": { - "id": "directory_user_01E1JG7J09H96KYP8HM9B0G5SJ", - "directory_id": "directory_01ECAZ4NV9QMV47GW873HDCX74", - "organization_id": "org_01EZTR6WYX1A0DSE2CYMGXQ24Y", - "state": "active", - "email": "veda@foo-corp.com", - "emails": [ - {"primary": True, "type": "work", "value": "veda@foo-corp.com"} - ], - "idp_id": "2836", - "object": "directory_user", - "username": "veda@foo-corp.com", - "last_name": "Torp", - "first_name": "Veda", - "raw_attributes": {}, - "custom_attributes": {}, - "created_at": "2021-06-25T19:07:33.155Z", - "updated_at": "2021-06-25T19:07:33.155Z", - }, - "created_at": "2026-01-15T12:00:00.000Z", - } - instance = EventSchema.from_dict(data) - serialized = instance.to_dict() - assert serialized["object"] == data["object"] - assert serialized["id"] == data["id"] - assert serialized["event"] == data["event"] - assert serialized["data"] == data["data"] - assert serialized["created_at"] == data["created_at"] - - def test_event_schema_omits_absent_optional_non_nullable_fields(self): - data = { - "object": "event", - "id": "event_01EHZNVPK3SFK441A1RGBFSHRT", - "event": "dsync.user.created", - "data": { - "id": "directory_user_01E1JG7J09H96KYP8HM9B0G5SJ", - "directory_id": "directory_01ECAZ4NV9QMV47GW873HDCX74", - "organization_id": "org_01EZTR6WYX1A0DSE2CYMGXQ24Y", - "state": "active", - "email": "veda@foo-corp.com", - "emails": [ - {"primary": True, "type": "work", "value": "veda@foo-corp.com"} - ], - "idp_id": "2836", - "object": "directory_user", - "username": "veda@foo-corp.com", - "last_name": "Torp", - "first_name": "Veda", - "raw_attributes": {}, - "custom_attributes": {}, - "created_at": "2021-06-25T19:07:33.155Z", - "updated_at": "2021-06-25T19:07:33.155Z", - }, - "created_at": "2026-01-15T12:00:00.000Z", - } - instance = EventSchema.from_dict(data) - serialized = instance.to_dict() - assert "context" not in serialized - def test_action_authentication_denied_round_trip(self): data = load_fixture("action_authentication_denied.json") instance = ActionAuthenticationDenied.from_dict(data) @@ -16318,3 +16248,29 @@ def test_data_integrations_list_response_data_connected_account_round_trips_unkn } instance = DataIntegrationsListResponseDataConnectedAccount.from_dict(data) assert instance.to_dict() == data + + +class TestDiscriminatorDispatch: + def test_event_schema_dispatches_known_variant(self): + data = load_fixture("action_authentication_denied.json") + result = EventSchema.from_dict(data) + assert isinstance(result, ActionAuthenticationDenied) + + def test_event_schema_returns_unknown_for_unrecognized_type(self): + data = load_fixture("action_authentication_denied.json") + data = {**data, "event": "future.unrecognized.type"} + result = EventSchema.from_dict(data) + assert isinstance(result, EventSchemaUnknown) + assert result.raw_data == data + + def test_event_schema_raises_on_missing_discriminator(self): + data = load_fixture("action_authentication_denied.json") + data = {k: v for k, v in data.items() if k != "event"} + with pytest.raises(Exception): + EventSchema.from_dict(data) + + def test_event_schema_raises_on_none_discriminator(self): + data = load_fixture("action_authentication_denied.json") + data = {**data, "event": None} + with pytest.raises(Exception): + EventSchema.from_dict(data) diff --git a/tests/test_webhook_verification.py b/tests/test_webhook_verification.py index 40c5d84b..aaa2ea05 100644 --- a/tests/test_webhook_verification.py +++ b/tests/test_webhook_verification.py @@ -4,7 +4,7 @@ import time import pytest -from workos.events.models import EventSchema +from workos.common.models.user_created import UserCreated from workos.webhooks._verification import ( verify_event as standalone_verify_event, verify_header as standalone_verify_header, @@ -29,7 +29,19 @@ def _make_sig_header(body: str, secret: str, timestamp_ms: int = 0) -> str: "object": "event", "id": "evt_01", "event": "user.created", - "data": {"id": "user_01", "email": "test@example.com"}, + "data": { + "object": "user", + "id": "user_01", + "email": "test@example.com", + "email_verified": True, + "first_name": None, + "last_name": None, + "profile_picture_url": None, + "external_id": None, + "last_sign_in_at": None, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + }, "created_at": "2024-01-01T00:00:00Z", } ) @@ -42,7 +54,7 @@ def test_verify_event_valid(self, workos): result = workos.webhooks.verify_event( event_body=SAMPLE_EVENT, event_signature=sig, secret=SECRET ) - assert isinstance(result, EventSchema) + assert isinstance(result, UserCreated) assert result.id == "evt_01" def test_verify_event_valid_bytes(self, workos): @@ -50,7 +62,7 @@ def test_verify_event_valid_bytes(self, workos): result = workos.webhooks.verify_event( event_body=SAMPLE_EVENT.encode("utf-8"), event_signature=sig, secret=SECRET ) - assert isinstance(result, EventSchema) + assert isinstance(result, UserCreated) assert result.id == "evt_01" def test_verify_event_invalid_signature(self, workos): @@ -77,7 +89,7 @@ def test_verify_event_custom_tolerance(self, workos): result = workos.webhooks.verify_event( event_body=SAMPLE_EVENT, event_signature=sig, secret=SECRET, tolerance=60 ) - assert isinstance(result, EventSchema) + assert isinstance(result, UserCreated) assert result.id == "evt_01" def test_verify_event_malformed_header(self, workos): @@ -119,7 +131,7 @@ def test_standalone_verify_event(self): result = standalone_verify_event( event_body=SAMPLE_EVENT.encode("utf-8"), event_signature=sig, secret=SECRET ) - assert isinstance(result, EventSchema) + assert isinstance(result, UserCreated) assert result.id == "evt_01" def test_standalone_verify_event_invalid(self):