Compare commits

...

9 Commits

Author SHA1 Message Date
Marie PUPO JEAMMET 439ddb9d4a 🔖(major) major release to 1.0.0
🎉! For changelog, see changelog.md
2024-08-09 15:21:42 +02:00
Sabrina Demagny a7a923e790 (mailboxes) manage bad secret sent to dimail API
- manage 403 returned by dimail API when mail domain secret is not valid
- improve some tests
- improve MailboxFactory to mock success for dimail API POST call
- override 403.html to return a nice failing error in django admin
- an error message is displayed on mailbox creation form of frontend
2024-08-09 13:37:20 +02:00
Marie PUPO JEAMMET 5ed63fc091 (test) add test list mailboxes non existing domain
Test that API raises a 404 when trying to list mailboxes
of a domain that does not exist.
2024-08-09 13:37:20 +02:00
Marie PUPO JEAMMET f55cb3a813 (mailboxes) add mail provisioning api integration
We want people to create new mailboxes in La Régie.
This commit adds integration with intermediary dimail-api,
which will in turn send our email creation request to Open-Xchange.
2024-08-09 13:37:20 +02:00
Marie PUPO JEAMMET 2c82f38c59 🗃️(domains) add "secret" field to domains
add a "secret" field to domain model. This secret will be used as
password in mail provisioning API.
2024-08-09 13:37:20 +02:00
Sabrina Demagny 8963f0bb3d (mail) add status on domain create or retrieve API
to display status on frontend
2024-08-08 23:47:49 +02:00
Anthony LC 733a1d8861 🔥(frontend) remove temporarily team feature
We want to make a first realease, but the
team feature is not ready yet.
So we will hide it for now by hiding the menu.
We will still let the feature in dev environment.
2024-08-08 16:29:06 +02:00
daproclaima 7916f7d7d0 (frontend) show names in mailbox table
- show a concatenation of first and last names
for each row in mailbox table
- update related e2e tests
2024-08-08 13:53:07 +02:00
daproclaima 45dbdd6c4c (frontend) restrict mailbox creation
- update mailbox creation feature by introducing the use of
new mail domain ability field to hide or show
mailbox creation button
- update related e2e tests
2024-08-08 13:53:07 +02:00
36 changed files with 875 additions and 73 deletions
+10
View File
@@ -7,3 +7,13 @@ and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.0.0] - 2024-08-09
### Added
- ✨(domains) create and manage domains on admin + API
- ✨(domains) mailbox creation + link to email provisioning API
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.0.0...main
[1.0.0]: https://github.com/numerique-gouv/people/releases/v1.0.0
@@ -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):
@@ -25,6 +27,7 @@ class MailDomainSerializer(serializers.ModelSerializer):
"id",
"name",
"slug",
"status",
"abilities",
"created_at",
"updated_at",
@@ -32,6 +35,7 @@ class MailDomainSerializer(serializers.ModelSerializer):
read_only_fields = [
"id",
"slug",
"status",
"abilities",
"created_at",
"updated_at",
+12 -1
View File
@@ -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
+37
View File
@@ -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
@@ -27,6 +31,7 @@ class MailDomainFactory(factory.django.DjangoModelFactory):
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):
@@ -75,3 +80,35 @@ class MailboxFactory(factory.django.DjangoModelFactory):
)
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):
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
@@ -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'),
),
]
+30 -7
View File
@@ -3,15 +3,15 @@ Declare and configure the models for the People additional application : mailbox
"""
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):
@@ -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"
@@ -137,8 +138,30 @@ class Mailbox(BaseModel):
def __str__(self):
return f"{self.local_part!s}@{self.domain.name:s}"
def save(self, *args, **kwargs):
self.full_clean()
def clean(self):
"""Mailboxes can be created only on enabled domains, with a set secret."""
if self.domain.status != MailDomainStatusChoices.ENABLED:
raise ValidationError("You can create mailbox only for a domain enabled")
super().save(*args, **kwargs)
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._state.adding:
with transaction.atomic():
client = DimailAPIClient()
client.send_mailbox_request(self)
return super().save(*args, **kwargs)
# Update is not implemented for now
raise NotImplementedError()
@@ -0,0 +1,2 @@
<h1>403 Forbidden</h1>
{{ exception }}
@@ -52,12 +52,10 @@ def test_api_mail_domains__create_authenticated():
Authenticated users should be able to create mail domains
and should automatically be added as owner of the newly created domain.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/mail-domains/",
{
@@ -65,10 +63,21 @@ def test_api_mail_domains__create_authenticated():
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
# a new domain pending is created and the authenticated user is the owner
domain = models.MailDomain.objects.get()
# response is as expected
assert response.json() == {
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"status": enums.MailDomainStatusChoices.PENDING,
"created_at": domain.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
"abilities": domain.get_abilities(user),
}
# a new domain with status "pending" is created and authenticated user is the owner
assert domain.status == enums.MailDomainStatusChoices.PENDING
assert domain.name == "mydomain.com"
assert domain.accesses.filter(role="owner", user=user).exists()
@@ -81,6 +81,7 @@ def test_api_mail_domains__retrieve_authenticated_related():
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"status": domain.status,
"created_at": domain.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
"abilities": domain.get_abilities(user),
@@ -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
@@ -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()
@@ -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()
@@ -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"]
@@ -78,3 +78,17 @@ def test_api_mailboxes__list_roles(role):
"secondary_email": str(mailbox1.secondary_email),
},
]
def test_api_mailboxes__list_non_existing():
"""
User gets a 404 when trying to list mailboxes of a domain which does not exist.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.MailboxFactory.create_batch(5)
response = client.get("/api/v1.0/mail-domains/nonexistent.domain/mailboxes/")
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -2,27 +2,34 @@
Unit tests for the mailbox model
"""
from django.core.exceptions import ValidationError
import json
import re
from logging import Logger
from unittest import mock
from django.core import exceptions
import pytest
import responses
from rest_framework import status
from mailbox_manager import enums, factories
from mailbox_manager import enums, factories, models
from mailbox_manager.api import serializers
pytestmark = pytest.mark.django_db
# LOCAL PART FIELD
def test_models_mailboxes__local_part_cannot_be_empty():
"""The "local_part" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank"):
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
factories.MailboxFactory(local_part="")
def test_models_mailboxes__local_part_cannot_be_null():
"""The "local_part" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null"):
with pytest.raises(exceptions.ValidationError, match="This field cannot be null"):
factories.MailboxFactory(local_part=None)
@@ -33,11 +40,13 @@ def test_models_mailboxes__local_part_matches_expected_format():
"""
factories.MailboxFactory(local_part="Marie-Jose.Perec+JO_2024")
with pytest.raises(ValidationError, match="Enter a valid value"):
# other special characters (such as "@" or "!") should raise a validation error
with pytest.raises(exceptions.ValidationError, match="Enter a valid value"):
factories.MailboxFactory(local_part="mariejo@unnecessarydomain.com")
with pytest.raises(ValidationError, match="Enter a valid value"):
factories.MailboxFactory(local_part="!")
for character in ["!", "$", "%"]:
with pytest.raises(exceptions.ValidationError, match="Enter a valid value"):
factories.MailboxFactory(local_part=f"marie{character}jo")
def test_models_mailboxes__local_part_unique_per_domain():
@@ -50,7 +59,8 @@ def test_models_mailboxes__local_part_unique_per_domain():
# same local part on the same domain should not be possible
with pytest.raises(
ValidationError, match="Mailbox with this Local_part and Domain already exists."
exceptions.ValidationError,
match="Mailbox with this Local_part and Domain already exists.",
):
factories.MailboxFactory(
local_part=existing_mailbox.local_part, domain=existing_mailbox.domain
@@ -72,7 +82,7 @@ def test_models_mailboxes__domain_must_be_a_maildomain_instance():
def test_models_mailboxes__domain_cannot_be_null():
"""The "domain" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null"):
with pytest.raises(models.MailDomain.DoesNotExist, match="Mailbox has no domain."):
factories.MailboxFactory(domain=None)
@@ -81,13 +91,13 @@ def test_models_mailboxes__domain_cannot_be_null():
def test_models_mailboxes__secondary_email_cannot_be_empty():
"""The "secondary_email" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank"):
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
factories.MailboxFactory(secondary_email="")
def test_models_mailboxes__secondary_email_cannot_be_null():
"""The "secondary_email" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null"):
with pytest.raises(exceptions.ValidationError, match="This field cannot be null"):
factories.MailboxFactory(secondary_email=None)
@@ -95,7 +105,8 @@ def test_models_mailboxes__cannot_be_created_for_disabled_maildomain():
"""Mailbox creation is allowed only for a domain enabled.
A disabled status for the mail domain raises an error."""
with pytest.raises(
ValidationError, match="You can create mailbox only for a domain enabled"
exceptions.ValidationError,
match="You can create mailbox only for a domain enabled",
):
factories.MailboxFactory(
domain=factories.MailDomainFactory(
@@ -108,7 +119,8 @@ def test_models_mailboxes__cannot_be_created_for_failed_maildomain():
"""Mailbox creation is allowed only for a domain enabled.
A failed status for the mail domain raises an error."""
with pytest.raises(
ValidationError, match="You can create mailbox only for a domain enabled"
exceptions.ValidationError,
match="You can create mailbox only for a domain enabled",
):
factories.MailboxFactory(
domain=factories.MailDomainFactory(
@@ -121,8 +133,134 @@ def test_models_mailboxes__cannot_be_created_for_pending_maildomain():
"""Mailbox creation is allowed only for a domain enabled.
A pending status for the mail domain raises an error."""
with pytest.raises(
ValidationError, match="You can create mailbox only for a domain enabled"
exceptions.ValidationError,
match="You can create mailbox only for a domain enabled",
):
# MailDomainFactory initializes a mail domain with default values,
# so mail domain status is pending!
factories.MailboxFactory(domain=factories.MailDomainFactory())
### SYNC TO DIMAIL-API
def test_models_mailboxes__no_secret():
"""If no secret is declared on the domain, the function should raise an error."""
domain = factories.MailDomainEnabledFactory(secret=None)
with pytest.raises(
exceptions.ValidationError,
match="Please configure your domain's secret before creating any mailbox.",
):
factories.MailboxFactory(domain=domain)
def test_models_mailboxes__wrong_secret():
"""If domain secret is inaccurate, the function should raise an error."""
domain = factories.MailDomainEnabledFactory()
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"detail": "Permission denied"}',
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body='{"detail": "Permission denied"}',
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
with pytest.raises(
exceptions.PermissionDenied,
match=f"Please check secret of the mail domain {domain.name}",
):
mailbox = factories.MailboxFactory(use_mock=False, domain=domain)
# Payload sent to mailbox provider
payload = json.loads(rsps.calls[1].request.body)
assert payload == {
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
"email": f"{mailbox.local_part}@{domain.name}",
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
}
@mock.patch.object(Logger, "error")
@mock.patch.object(Logger, "info")
def test_models_mailboxes__create_mailbox_success(mock_info, mock_error):
"""Creating a mailbox sends the expected information and get expected response before saving."""
domain = factories.MailDomainEnabledFactory()
# generate mailbox data before mailbox, to mock responses
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=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",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
mailbox = factories.MailboxFactory(
use_mock=False, local_part=mailbox_data["local_part"], domain=domain
)
# Check headers
headers = rsps.calls[1].request.headers
# assert "Authorization" not in headers
assert headers["Content-Type"] == "application/json"
# Payload sent to mailbox provider
payload = json.loads(rsps.calls[1].request.body)
assert payload == {
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
"email": f"{mailbox.local_part}@{domain.name}",
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
}
# Logger
assert not mock_error.called
assert mock_info.call_count == 1
assert mock_info.call_args_list[0][0] == (
"Mailbox successfully created on domain %s",
domain.name,
)
assert mock_info.call_args_list[0][1] == (
{
"extra": {
"response": str(
{
"email": f"{mailbox.local_part}@{domain.name}",
"password": "newpass",
"uuid": "uuid",
}
)
}
}
)
@@ -0,0 +1,95 @@
"""A minimalist client to synchronize with mailbox provisioning API."""
from logging import getLogger
from django.conf import settings
from django.core import exceptions
import requests
from rest_framework import status
from urllib3.util import Retry
logger = getLogger(__name__)
adapter = requests.adapters.HTTPAdapter(
max_retries=Retry(
total=4,
backoff_factor=0.1,
status_forcelist=[500, 502],
allowed_methods=["PATCH"],
)
)
session = requests.Session()
session.mount("http://", adapter)
session.mount("https://", adapter)
class DimailAPIClient:
"""A dimail-API client."""
def get_headers(self, domain):
"""Build header dict from domain object."""
# self.secret is the encoded basic auth, to request a new token from dimail-api
headers = {"Content-Type": "application/json"}
response = requests.get(
f"{settings.MAIL_PROVISIONING_API_URL}/token/",
headers={"Authorization": f"Basic {domain.secret}"},
timeout=status.HTTP_200_OK,
)
if response.json() == "{'detail': 'Permission denied'}":
raise exceptions.PermissionDenied(
"This secret does not allow for a new token."
)
if "access_token" in response.json():
headers["Authorization"] = f"Bearer {response.json()['access_token']}"
return headers
def send_mailbox_request(self, mailbox):
"""Send a CREATE mailbox request to mail provisioning API."""
payload = {
"email": f"{mailbox.local_part}@{mailbox.domain}",
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
}
try:
response = session.post(
f"{settings.MAIL_PROVISIONING_API_URL}/domains/{mailbox.domain}/mailboxes/",
json=payload,
headers=self.get_headers(mailbox.domain),
verify=True,
timeout=10,
)
except requests.exceptions.ConnectionError as e:
logger.error(
"Connection error while trying to reach %s.",
settings.MAIL_PROVISIONING_API_URL,
exc_info=e,
)
if response.status_code == status.HTTP_201_CREATED:
extra = {"response": response.content.decode("utf-8")}
# This a temporary broken solution. Password will soon be sent
# from OX servers but their prod is not ready.
# In the meantime, we log mailbox info (including password !)
logger.info(
"Mailbox successfully created on domain %s",
mailbox.domain.name,
extra=extra,
)
elif response.status_code == status.HTTP_403_FORBIDDEN:
logger.error(
"[DIMAIL] 403 Forbidden: please check the mail domain secret of %s",
mailbox.domain.name,
)
raise exceptions.PermissionDenied(
f"Please check secret of the mail domain {mailbox.domain.name}"
)
return response
+7
View File
@@ -364,6 +364,13 @@ class Base(Configuration):
environ_prefix=None,
)
# mailboxes provisioning API
MAIL_PROVISIONING_API_URL = values.Value(
default="https://main.dev.ox.numerique.gouv.fr",
environ_name="MAIL_PROVISIONING_API_URL",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "people"
version = "0.1.0"
version = "1.0.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+1
View File
@@ -1 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_FEATURE_TEAM=true
+1
View File
@@ -1 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=http://test.jest
NEXT_PUBLIC_FEATURE_TEAM=true
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-desk",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -11,7 +11,7 @@ export function MainLayout({ children }: PropsWithChildren) {
<Box $height="100vh">
<Header />
<Box $css="flex: 1;" $direction="row">
<Menu />
{process.env.NEXT_PUBLIC_FEATURE_TEAM === 'true' && <Menu />}
<Box
as="main"
$height={`calc(100vh - ${HEADER_HEIGHT})`}
@@ -0,0 +1,47 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { AppWrapper } from '@/tests/utils';
import { MainLayout } from '../MainLayout';
jest.mock('next/navigation', () => ({
...jest.requireActual('next/navigation'),
usePathname: () => '/',
}));
describe('MainLayout', () => {
it('checks menu rendering', () => {
render(<MainLayout />, { wrapper: AppWrapper });
expect(
screen.getByRole('button', {
name: /Teams button/i,
}),
).toBeInTheDocument();
expect(
screen.getByRole('button', {
name: /Mail Domains button/i,
}),
).toBeInTheDocument();
});
it('checks menu rendering without team feature', () => {
process.env.NEXT_PUBLIC_FEATURE_TEAM = 'false';
render(<MainLayout />, { wrapper: AppWrapper });
expect(
screen.queryByRole('button', {
name: /Teams button/i,
}),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', {
name: /Mail Domains button/i,
}),
).not.toBeInTheDocument();
});
});
@@ -1,3 +1,5 @@
import { UUID } from 'crypto';
import {
Button,
DataGrid,
@@ -17,7 +19,11 @@ import { MailDomain, MailDomainMailbox } from '../types';
import { CreateMailboxForm } from './forms/CreateMailboxForm';
export type ViewMailbox = { email: string; id: string };
export type ViewMailbox = {
name: string;
email: string;
id: UUID;
};
// FIXME : ask Cunningham to export this type
type SortModelItem = {
@@ -69,6 +75,7 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
mailDomain && data?.results?.length
? data.results.map((mailbox: MailDomainMailbox) => ({
email: `${mailbox.local_part}@${mailDomain.name}`,
name: `${mailbox.first_name} ${mailbox.last_name}`,
id: mailbox.id,
}))
: [];
@@ -76,6 +83,7 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
useEffect(() => {
setPagesCount(data?.count ? Math.ceil(data.count / pageSize) : 0);
}, [data?.count, pageSize, setPagesCount]);
return isLoading ? (
<Box $align="center" $justify="center" $height="100%">
<Loader />
@@ -88,18 +96,35 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
closeModal={() => setIsCreateMailboxFormVisible(false)}
/>
) : null}
<TopBanner
name={mailDomain.name}
setIsFormVisible={setIsCreateMailboxFormVisible}
abilities={mailDomain?.abilities}
/>
<Card
$padding={{ bottom: 'small' }}
$margin={{ all: 'big', top: 'none' }}
$overflow="auto"
>
{error && <TextErrors causes={error.cause} />}
<DataGrid
columns={[
{
field: 'name',
headerName: t('Names'),
renderCell: ({ row }) => (
<Text
$weight="bold"
$theme="primary"
$css="text-transform: capitalize;"
>
{row.name}
</Text>
),
},
{
field: 'email',
headerName: t('Emails'),
@@ -127,9 +152,11 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
const TopBanner = ({
name,
setIsFormVisible,
abilities,
}: {
name: string;
setIsFormVisible: (value: boolean) => void;
abilities: MailDomain['abilities'];
}) => {
const { t } = useTranslation();
@@ -147,12 +174,14 @@ const TopBanner = ({
</Text>
</Box>
<Box $margin={{ all: 'big', bottom: 'small' }} $align="flex-end">
<Button
aria-label={t(`Create a mailbox in {{name}} domain`, { name })}
onClick={() => setIsFormVisible(true)}
>
{t('Create a mailbox')}
</Button>
{abilities.post ? (
<Button
aria-label={t(`Create a mailbox in {{name}} domain`, { name })}
onClick={() => setIsFormVisible(true)}
>
{t('Create a mailbox')}
</Button>
) : null}
</Box>
</>
);
@@ -21,6 +21,11 @@ export const Panel = () => {
$minWidth: '0',
};
const styleNoTeam = process.env.NEXT_PUBLIC_FEATURE_TEAM !== 'true' && {
$display: 'none',
tabIndex: -1,
};
const transition = 'all 0.5s ease-in-out';
return (
@@ -52,6 +57,7 @@ export const Panel = () => {
transition: ${transition};
`}
onClick={() => setIsOpen(!isOpen)}
{...styleNoTeam}
>
<IconOpenClose width={24} height={24} aria-hidden="true" />
</BoxButton>
@@ -6,10 +6,20 @@ export interface MailDomain {
created_at: string;
updated_at: string;
slug: string;
abilities: {
get: boolean;
patch: boolean;
put: boolean;
post: boolean;
delete: boolean;
manage_accesses: boolean;
};
}
export interface MailDomainMailbox {
id: UUID;
local_part: string;
first_name: string;
last_name: string;
secondary_email: string;
}
+5 -15
View File
@@ -1,16 +1,6 @@
import type { ReactElement } from 'react';
import MailDomains from './mail-domains';
import Teams from './teams';
import { TeamLayout } from '@/features/teams/team-management';
import { NextPageWithLayout } from '@/types/next';
import Teams from './teams/';
const Page: NextPageWithLayout = () => {
return <Teams />;
};
Page.getLayout = function getLayout(page: ReactElement) {
return <TeamLayout>{page}</TeamLayout>;
};
export default Page;
export default process.env.NEXT_PUBLIC_FEATURE_TEAM === 'true'
? Teams
: MailDomains;
@@ -15,6 +15,14 @@ const mailDomainsFixtures: MailDomain[] = [
created_at: currentDateIso,
updated_at: currentDateIso,
slug: 'domainfr',
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
},
{
name: 'mails.fr',
@@ -22,6 +30,14 @@ const mailDomainsFixtures: MailDomain[] = [
created_at: currentDateIso,
updated_at: currentDateIso,
slug: 'mailsfr',
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
},
{
name: 'versailles.net',
@@ -29,6 +45,14 @@ const mailDomainsFixtures: MailDomain[] = [
created_at: currentDateIso,
updated_at: currentDateIso,
slug: 'versaillesnet',
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
},
{
name: 'paris.fr',
@@ -36,6 +60,14 @@ const mailDomainsFixtures: MailDomain[] = [
created_at: currentDateIso,
updated_at: currentDateIso,
slug: 'parisfr',
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
},
];
@@ -99,8 +131,7 @@ test.describe('Mail domain create mailbox', () => {
await keyCloakSignIn(page, browserName);
});
// user should have administrator or owner role on this domain to be able perform this action
test('checks user can create a mailbox for a mail domain', async ({
test('checks user can create a mailbox when he has post ability', async ({
page,
}) => {
const newMailbox = {
@@ -224,6 +255,62 @@ test.describe('Mail domain create mailbox', () => {
);
});
test('checks user is not allowed to create a mailbox when he is missing post ability', async ({
page,
}) => {
const localMailDomainsFixtures = [...mailDomainsFixtures];
localMailDomainsFixtures[0].abilities.post = false;
const localMailDomainDomainFr = localMailDomainsFixtures[0];
const localMailboxFixtures = { ...mailboxesFixtures };
const interceptRequests = (page: Page) => {
void page.route('**/api/v1.0/mail-domains/?page=*', (route) => {
void route.fulfill({
json: {
count: localMailDomainsFixtures.length,
next: null,
previous: null,
results: localMailDomainsFixtures,
},
});
});
void page.route('**/api/v1.0/mail-domains/domainfr', (route) => {
void route.fulfill({
json: localMailDomainDomainFr,
});
});
void page.route(
'**/api/v1.0/mail-domains/domainfr/mailboxes/?page=1**',
(route) => {
void route.fulfill({
json: {
count: localMailboxFixtures.domainFr.page1.length,
next: null,
previous: null,
results: localMailboxFixtures.domainFr.page1,
},
});
},
{ times: 1 },
);
};
void interceptRequests(page);
await page
.locator('menu')
.first()
.getByLabel(`Mail Domains button`)
.click();
await page.getByRole('listbox').first().getByText('domain.fr').click();
await expect(
page.getByRole('button', { name: 'Create a mailbox' }),
).not.toBeInViewport();
});
test('checks client invalidation messages are displayed and no mailbox creation request is sent when fields are not properly filled', async ({
page,
}) => {
@@ -12,6 +12,14 @@ const mailDomainsFixtures: MailDomain[] = [
created_at: currentDateIso,
updated_at: currentDateIso,
slug: 'domainfr',
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
},
{
name: 'mails.fr',
@@ -19,6 +27,14 @@ const mailDomainsFixtures: MailDomain[] = [
created_at: currentDateIso,
updated_at: currentDateIso,
slug: 'mailsfr',
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
},
{
name: 'versailles.net',
@@ -26,6 +42,14 @@ const mailDomainsFixtures: MailDomain[] = [
created_at: currentDateIso,
updated_at: currentDateIso,
slug: 'versaillesnet',
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
},
{
name: 'paris.fr',
@@ -33,6 +57,14 @@ const mailDomainsFixtures: MailDomain[] = [
created_at: currentDateIso,
updated_at: currentDateIso,
slug: 'parisfr',
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
},
];
@@ -131,11 +163,15 @@ test.describe('Mail domain', () => {
domainFr: {
page1: Array.from({ length: 20 }, (_, i) => ({
id: `456ac6ca-0402-4615-8005-69bc1efde${i}f`,
first_name: 'john',
last_name: 'doe',
local_part: `local_part-${i}`,
secondary_email: `secondary_email-${i}`,
})),
page2: Array.from({ length: 2 }, (_, i) => ({
id: `456ac6ca-0402-4615-8005-69bc1efde${i}d`,
first_name: 'john',
last_name: 'doe',
local_part: `local_part-${i}`,
secondary_email: `secondary_email-${i}`,
})),
@@ -203,6 +239,10 @@ test.describe('Mail domain', () => {
page.getByRole('heading', { name: 'domain.fr' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: /Names/ }).first(),
).toBeVisible();
await expect(
page.getByRole('button', { name: /Emails/ }).first(),
).toBeVisible();
@@ -217,6 +257,12 @@ test.describe('Mail domain', () => {
),
);
const table = page.locator('table');
await expect(table).toBeVisible();
const tdNames = await table.getByText('John Doe').all();
expect(tdNames.length).toEqual(20);
await expect(
page.locator('.c__pagination__list').getByRole('button', { name: '1' }),
).toBeVisible();
@@ -11,6 +11,14 @@ const mailDomainsFixtures: MailDomain[] = [
created_at: currentDateIso,
updated_at: currentDateIso,
slug: 'domainfr',
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
},
{
name: 'mails.fr',
@@ -18,6 +26,14 @@ const mailDomainsFixtures: MailDomain[] = [
created_at: currentDateIso,
updated_at: currentDateIso,
slug: 'mailsfr',
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
},
{
name: 'versailles.net',
@@ -25,6 +41,14 @@ const mailDomainsFixtures: MailDomain[] = [
created_at: currentDateIso,
updated_at: currentDateIso,
slug: 'versaillesnet',
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
},
{
name: 'paris.fr',
@@ -32,6 +56,14 @@ const mailDomainsFixtures: MailDomain[] = [
created_at: currentDateIso,
updated_at: currentDateIso,
slug: 'parisfr',
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
},
];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "people",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"workspaces": {
"packages": [
@@ -1,6 +1,6 @@
{
"name": "eslint-config-people",
"version": "0.1.0",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:desk",
@@ -50,6 +50,7 @@ backend:
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
MAIL_PROVISIONING_API_URL: "http://host.docker.internal:8000"
command:
- "gunicorn"
- "-c"
@@ -1,7 +1,7 @@
image:
repository: lasuite/people-backend
pullPolicy: Always
tag: "main"
tag: "v1.0.0"
backend:
migrateJobAnnotations:
@@ -84,6 +84,7 @@ backend:
secretKeyRef:
name: redis.redis.libre.sh
key: url
MAIL_PROVISIONING_API_URL: "https://main.dev.ox.numerique.gouv.fr"
createsuperuser:
command:
@@ -96,7 +97,7 @@ frontend:
image:
repository: lasuite/people-frontend
pullPolicy: Always
tag: "main"
tag: "v1.0.0"
ingress:
enabled: true
@@ -1,7 +1,7 @@
image:
repository: lasuite/people-backend
pullPolicy: Always
tag: "v0.1.0"
tag: "v1.0.0"
backend:
migrateJobAnnotations:
@@ -84,6 +84,7 @@ backend:
secretKeyRef:
name: redis.redis.libre.sh
key: url
MAIL_PROVISIONING_API_URL: "https://main.dev.ox.numerique.gouv.fr"
createsuperuser:
command:
@@ -96,7 +97,7 @@ frontend:
image:
repository: lasuite/people-frontend
pullPolicy: Always
tag: "v0.1.0"
tag: "v1.0.0"
ingress:
enabled: true
@@ -84,6 +84,8 @@ backend:
secretKeyRef:
name: redis.redis.libre.sh
key: url
MAIL_PROVISIONING_API_URL: "https://main.dev.ox.numerique.gouv.fr"
createsuperuser:
command: