✨(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:
committed by
Marie
parent
7ea6342a01
commit
ebf58f42c9
@@ -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",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
341
src/backend/core/tests/test_utils_webhooks_scim_client.py
Normal file
341
src/backend/core/tests/test_utils_webhooks_scim_client.py
Normal 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"
|
||||
Reference in New Issue
Block a user