diff --git a/tests/conftest.py b/tests/conftest.py index d671ad8c..cab8fa0e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,17 +41,19 @@ def mock(*args, **kwargs): @pytest.fixture -def capture_and_mock_requests(monkeypatch): - def inner(): - captured_requests = [] +def capture_and_mock_request(monkeypatch): + def inner(method, response_dict, status_code): + request_args = [] + request_kwargs = {} - def capture(*args, **kwargs): - captured_requests.append((args, kwargs)) - return MockResponse({}, 200) + def capture_and_mock(*args, **kwargs): + request_args.extend(args) + request_kwargs.update(kwargs) - monkeypatch.setattr(requests, "get", capture) - monkeypatch.setattr(requests, "post", capture) + return MockResponse(response_dict, status_code) - return captured_requests + monkeypatch.setattr(requests, method, capture_and_mock) + + return (request_args, request_kwargs) return inner diff --git a/tests/test_audit_trail.py b/tests/test_audit_trail.py index 2645dee9..72f2908e 100644 --- a/tests/test_audit_trail.py +++ b/tests/test_audit_trail.py @@ -23,17 +23,18 @@ def test_create_audit_trail_event_succeeds(self, mock_request_method): "actor_id": "user_12345", "target_name": "Ryota Yamasato", "target_id": "user_67890", - "occurred_at": datetime.utcnow().isoformat(), + "occurred_at": datetime.now().isoformat(), "metadata": {"a": "b"}, } mock_response = Response() mock_response.status_code = 200 mock_request_method("post", mock_response, 200) - response = self.audit_trail.create_event(event) - assert response.status_code == 200 + + result = self.audit_trail.create_event(event) + assert result == True def test_create_audit_trail_event_fails_with_long_metadata(self): - with pytest.raises(Exception, match=r"Number of metadata keys exceeds .*"): + with pytest.raises(ValueError, match=r"Number of metadata keys exceeds .*"): metadata = {str(num): num for num in range(51)} event = { "group": "Terrace House", @@ -48,3 +49,155 @@ def test_create_audit_trail_event_fails_with_long_metadata(self): "metadata": metadata, } self.audit_trail.create_event(event) + + def test_get_events_succeeds(self, mock_request_method): + event = { + "id": "evt_123", + "group": "Terrace House", + "location": "1.1.1.1", + "latitude": None, + "longitude": None, + "action": { + "id": "evt_action_123", + "name": "house.created", + "project_id": "project_123", + }, + "type": "C", + "actor_name": "Daiki Miyagi", + "actor_id": "user_12345", + "target_name": "Ryota Yamasato", + "target_id": "user_67890", + "occurred_at": datetime.now().isoformat(), + "metadata": {"a": "b"}, + } + + response = { + "data": [event,], + "listMetadata": {"before": None, "after": None,}, + } + mock_request_method("get", response, 200) + + events, before, after = self.audit_trail.get_events() + assert events[0].to_dict() == event + + def test_get_events_raises_valueerror_when_before_and_after_provided(self): + with pytest.raises(ValueError): + self.audit_trail.get_events(before="evt_123", after="evt_456") + + def test_get_events_correctly_includes_occured_at_filter( + self, capture_and_mock_request + ): + event = { + "id": "evt_123", + "group": "Terrace House", + "location": "1.1.1.1", + "latitude": None, + "longitude": None, + "action": { + "id": "evt_action_123", + "name": "house.created", + "project_id": "project_123", + }, + "type": "C", + "actor_name": "Daiki Miyagi", + "actor_id": "user_12345", + "target_name": "Ryota Yamasato", + "target_id": "user_67890", + "occurred_at": datetime.now().isoformat(), + "metadata": {"a": "b"}, + } + + response = { + "data": [event,], + "listMetadata": {"before": None, "after": None,}, + } + request_args, request_kwargs = capture_and_mock_request("get", response, 200) + + self.audit_trail.get_events( + occurred_at=datetime.now(), + occurred_at_gte=datetime.now(), + occurred_at_gt=datetime.now(), + occurred_at_lte=datetime.now, + occurred_at_lt=datetime.now(), + ) + + request_params = request_kwargs["params"] + assert "occurred_at" in request_params + assert "occurred_at_gte" not in request_params + assert "occurred_at_gt" not in request_params + assert "occurred_at_lte" not in request_params + assert "occurred_at_lt" not in request_params + + def test_get_events_correctly_includes_occurred_at_gte( + self, capture_and_mock_request + ): + event = { + "id": "evt_123", + "group": "Terrace House", + "location": "1.1.1.1", + "latitude": None, + "longitude": None, + "action": { + "id": "evt_action_123", + "name": "house.created", + "project_id": "project_123", + }, + "type": "C", + "actor_name": "Daiki Miyagi", + "actor_id": "user_12345", + "target_name": "Ryota Yamasato", + "target_id": "user_67890", + "occurred_at": datetime.now().isoformat(), + "metadata": {"a": "b"}, + } + + response = { + "data": [event,], + "listMetadata": {"before": None, "after": None,}, + } + request_args, request_kwargs = capture_and_mock_request("get", response, 200) + + self.audit_trail.get_events( + occurred_at_gte=datetime.now(), occurred_at_gt=datetime.now(), + ) + + request_params = request_kwargs["params"] + assert "occurred_at_gte" in request_params + assert "occurred_at_gt" not in request_params + + def test_get_events_correctly_includes_occured_at_lte( + self, capture_and_mock_request + ): + event = { + "id": "evt_123", + "group": "Terrace House", + "location": "1.1.1.1", + "latitude": None, + "longitude": None, + "action": { + "id": "evt_action_123", + "name": "house.created", + "project_id": "project_123", + }, + "type": "C", + "actor_name": "Daiki Miyagi", + "actor_id": "user_12345", + "target_name": "Ryota Yamasato", + "target_id": "user_67890", + "occurred_at": datetime.now().isoformat(), + "metadata": {"a": "b"}, + } + + response = { + "data": [event,], + "listMetadata": {"before": None, "after": None,}, + } + request_args, request_kwargs = capture_and_mock_request("get", response, 200) + + self.audit_trail.get_events( + occurred_at_lte=datetime.now, occurred_at_lt=datetime.now() + ) + + request_params = request_kwargs["params"] + assert "occurred_at_lte" in request_params + assert "occurred_at_lt" not in request_params diff --git a/tests/utils/test_requests.py b/tests/utils/test_requests.py index 8aa1ae54..08c43a79 100644 --- a/tests/utils/test_requests.py +++ b/tests/utils/test_requests.py @@ -74,14 +74,12 @@ def test_request_bad_body_raises_expected_exception_with_request_data( # This'll fail for sure here but... just using the nice error that'd come up assert ex.__class__ == ServerException - def test_request_includes_base_headers(self, capture_and_mock_requests): - requests = capture_and_mock_requests() + def test_request_includes_base_headers(self, capture_and_mock_request): + request_args, request_kwargs = capture_and_mock_request("get", {}, 200) RequestHelper().request("ok_place") - assert len(requests) == 1 - base_headers = set(BASE_HEADERS.items()) - headers = set(requests[0][1]["headers"].items()) + headers = set(request_kwargs["headers"].items()) assert base_headers.issubset(headers) diff --git a/workos/__about__.py b/workos/__about__.py index e5c4bb40..03bd504e 100644 --- a/workos/__about__.py +++ b/workos/__about__.py @@ -12,7 +12,7 @@ __package_url__ = "https://github.com/workos-inc/workos-python" -__version__ = "0.3.3" +__version__ = "0.4.0" __author__ = "WorkOS" diff --git a/workos/audit_trail.py b/workos/audit_trail.py index 7e8e9659..e52f30e6 100644 --- a/workos/audit_trail.py +++ b/workos/audit_trail.py @@ -1,10 +1,12 @@ import workos from workos.exceptions import ConfigurationException -from workos.utils.request import RequestHelper, REQUEST_METHOD_POST +from workos.resources.event import WorkOSEvent +from workos.utils.request import RequestHelper, REQUEST_METHOD_GET, REQUEST_METHOD_POST from workos.utils.validation import AUDIT_TRAIL_MODULE, validate_settings EVENTS_PATH = "events" METADATA_LIMIT = 50 +DEFAULT_EVENT_LIMIT = 10 class AuditTrail(object): @@ -48,10 +50,10 @@ def create_event(self, event, idempotency_key=None): idempotency_key (str) - An idempotency key Returns: - dict: Response from WorkOS + boolean: Returns True """ if len(event.get("metadata", {})) > METADATA_LIMIT: - raise Exception( + raise ValueError( "Number of metadata keys exceeds {}.".format(METADATA_LIMIT) ) @@ -59,10 +61,115 @@ def create_event(self, event, idempotency_key=None): "idempotency-key": idempotency_key, } - return self.request_helper.request( + self.request_helper.request( EVENTS_PATH, method=REQUEST_METHOD_POST, params=event, headers=headers, token=workos.api_key, ) + + return True + + def get_events( + self, + before=None, + after=None, + limit=DEFAULT_EVENT_LIMIT, + group=None, + action=None, + action_type=None, + actor_name=None, + actor_id=None, + target_name=None, + target_id=None, + occurred_at=None, + occurred_at_gt=None, + occurred_at_gte=None, + occurred_at_lt=None, + occurred_at_lte=None, + search=None, + ): + """Filter for Audit Trail Events. + + Kwargs: + before (str) - Event ID to look before + after (str) - Event ID to look after + limit (int) - Number of Events to return + group (str|list) - Group or groups to filter for + action (str|list) - Action or actions to filter for + action_type (str|list) - Action type or types to filter for + actor_name (str|list) - Actor name or name to filter for + actor_id (str|list) - Actor ID or IDs to filter for + target_name (str|list) - Target name or names to filter for + target_id (str|list) - Target ID or IDs to filter for + occurred_at (str) - ISO-8601 datetime of when an event occurred + occurred_at_gt (str) - ISO-8601 datetime of when an event occurred after + occurred_at_gte (str) - ISO-8601 datetime of when an event occurred at or after + occurred_at_lt (str) - ISO-8601 datetime of when an event occurred before + occurred_at_lte (str) - ISO-8601 datetime of when an event occured at or before + search (str) - Keyword search + + Returns: + tuple + list - List of WorkOSEvent objects + string - Event ID to use as before cursor + string - Event ID to use as after cursor + """ + if before and after: + raise ValueError("Specify either before or after") + + params = { + "before": before, + "after": after, + "limit": limit, + } + + if group is not None: + params["group"] = list(group) + + if action is not None: + params["action"] = list(action) + + if action_type is not None: + params["action_type"] = list(action_type) + + if actor_name is not None: + params["actor_name"] = list(actor_name) + + if actor_id is not None: + params["actor_id"] = list(actor_id) + + if target_name is not None: + params["target_name"] = list(target_name) + + if target_id is not None: + params["target_id"] = list(target_id) + + if occurred_at is not None: + params["occurred_at"] = occurred_at + else: + if occurred_at_gte is not None: + params["occurred_at_gte"] = occurred_at_gte + elif occurred_at_gt is not None: + params["occurred_at_gt"] = occurred_at_gt + + if occurred_at_lte is not None: + params["occurred_at_lte"] = occurred_at_lte + elif occurred_at_lt is not None: + params["occurred_at_lt"] = occurred_at_lt + + if search is not None: + params["search"] = search + + response = self.request_helper.request( + EVENTS_PATH, method=REQUEST_METHOD_GET, params=params, token=workos.api_key, + ) + + events = [ + WorkOSEvent.construct_from_response(data) for data in response["data"] + ] + before = response["listMetadata"]["before"] + after = response["listMetadata"]["after"] + + return (events, before, after) diff --git a/workos/resources/base.py b/workos/resources/base.py new file mode 100644 index 00000000..c87c6e48 --- /dev/null +++ b/workos/resources/base.py @@ -0,0 +1,36 @@ +class WorkOSBaseResource(object): + """Representation of a WorkOS Resource as returned through the API. + + Attributes: + OBJECT_FIELDS (list): List of fields a Resource is comprised of. + """ + + OBJECT_FIELDS = [] + + @classmethod + def construct_from_response(cls, response): + """Returns an instance of WorkOSBaseResource. + + Args: + response (dict): Resource data from a WorkOS API response + + Returns: + WorkOSBaseResource: Instance of a WorkOSBaseResource with OBJECT_FIELDS fields set + """ + obj = cls() + for field in cls.OBJECT_FIELDS: + setattr(obj, field, response[field]) + + return obj + + def to_dict(self): + """Returns a dict representation of the WorkOSBaseResource. + + Returns: + dict: A dict representation of the WorkOSBaseResource + """ + obj_dict = {} + for field in self.OBJECT_FIELDS: + obj_dict[field] = getattr(self, field, None) + + return obj_dict diff --git a/workos/resources/event.py b/workos/resources/event.py new file mode 100644 index 00000000..13b68ea7 --- /dev/null +++ b/workos/resources/event.py @@ -0,0 +1,42 @@ +from workos.resources.base import WorkOSBaseResource +from workos.resources.event_action import WorkOSEventAction + + +class WorkOSEvent(WorkOSBaseResource): + """Representation of an Event as returned by WorkOS through the Audit Trail feature. + + Attributes: + OBJECT_FIELDS (list): List of fields a WorkOSEvent is comprised of. + """ + + OBJECT_FIELDS = [ + "id", + "group", + "location", + "latitude", + "longitude", + "type", + "actor_name", + "actor_id", + "target_name", + "target_id", + "metadata", + "occurred_at", + ] + + @classmethod + def construct_from_response(cls, response): + event = super(WorkOSEvent, cls).construct_from_response(response) + + event_action = WorkOSEventAction.construct_from_response(response["action"]) + event.action = event_action + + return event + + def to_dict(self): + event_dict = super(WorkOSEvent, self).to_dict() + + event_action_dict = self.action.to_dict() + event_dict["action"] = event_action_dict + + return event_dict diff --git a/workos/resources/event_action.py b/workos/resources/event_action.py new file mode 100644 index 00000000..c693fdd3 --- /dev/null +++ b/workos/resources/event_action.py @@ -0,0 +1,15 @@ +from workos.resources.base import WorkOSBaseResource + + +class WorkOSEventAction(WorkOSBaseResource): + """Representation of an Event Action as returned by WorkOS through the Audit Trail feature. + + Attributes: + OBJECT_FIELDS (list): List of fields a WorkOSEventAction is comprised of. + """ + + OBJECT_FIELDS = [ + "id", + "name", + "project_id", + ] diff --git a/workos/resources/sso.py b/workos/resources/sso.py index 797a4eb7..86fc3002 100644 --- a/workos/resources/sso.py +++ b/workos/resources/sso.py @@ -1,4 +1,7 @@ -class WorkOSProfile(object): +from workos.resources.base import WorkOSBaseResource + + +class WorkOSProfile(WorkOSBaseResource): """Representation of a User Profile as returned by WorkOS through the SSO feature. Attributes: @@ -13,33 +16,3 @@ class WorkOSProfile(object): "connection_type", "idp_id", ] - - @classmethod - def construct_from_response(cls, response): - """Returns an instance of WorkOSProfile. - - Args: - response (dict): Response from a WorkOS API request as returned by RequestHelper - - Returns: - WorkOSProfile: The WorkOS profile of a User - """ - profile_data = response["profile"] - - profile = cls() - for field in WorkOSProfile.OBJECT_FIELDS: - setattr(profile, field, profile_data[field]) - - return profile - - def to_dict(self): - """Returns a dict representation of the WorkOSProfile. - - Returns: - dict: A dict representation of the WorkOSProfile - """ - profile_dict = {} - for field in WorkOSProfile.OBJECT_FIELDS: - profile_dict[field] = getattr(self, field, None) - - return profile_dict diff --git a/workos/sso.py b/workos/sso.py index f90fe8c0..bd4cf996 100644 --- a/workos/sso.py +++ b/workos/sso.py @@ -100,7 +100,7 @@ def get_profile(self, code): TOKEN_PATH, method=REQUEST_METHOD_POST, params=params ) - return WorkOSProfile.construct_from_response(response) + return WorkOSProfile.construct_from_response(response["profile"]) def promote_draft_connection(self, token): """Promote a Draft Connection