(webhook) add webhook logic and synchronization utils

adding webhooks logic to send serialized team memberships data
to a designated serie of webhooks.
This commit is contained in:
Marie PUPO JEAMMET
2024-03-22 14:47:08 +01:00
committed by Marie
parent 7ea6342a01
commit ebf58f42c9
13 changed files with 813 additions and 9 deletions

View File

@@ -2,9 +2,12 @@
Test for team accesses API endpoints in People's core app : create
"""
import json
import random
import re
import pytest
import responses
from rest_framework.test import APIClient
from core import factories, models
@@ -174,3 +177,60 @@ def test_api_team_accesses_create_authenticated_owner():
"role": role,
"user": str(other_user.id),
}
def test_api_team_accesses_create_webhook():
"""
When the team has a webhook, creating a team access should fire a call.
"""
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "owner")])
webhook = factories.TeamWebhookFactory(team=team)
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert rsp.call_count == 1
assert rsps.calls[0].request.url == webhook.url
# Payload sent to scim provider
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(other_user.id),
"email": None,
"type": "User",
}
],
}
],
}

View File

@@ -2,9 +2,12 @@
Test for team accesses API endpoints in People's core app : delete
"""
import json
import random
import re
import pytest
import responses
from rest_framework.test import APIClient
from core import factories, models
@@ -148,18 +151,77 @@ def test_api_team_accesses_delete_owners_last_owner():
"""
It should not be possible to delete the last owner access from a team
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
assert models.TeamAccess.objects.count() == 1
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_webhook():
"""
When the team has a webhook, deleting a team access should fire a call.
"""
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "administrator")])
webhook = factories.TeamWebhookFactory(team=team)
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(user)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert rsp.call_count == 1
assert rsps.calls[0].request.url == webhook.url
# Payload sent to scim provider
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "remove",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": None,
"type": "User",
}
],
}
],
}
assert models.TeamAccess.objects.count() == 1
assert models.TeamAccess.objects.filter(user=access.user).exists() is False

View File

@@ -2,12 +2,16 @@
Unit tests for the TeamAccess model
"""
import json
import re
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
import pytest
import responses
from core import factories
from core import factories, models
pytestmark = pytest.mark.django_db
@@ -39,6 +43,94 @@ def test_models_team_accesses_unique():
factories.TeamAccessFactory(user=access.user, team=access.team)
def test_models_team_accesses_create_webhook():
"""
When the team has a webhook, creating a team access should fire a call.
"""
user = factories.UserFactory()
team = factories.TeamFactory()
webhook = factories.TeamWebhookFactory(team=team)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
models.TeamAccess.objects.create(user=user, team=team)
assert rsp.call_count == 1
assert rsps.calls[0].request.url == webhook.url
# Payload sent to scim provider
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(user.id),
"email": None,
"type": "User",
}
],
}
],
}
def test_models_team_accesses_delete_webhook():
"""
When the team has a webhook, deleting a team access should fire a call.
"""
team = factories.TeamFactory()
webhook = factories.TeamWebhookFactory(team=team)
access = factories.TeamAccessFactory(team=team)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
access.delete()
assert rsp.call_count == 1
assert rsps.calls[0].request.url == webhook.url
# Payload sent to scim provider
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "remove",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": None,
"type": "User",
}
],
}
],
}
assert models.TeamAccess.objects.exists() is False
# get_abilities

View File

@@ -0,0 +1,341 @@
"""Test Team synchronization webhooks."""
import json
import random
import re
from logging import Logger
from unittest import mock
import pytest
import responses
from core import factories
from core.utils.webhooks import scim_synchronizer
pytestmark = pytest.mark.django_db
def test_utils_webhooks_add_user_to_group_no_webhooks():
"""If no webhook is declared on the team, the function should not make any request."""
access = factories.TeamAccessFactory()
with responses.RequestsMock():
scim_synchronizer.add_user_to_group(access.team, access.user)
assert len(responses.calls) == 0
@mock.patch.object(Logger, "info")
def test_utils_webhooks_add_user_to_group_success(mock_info):
"""The user passed to the function should get added."""
identity = factories.IdentityFactory()
access = factories.TeamAccessFactory(user=identity.user)
webhooks = factories.TeamWebhookFactory.create_batch(2, team=access.team)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
# Check headers
headers = rsps.calls[i].request.headers
assert "Authorization" not in headers
assert headers["Content-Type"] == "application/json"
# Payload sent to scim provider
for call in rsps.calls:
payload = json.loads(call.request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": identity.email,
"type": "User",
}
],
}
],
}
# Logger
assert mock_info.call_count == 2
for i, webhook in enumerate(webhooks):
assert mock_info.call_args_list[i][0] == (
"%s synchronization succeeded with %s",
"add_user_to_group",
webhook.url,
)
# Status
for webhook in webhooks:
webhook.refresh_from_db()
assert webhook.status == "success"
@mock.patch.object(Logger, "info")
def test_utils_webhooks_remove_user_from_group_success(mock_info):
"""The user passed to the function should get removed."""
identity = factories.IdentityFactory()
access = factories.TeamAccessFactory(user=identity.user)
webhooks = factories.TeamWebhookFactory.create_batch(2, team=access.team)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
scim_synchronizer.remove_user_from_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
# Payload sent to scim provider
for call in rsps.calls:
payload = json.loads(call.request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "remove",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": identity.email,
"type": "User",
}
],
}
],
}
# Logger
assert mock_info.call_count == 2
for i, webhook in enumerate(webhooks):
assert mock_info.call_args_list[i][0] == (
"%s synchronization succeeded with %s",
"remove_user_from_group",
webhook.url,
)
# Status
for webhook in webhooks:
webhook.refresh_from_db()
assert webhook.status == "success"
@mock.patch.object(Logger, "error")
@mock.patch.object(Logger, "info")
def test_utils_webhooks_add_user_to_group_failure(mock_info, mock_error):
"""The logger should be called on webhook call failure."""
identity = factories.IdentityFactory()
access = factories.TeamAccessFactory(user=identity.user)
webhooks = factories.TeamWebhookFactory.create_batch(2, team=access.team)
with responses.RequestsMock() as rsps:
# Simulate webhook failure using "responses":
rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=random.choice([404, 301, 302]),
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
# Payload sent to scim provider
for call in rsps.calls:
payload = json.loads(call.request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": identity.email,
"type": "User",
}
],
}
],
}
# Logger
assert not mock_info.called
assert mock_error.call_count == 2
for i, webhook in enumerate(webhooks):
assert mock_error.call_args_list[i][0] == (
"%s synchronization failed with %s",
"add_user_to_group",
webhook.url,
)
# Status
for webhook in webhooks:
webhook.refresh_from_db()
assert webhook.status == "failure"
@mock.patch.object(Logger, "error")
@mock.patch.object(Logger, "info")
def test_utils_webhooks_add_user_to_group_retries(mock_info, mock_error):
"""webhooks synchronization supports retries."""
identity = factories.IdentityFactory()
access = factories.TeamAccessFactory(user=identity.user)
webhook = factories.TeamWebhookFactory(team=access.team)
url = re.compile(r".*/Groups/.*")
with responses.RequestsMock() as rsps:
# Make webhook fail 3 times before succeeding using "responses"
all_rsps = [
rsps.add(rsps.PATCH, url, status=500, content_type="application/json"),
rsps.add(rsps.PATCH, url, status=500, content_type="application/json"),
rsps.add(rsps.PATCH, url, status=500, content_type="application/json"),
rsps.add(rsps.PATCH, url, status=200, content_type="application/json"),
]
scim_synchronizer.add_user_to_group(access.team, access.user)
for i in range(4):
assert all_rsps[i].call_count == 1
assert rsps.calls[i].request.url == webhook.url
payload = json.loads(rsps.calls[i].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": identity.email,
"type": "User",
}
],
}
],
}
# Logger
assert not mock_error.called
assert mock_info.call_count == 1
assert mock_info.call_args_list[0][0] == (
"%s synchronization succeeded with %s",
"add_user_to_group",
webhook.url,
)
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
@mock.patch.object(Logger, "error")
@mock.patch.object(Logger, "info")
def test_utils_synchronize_course_runs_max_retries_exceeded(mock_info, mock_error):
"""Webhooks synchronization has exceeded max retries and should get logged."""
identity = factories.IdentityFactory()
access = factories.TeamAccessFactory(user=identity.user)
webhook = factories.TeamWebhookFactory(team=access.team)
with responses.RequestsMock() as rsps:
# Simulate webhook temporary failure using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=random.choice([500, 502]),
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
assert rsp.call_count == 5
assert rsps.calls[0].request.url == webhook.url
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": identity.email,
"type": "User",
}
],
}
],
}
# Logger
assert not mock_info.called
assert mock_error.call_count == 1
assert mock_error.call_args_list[0][0] == (
"%s synchronization failed due to max retries exceeded with url %s",
"add_user_to_group",
webhook.url,
)
# Status
webhook.refresh_from_db()
assert webhook.status == "failure"
def test_utils_webhooks_add_user_to_group_authorization():
"""Secret token should be passed in authorization header when set."""
identity = factories.IdentityFactory()
access = factories.TeamAccessFactory(user=identity.user)
webhook = factories.TeamWebhookFactory(team=access.team, secret="123")
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
assert rsps.calls[0].request.url == webhook.url
# Check headers
headers = rsps.calls[0].request.headers
assert headers["Authorization"] == "Bearer 123"
assert headers["Content-Type"] == "application/json"
# Status
webhook.refresh_from_db()
assert webhook.status == "success"