Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨(mailboxes) link mailbox creation to dimail-api #261

Merged
merged 4 commits into from
Aug 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/backend/mailbox_manager/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class MailboxSerializer(serializers.ModelSerializer):
class Meta:
model = models.Mailbox
fields = ["id", "first_name", "last_name", "local_part", "secondary_email"]
# everything is actually read-only as we do not allow update for now
read_only_fields = ["id"]


class MailDomainSerializer(serializers.ModelSerializer):
Expand Down
13 changes: 12 additions & 1 deletion src/backend/mailbox_manager/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,18 @@ class MailBoxViewSet(
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""MailBox ViewSet"""
"""MailBox ViewSet

GET /api/<version>/mail-domains/<domain-slug>/mailboxes/
Return a list of mailboxes on the domain

POST /api/<version>/mail-domains/<domain-slug>/mailboxes/ with expected data:
- first_name: str
- last_name: str
- local_part: str
- secondary_email: str
Sends request to email provisioning API and returns newly created mailbox
"""

permission_classes = [permissions.MailBoxPermission]
serializer_class = serializers.MailboxSerializer
Expand Down
37 changes: 37 additions & 0 deletions src/backend/mailbox_manager/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
Mailbox manager application factories
"""

import re

from django.utils.text import slugify

import factory.fuzzy
import responses
from faker import Faker
from rest_framework import status

from core import factories as core_factories
from core import models as core_models
Expand All @@ -27,6 +31,7 @@ class Meta:

name = factory.Faker("domain_name")
slug = factory.LazyAttribute(lambda o: slugify(o.name))
secret = factory.Faker("password")

@factory.post_generation
def users(self, create, extracted, **kwargs):
Expand Down Expand Up @@ -75,3 +80,35 @@ class Meta:
)
domain = factory.SubFactory(MailDomainEnabledFactory)
secondary_email = factory.Faker("email")

@classmethod
def _create(cls, model_class, *args, use_mock=True, **kwargs):
domain = kwargs["domain"]
if use_mock and isinstance(domain, models.MailDomain):
Copy link
Collaborator

@sdemagny sdemagny Aug 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C'est un peu étrange ce que je suis obligée de faire pour que les tests qui concernent la création de boite mail sans mail domaine fonctionnent. Ya un truc qui me chiffonne avec ces tests. À discuter tranquillement.
@mjeammet

with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=str(
{
"email": f"{kwargs['local_part']}@{domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)

result = super()._create(model_class, *args, **kwargs)
else:
result = super()._create(model_class, *args, **kwargs)
return result
18 changes: 18 additions & 0 deletions src/backend/mailbox_manager/migrations/0011_maildomain_secret.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-07-01 16:22

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('mailbox_manager', '0010_alter_mailbox_first_name_alter_mailbox_last_name'),
]

operations = [
migrations.AddField(
model_name='maildomain',
name='secret',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='secret'),
),
]
35 changes: 29 additions & 6 deletions src/backend/mailbox_manager/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
"""

from django.conf import settings
from django.core import validators
from django.core.exceptions import ValidationError
from django.db import models
from django.core import exceptions, validators
from django.db import models, transaction
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _

from core.models import BaseModel

from mailbox_manager.enums import MailDomainRoleChoices, MailDomainStatusChoices
from mailbox_manager.utils.dimail import DimailAPIClient


class MailDomain(BaseModel):
Expand All @@ -26,6 +26,7 @@ class MailDomain(BaseModel):
default=MailDomainStatusChoices.PENDING,
choices=MailDomainStatusChoices.choices,
)
secret = models.CharField(_("secret"), max_length=255, null=True, blank=True)

class Meta:
db_table = "people_mail_domain"
Expand Down Expand Up @@ -137,8 +138,30 @@ class Meta:
def __str__(self):
return f"{self.local_part!s}@{self.domain.name:s}"

def clean(self):
"""Mailboxes can be created only on enabled domains, with a set secret."""
if self.domain.status != MailDomainStatusChoices.ENABLED:
raise exceptions.ValidationError(
"You can create mailbox only for a domain enabled"
)

if not self.domain.secret:
raise exceptions.ValidationError(
"Please configure your domain's secret before creating any mailbox."
)

def save(self, *args, **kwargs):
"""
Override save function to fire a request on mailbox creation.
Modification is forbidden for now.
"""
self.full_clean()
if self.domain.status != MailDomainStatusChoices.ENABLED:
raise ValidationError("You can create mailbox only for a domain enabled")
super().save(*args, **kwargs)

if self._state.adding:
with transaction.atomic():
client = DimailAPIClient()
client.send_mailbox_request(self)
mjeammet marked this conversation as resolved.
Show resolved Hide resolved
return super().save(*args, **kwargs)

# Update is not implemented for now
raise NotImplementedError()
mjeammet marked this conversation as resolved.
Show resolved Hide resolved
2 changes: 2 additions & 0 deletions src/backend/mailbox_manager/templates/403.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<h1>403 Forbidden</h1>
{{ exception }}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
Unit tests for the mailbox API
"""

import json
import re

import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient

Expand Down Expand Up @@ -91,11 +95,33 @@ def test_api_mailboxes__create_roles_success(role):
mailbox_values = serializers.MailboxSerializer(
factories.MailboxFactory.build()
).data
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{mail_domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_values['local_part']}@{mail_domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)

assert response.status_code == status.HTTP_201_CREATED
mailbox = models.Mailbox.objects.get()
Expand Down Expand Up @@ -130,12 +156,33 @@ def test_api_mailboxes__create_with_accent_success(role):
mailbox_values = serializers.MailboxSerializer(
factories.MailboxFactory.build(first_name="Aimé")
).data
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)

with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{mail_domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_values['local_part']}@{mail_domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
mailbox = models.Mailbox.objects.get()

Expand Down Expand Up @@ -187,3 +234,135 @@ def test_api_mailboxes__create_administrator_missing_fields():
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not models.Mailbox.objects.exists()
assert response.json() == {"secondary_email": ["This field is required."]}


### SYNC TO PROVISIONING API


def test_api_mailboxes__unrelated_user_provisioning_api_not_called():
"""
Provisioning API should not be called if an user tries
to create a mailbox on a domain they have no access to.
"""
domain = factories.MailDomainEnabledFactory()

client = APIClient()
client.force_login(core_factories.UserFactory()) # user with no access
body_values = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=domain)
).data
with responses.RequestsMock():
# We add no simulated response in RequestsMock
# because we expected no "outside" calls to be made
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/mailboxes/",
body_values,
format="json",
)
# No exception raised by RequestsMock means no call was sent
# our API blocked the request before sending it
assert response.status_code == status.HTTP_403_FORBIDDEN


def test_api_mailboxes__domain_viewer_provisioning_api_not_called():
"""
Provisioning API should not be called if a domain viewer tries
to create a mailbox on a domain they are not owner/admin of.
"""
access = factories.MailDomainAccessFactory(
domain=factories.MailDomainEnabledFactory(),
user=core_factories.UserFactory(),
role=enums.MailDomainRoleChoices.VIEWER,
)

client = APIClient()
client.force_login(access.user)
body_values = serializers.MailboxSerializer(factories.MailboxFactory.build()).data
with responses.RequestsMock():
# We add no simulated response in RequestsMock
# because we expected no "outside" calls to be made
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
body_values,
format="json",
)
# No exception raised by RequestsMock means no call was sent
# our API blocked the request before sending it
assert response.status_code == status.HTTP_403_FORBIDDEN


@pytest.mark.parametrize(
"role",
[enums.MailDomainRoleChoices.ADMIN, enums.MailDomainRoleChoices.OWNER],
)
def test_api_mailboxes__domain_owner_or_admin_successful_creation_and_provisioning(
role,
):
"""
Domain owner/admin should be able to create mailboxes.
Provisioning API should be called when owner/admin makes a call.
Expected response contains new email and password.
"""
# creating all needed objects
access = factories.MailDomainAccessFactory(role=role)

client = APIClient()
client.force_login(access.user)
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=access.domain)
).data

with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsp = rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)

response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)

# Checks payload sent to email-provisioning API
payload = json.loads(rsps.calls[1].request.body)
assert payload == {
"displayName": f"{mailbox_data['first_name']} {mailbox_data['last_name']}",
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
"givenName": mailbox_data["first_name"],
"surName": mailbox_data["last_name"],
}

# Checks response
assert response.status_code == status.HTTP_201_CREATED
assert rsp.call_count == 1

mailbox = models.Mailbox.objects.get()
assert response.json() == {
"id": str(mailbox.id),
"first_name": str(mailbox_data["first_name"]),
"last_name": str(mailbox_data["last_name"]),
"local_part": str(mailbox_data["local_part"]),
"secondary_email": str(mailbox_data["secondary_email"]),
}
assert mailbox.first_name == mailbox_data["first_name"]
assert mailbox.last_name == mailbox_data["last_name"]
assert mailbox.local_part == mailbox_data["local_part"]
assert mailbox.secondary_email == mailbox_data["secondary_email"]
Loading
Loading