diff --git a/CHANGELOG.md b/CHANGELOG.md index 2850f99..27ed6b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- ✨(domains) display DNS config expected for domain with required actions - ✨(domains) check status after creation - ✨(domains) display required actions to do on domain - ✨(plugin) add CommuneCreation plugin with domain provisioning #658 diff --git a/src/backend/mailbox_manager/api/client/serializers.py b/src/backend/mailbox_manager/api/client/serializers.py index 03d24a7..e6882bb 100644 --- a/src/backend/mailbox_manager/api/client/serializers.py +++ b/src/backend/mailbox_manager/api/client/serializers.py @@ -74,6 +74,7 @@ class MailDomainSerializer(serializers.ModelSerializer): "support_email", "last_check_details", "action_required_details", + "expected_config", ] read_only_fields = [ "id", @@ -85,6 +86,7 @@ class MailDomainSerializer(serializers.ModelSerializer): "count_mailboxes", "last_check_details", "action_required_details", + "expected_config", ] def get_action_required_details(self, domain) -> dict: @@ -122,6 +124,7 @@ class MailDomainSerializer(serializers.ModelSerializer): # check domain status and update it try: client.fetch_domain_status(domain) + client.fetch_domain_expected_config(domain) except HTTPError as e: logger.exception( "[DIMAIL] domain status fetch after creation failed %s with error %s", diff --git a/src/backend/mailbox_manager/migrations/0021_maildomain_expected_config.py b/src/backend/mailbox_manager/migrations/0021_maildomain_expected_config.py new file mode 100644 index 0000000..cf05f16 --- /dev/null +++ b/src/backend/mailbox_manager/migrations/0021_maildomain_expected_config.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-02-14 17:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mailbox_manager', '0020_alter_mailbox_options_alter_maildomain_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='maildomain', + name='expected_config', + field=models.JSONField(blank=True, help_text='A JSON object containing the expected config', null=True, verbose_name='expected config'), + ), + ] diff --git a/src/backend/mailbox_manager/models.py b/src/backend/mailbox_manager/models.py index 185cf25..c76ff1f 100644 --- a/src/backend/mailbox_manager/models.py +++ b/src/backend/mailbox_manager/models.py @@ -36,6 +36,12 @@ class MailDomain(BaseModel): verbose_name=_("last check details"), help_text=_("A JSON object containing the last health check details"), ) + expected_config = models.JSONField( + null=True, + blank=True, + verbose_name=_("expected config"), + help_text=_("A JSON object containing the expected config"), + ) class Meta: db_table = "people_mail_domain" diff --git a/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_create.py b/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_create.py index 8874e76..9393841 100644 --- a/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_create.py +++ b/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_create.py @@ -15,7 +15,11 @@ from rest_framework.test import APIClient from core import factories as core_factories from mailbox_manager import enums, factories, models -from mailbox_manager.tests.fixtures.dimail import CHECK_DOMAIN_BROKEN, CHECK_DOMAIN_OK +from mailbox_manager.tests.fixtures.dimail import ( + CHECK_DOMAIN_BROKEN, + CHECK_DOMAIN_OK, + DOMAIN_SPEC, +) pytestmark = pytest.mark.django_db @@ -108,6 +112,13 @@ def test_api_mail_domains__create_authenticated(): status=status.HTTP_200_OK, content_type="application/json", ) + responses.add( + responses.GET, + re.compile(rf".*/domains/{domain_name}/spec/"), + body=json.dumps(DOMAIN_SPEC), + status=status.HTTP_200_OK, + content_type="application/json", + ) response = client.post( "/api/v1.0/mail-domains/", { @@ -145,6 +156,7 @@ def test_api_mail_domains__create_authenticated(): "or je trouve example-fr.mail.protection.outlook.com.", "spf": "Le SPF record ne contient pas include:_spf.ox.numerique.gouv.fr", }, + "expected_config": DOMAIN_SPEC, } # a new domain with status "action required" is created and authenticated user is the owner @@ -212,6 +224,7 @@ def test_api_mail_domains__create_authenticated__dimail_failure(caplog): status=dimail_error["status_code"], content_type="application/json", ) + response = client.post( "/api/v1.0/mail-domains/", { @@ -237,6 +250,7 @@ def test_api_mail_domains__create_authenticated__dimail_failure(caplog): "support_email": domain.support_email, "last_check_details": None, "action_required_details": {}, + "expected_config": None, } # a new domain with status "failed" is created and authenticated user is the owner @@ -302,6 +316,13 @@ def test_api_mail_domains__create_dimail_domain(caplog): status=status.HTTP_200_OK, content_type="application/json", ) + responses.add( + responses.GET, + re.compile(rf".*/domains/{domain_name}/spec/"), + body=json.dumps(DOMAIN_SPEC), + status=status.HTTP_200_OK, + content_type="application/json", + ) response = client.post( "/api/v1.0/mail-domains/", { diff --git a/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_retrieve.py b/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_retrieve.py index 32ca138..5729163 100644 --- a/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_retrieve.py +++ b/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_retrieve.py @@ -2,7 +2,11 @@ Tests for MailDomains API endpoint in People's mailbox manager app. Focus on "retrieve" action. """ +import json +import re + import pytest +import responses from rest_framework import status from rest_framework.test import APIClient @@ -14,6 +18,7 @@ from mailbox_manager.tests.fixtures import dimail as dimail_fixtures pytestmark = pytest.mark.django_db +@responses.activate def test_api_mail_domains__retrieve_anonymous(): """Anonymous users should not be allowed to retrieve a domain.""" @@ -24,8 +29,11 @@ def test_api_mail_domains__retrieve_anonymous(): assert response.json() == { "detail": "Authentication credentials were not provided." } + # Verify no calls were made to dimail API + assert len(responses.calls) == 0 +@responses.activate def test_api_domains__retrieve_non_existing(): """ Authenticated users should have an explicit error when trying to retrive @@ -39,8 +47,11 @@ def test_api_domains__retrieve_non_existing(): ) assert response.status_code == status.HTTP_404_NOT_FOUND assert response.json() == {"detail": "Not found."} + # Verify no calls were made to dimail API + assert len(responses.calls) == 0 +@responses.activate def test_api_mail_domains__retrieve_authenticated_unrelated(): """ Authenticated users should not be allowed to retrieve a domain @@ -60,6 +71,7 @@ def test_api_mail_domains__retrieve_authenticated_unrelated(): assert response.json() == {"detail": "No MailDomain matches the given query."} +@responses.activate def test_api_mail_domains__retrieve_authenticated_related(): """ Authenticated users should be allowed to retrieve a domain @@ -91,9 +103,11 @@ def test_api_mail_domains__retrieve_authenticated_related(): "support_email": domain.support_email, "last_check_details": None, "action_required_details": {}, + "expected_config": None, } +@responses.activate def test_api_mail_domains__retrieve_authenticated_related_with_action_required(): """ Authenticated users should be allowed to retrieve a domain @@ -113,7 +127,6 @@ def test_api_mail_domains__retrieve_authenticated_related_with_action_required() response = client.get( f"/api/v1.0/mail-domains/{domain.slug}/", ) - assert response.status_code == status.HTTP_200_OK assert response.json() == { "id": str(domain.id), @@ -130,9 +143,11 @@ def test_api_mail_domains__retrieve_authenticated_related_with_action_required() "mx": "Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr., " "or je trouve example-fr.mail.protection.outlook.com.", }, + "expected_config": None, } +@responses.activate def test_api_mail_domains__retrieve_authenticated_related_with_ok_status(): """ Authenticated users should be allowed to retrieve a domain @@ -165,4 +180,43 @@ def test_api_mail_domains__retrieve_authenticated_related_with_ok_status(): "support_email": domain.support_email, "last_check_details": dimail_fixtures.CHECK_DOMAIN_OK, "action_required_details": {}, + "expected_config": None, + } + + +@responses.activate +def test_api_mail_domains__retrieve_authenticated_related_with_failed_status(): + """ + Authenticated users should be allowed to retrieve a domain + to which they have access and which has failed status. + """ + user = core_factories.UserFactory() + + client = APIClient() + client.force_login(user) + + domain = factories.MailDomainFactory( + status=enums.MailDomainStatusChoices.FAILED, + last_check_details=dimail_fixtures.CHECK_DOMAIN_BROKEN_INTERNAL, + ) + factories.MailDomainAccessFactory(domain=domain, user=user) + + response = client.get( + f"/api/v1.0/mail-domains/{domain.slug}/", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "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), + "count_mailboxes": 0, + "support_email": domain.support_email, + "last_check_details": dimail_fixtures.CHECK_DOMAIN_BROKEN_INTERNAL, + "action_required_details": {}, + "expected_config": None, } diff --git a/src/backend/mailbox_manager/tests/fixtures/dimail.py b/src/backend/mailbox_manager/tests/fixtures/dimail.py index 7931500..f7e06c2 100644 --- a/src/backend/mailbox_manager/tests/fixtures/dimail.py +++ b/src/backend/mailbox_manager/tests/fixtures/dimail.py @@ -255,6 +255,25 @@ CHECK_DOMAIN_OK = { "cert": {"ok": True, "internal": True, "errors": []}, } +# pylint: disable=line-too-long +DOMAIN_SPEC = [ + {"target": "", "type": "mx", "value": "mx.ox.numerique.gouv.fr."}, + { + "target": "dimail._domainkey", + "type": "txt", + "value": "v=DKIM1; h=sha256; k=rsa; p=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + }, + {"target": "imap", "type": "cname", "value": "imap.ox.numerique.gouv.fr."}, + {"target": "smtp", "type": "cname", "value": "smtp.ox.numerique.gouv.fr."}, + { + "target": "", + "type": "txt", + "value": "v=spf1 include:_spf.ox.numerique.gouv.fr -all", + }, + {"target": "webmail", "type": "cname", "value": "webmail.ox.numerique.gouv.fr."}, +] + + ## TOKEN TOKEN_OK = json.dumps({"access_token": "token", "token_type": "bearer"}) diff --git a/src/backend/mailbox_manager/utils/dimail.py b/src/backend/mailbox_manager/utils/dimail.py index 6309264..1a9001d 100644 --- a/src/backend/mailbox_manager/utils/dimail.py +++ b/src/backend/mailbox_manager/utils/dimail.py @@ -519,3 +519,48 @@ class DimailAPIClient: if isinstance(value, dict) and value.get("internal") is internal } return {key: value.get("ok", False) for key, value in checks.items()} + + def fetch_domain_expected_config(self, domain): + """Send a request to dimail to get domain specification for DNS configuration.""" + try: + response = session.get( + f"{self.API_URL}/domains/{domain.name}/spec/", + headers={"Authorization": f"Basic {self.API_CREDENTIALS}"}, + verify=True, + timeout=10, + ) + except requests.exceptions.ConnectionError as error: + logger.exception( + "Connection error while trying to reach %s.", + self.API_URL, + exc_info=error, + ) + return [] + if response.status_code == status.HTTP_200_OK: + # format the response to log an error if api response changed + try: + dimail_expected_config = [ + { + "target": item["target"], + "type": item["type"], + "value": item["value"], + } + for item in response.json() + ] + domain.expected_config = dimail_expected_config + domain.save() + return dimail_expected_config + except KeyError as error: + logger.exception( + "[DIMAIL] spec expected response format changed: %s", + error, + ) + return [] + else: + logger.exception( + "[DIMAIL] unexpected error : %s %s", + response.status_code, + response.content, + exc_info=False, + ) + return []