Compare commits

..

1 Commits

Author SHA1 Message Date
Eléonore Voisin 7522569ce0 Try to fix test 2025-02-07 10:05:03 +01:00
140 changed files with 1262 additions and 6201 deletions
+2 -2
View File
@@ -9,9 +9,9 @@ We primarily use GitHub as an issue tracker. If however you're encountering an i
---
Please make sure you have read our [main Readme](https://github.com/suitenumerique/people).
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/people).
Also make sure it was not already answered in [an open or close issue](https://github.com/suitenumerique/people/issues).
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/people/issues).
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
+5 -2
View File
@@ -201,6 +201,9 @@ jobs:
run: |
make dimail-setup-db
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
@@ -266,7 +269,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '3.10'
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -321,7 +324,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '3.10'
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install gettext (required to compile messages)
+1 -4
View File
@@ -20,10 +20,7 @@ jobs:
fetch-depth: 0
- name: Cleanup
run: |
rm -rf ./src/helm/extra
rm -rf ./src/helm/dimail
rm -rf ./src/helm/maildev
run: rm -rf ./src/helm/extra
- name: Install Helm
uses: azure/setup-helm@v4
+21 -67
View File
@@ -8,46 +8,6 @@ and this project adheres to
## [Unreleased]
## [1.13.1] - 2025-03-04
### Fixed
- 🐛(mailbox) fix migration to fill dn_email field
## [1.13.0] - 2025-03-04
### Added
- ✨(oidc) people as an identity provider #638
### Fixed
- 💄(domains) improve user experience and avoid repeat fix_domain operations
- 👽️(dimail) increase timeout value for check domain API call
- 🧱(helm) add resource-server ingress path #743
- 🌐(backend) synchronize translations with crowdin again
## [1.12.1] - 2025-02-20
### Fixed
- 👽️(dimail) increase timeout value for API calls
## [1.12.0] - 2025-02-18
### Added
- ✨(domains) allow user to re-run all fetch domain data from dimail
- ✨(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
- ✨(frontend) display action required status on domain
- ✨(domains) store last health check details on MailDomain
- ✨(domains) add support email field on domain
## [1.11.0] - 2025-02-07
### Added
- ✨(api) add count mailboxes to MailDomain serializer
@@ -57,7 +17,6 @@ and this project adheres to
### Fixed
- ✨(auth) fix empty names from ProConnect #687
- 🚑️(teams) do not display add button when disallowed #676
- 🚑️(plugins) fix name from SIRET specific case #674
- 🐛(api) restrict mailbox sync to enabled domains
@@ -317,29 +276,24 @@ and this project adheres to
- ✨(domains) create and manage domains on admin + API
- ✨(domains) mailbox creation + link to email provisioning API
[unreleased]: https://github.com/suitenumerique/people/compare/v1.13.1...main
[1.13.1]: https://github.com/suitenumerique/people/releases/v1.13.1
[1.13.0]: https://github.com/suitenumerique/people/releases/v1.13.0
[1.12.1]: https://github.com/suitenumerique/people/releases/v1.12.1
[1.12.0]: https://github.com/suitenumerique/people/releases/v1.12.0
[1.11.0]: https://github.com/suitenumerique/people/releases/v1.11.0
[1.10.1]: https://github.com/suitenumerique/people/releases/v1.10.1
[1.10.0]: https://github.com/suitenumerique/people/releases/v1.10.0
[1.9.1]: https://github.com/suitenumerique/people/releases/v1.9.1
[1.9.0]: https://github.com/suitenumerique/people/releases/v1.9.0
[1.8.0]: https://github.com/suitenumerique/people/releases/v1.8.0
[1.7.1]: https://github.com/suitenumerique/people/releases/v1.7.1
[1.7.0]: https://github.com/suitenumerique/people/releases/v1.7.0
[1.6.1]: https://github.com/suitenumerique/people/releases/v1.6.1
[1.6.0]: https://github.com/suitenumerique/people/releases/v1.6.0
[1.5.0]: https://github.com/suitenumerique/people/releases/v1.5.0
[1.4.1]: https://github.com/suitenumerique/people/releases/v1.4.1
[1.4.0]: https://github.com/suitenumerique/people/releases/v1.4.0
[1.3.1]: https://github.com/suitenumerique/people/releases/v1.3.1
[1.3.0]: https://github.com/suitenumerique/people/releases/v1.3.0
[1.2.1]: https://github.com/suitenumerique/people/releases/v1.2.1
[1.2.0]: https://github.com/suitenumerique/people/releases/v1.2.0
[1.1.0]: https://github.com/suitenumerique/people/releases/v1.1.0
[1.0.2]: https://github.com/suitenumerique/people/releases/v1.0.2
[1.0.1]: https://github.com/suitenumerique/people/releases/v1.0.1
[1.0.0]: https://github.com/suitenumerique/people/releases/v1.0.0
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.10.1...main
[1.10.1]: https://github.com/numerique-gouv/people/releases/v1.10.1
[1.10.0]: https://github.com/numerique-gouv/people/releases/v1.10.0
[1.9.1]: https://github.com/numerique-gouv/people/releases/v1.9.1
[1.9.0]: https://github.com/numerique-gouv/people/releases/v1.9.0
[1.8.0]: https://github.com/numerique-gouv/people/releases/v1.8.0
[1.7.1]: https://github.com/numerique-gouv/people/releases/v1.7.1
[1.7.0]: https://github.com/numerique-gouv/people/releases/v1.7.0
[1.6.1]: https://github.com/numerique-gouv/people/releases/v1.6.1
[1.6.0]: https://github.com/numerique-gouv/people/releases/v1.6.0
[1.5.0]: https://github.com/numerique-gouv/people/releases/v1.5.0
[1.4.1]: https://github.com/numerique-gouv/people/releases/v1.4.1
[1.4.0]: https://github.com/numerique-gouv/people/releases/v1.4.0
[1.3.1]: https://github.com/numerique-gouv/people/releases/v1.3.1
[1.3.0]: https://github.com/numerique-gouv/people/releases/v1.3.0
[1.2.1]: https://github.com/numerique-gouv/people/releases/v1.2.1
[1.2.0]: https://github.com/numerique-gouv/people/releases/v1.2.0
[1.1.0]: https://github.com/numerique-gouv/people/releases/v1.1.0
[1.0.2]: https://github.com/numerique-gouv/people/releases/v1.0.2
[1.0.1]: https://github.com/numerique-gouv/people/releases/v1.0.1
[1.0.0]: https://github.com/numerique-gouv/people/releases/v1.0.0
-6
View File
@@ -41,12 +41,6 @@ RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3
USER nginx
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
+1 -5
View File
@@ -72,7 +72,6 @@ data/static:
create-env-files: ## Copy the dist env files to env files
create-env-files: \
env.d/development/common \
env.d/development/france \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql
@@ -229,9 +228,6 @@ resetdb: ## flush database and create a superuser "admin"
env.d/development/common:
cp -n env.d/development/common.dist env.d/development/common
env.d/development/france:
cp -n env.d/development/france.dist env.d/development/france
env.d/development/postgresql:
cp -n env.d/development/postgresql.dist env.d/development/postgresql
@@ -332,7 +328,7 @@ help:
# Front
install-front-desk: ## Install the frontend dependencies of app Desk
cd $(PATH_FRONT_DESK) && yarn && yarn playwright install chromium
cd $(PATH_FRONT_DESK) && yarn
.PHONY: install-front-desk
run-front-desk: ## Start app Desk
-29
View File
@@ -59,35 +59,6 @@ cmd_button('Migrate db',
text='Run database migration',
)
# Command to reset DB
reset_db = '''
set -eu
# get k8s pod name from tilt resource name
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
kubectl -n desk exec "$POD_NAME" -- python manage.py flush --no-input
kubectl -n desk exec "$POD_NAME" -- python manage.py createsuperuser --username admin@example.com --password admin
'''
cmd_button('Reset DB',
argv=['sh', '-c', reset_db],
resource='desk-backend',
icon_name='developer_board',
text='Reset DB',
)
# Command to create demo data
populate_people_with_demo_data = '''
set -eu
# get k8s pod name from tilt resource name
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
kubectl -n desk exec "$POD_NAME" -- python manage.py create_demo --force
'''
cmd_button('Populate with demo data',
argv=['sh', '-c', populate_people_with_demo_data],
resource='desk-backend',
icon_name='developer_board',
text='Populate with demo data',
)
# Command to created domain/users/access from people to dimail
populate_dimail_from_people = '''
set -eu
+3 -6
View File
@@ -9,8 +9,8 @@ services:
redis:
image: redis:5
maildev:
image: maildev/maildev:latest
mailcatcher:
image: sj26/mailcatcher:latest
ports:
- "1081:1080"
@@ -27,7 +27,6 @@ services:
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/common
- env.d/development/france
- env.d/development/postgresql
ports:
- "8071:8000"
@@ -38,7 +37,7 @@ services:
depends_on:
- dimail
- postgresql
- maildev
- mailcatcher
- redis
celery-dev:
@@ -129,8 +128,6 @@ services:
image: quay.io/keycloak/keycloak:20.0.1
volumes:
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
extra_hosts:
- "host.docker.internal:host-gateway"
command:
- start-dev
- --features=preview
+4 -126
View File
@@ -65,7 +65,7 @@
"lastName": "Delamairie",
"enabled": true,
"attributes": {
"siret": "21510339100011"
"siret": "21580304000017"
},
"credentials": [
{
@@ -1652,130 +1652,8 @@
"enabledEventTypes": [],
"adminEventsEnabled": false,
"adminEventsDetailsEnabled": false,
"identityProviders": [
{
"alias": "oidc-people-local",
"displayName": "People OIDC (local)",
"internalId": "47aa6d7c-8ac5-4178-934e-66f78e510ee4",
"providerId": "oidc",
"enabled": true,
"updateProfileFirstLoginMode": "on",
"trustEmail": false,
"storeToken": false,
"addReadTokenRoleOnCreate": false,
"authenticateByDefault": false,
"linkOnly": false,
"firstBrokerLoginFlowAlias": "first broker login",
"config": {
"hideOnLoginPage": "false",
"userInfoUrl": "http://app-dev:8000/o/userinfo/",
"validateSignature": "true",
"acceptsPromptNoneForwardFromClient": "false",
"clientId": "people-idp",
"tokenUrl": "http://app-dev:8000/o/token/",
"uiLocales": "false",
"jwksUrl": "http://app-dev:8000/o/.well-known/jwks.json",
"backchannelSupported": "false",
"issuer": "http://app-dev:8000/o",
"useJwksUrl": "true",
"loginHint": "true",
"pkceEnabled": "true",
"pkceMethod": "S256",
"authorizationUrl": "http://localhost:8071/o/authorize/",
"clientAuthMethod": "client_secret_post",
"disableUserInfo": "false",
"syncMode": "IMPORT",
"clientSecret": "local-tests-only",
"passMaxAge": "false",
"defaultScope": "openid given_name usual_name email siret",
"allowedClockSkew": "0"
}
}
],
"identityProviderMappers": [
{
"id": "e55dc88c-7bb5-46fb-95ad-1df701a96282",
"name": "Sub",
"identityProviderAlias": "oidc-people-local",
"identityProviderMapper": "oidc-username-idp-mapper",
"config": {
"template": "${CLAIM.sub}",
"are.claim.values.regex": "false",
"claims": "[{\"key\":\"\",\"value\":\"\"}]",
"syncMode": "FORCE",
"attributes": "[]",
"target": "BROKER_ID"
}
},
{
"id": "7e489676-8cba-49e4-aa1e-dcd1462d33f7",
"name": "given_name",
"identityProviderAlias": "oidc-people-local",
"identityProviderMapper": "hardcoded-attribute-idp-mapper",
"config": {
"claims": "[{\"key\":\"\",\"value\":\"\"}]",
"syncMode": "FORCE",
"are.claim.values.regex": "false",
"attributes": "[]",
"attribute": "firstName"
}
},
{
"id": "30b6b3bc-5738-4936-bf88-c540b8805998",
"name": "usual_name",
"identityProviderAlias": "oidc-people-local",
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
"config": {
"template": "${ALIAS}.${CLAIM.preferred_username}",
"are.claim.values.regex": "false",
"claims": "[{\"key\":\"profile\",\"value\":\"lastName\"}]",
"syncMode": "FORCE",
"claim": "profile",
"user.attribute": "lastName",
"attributes": "[]",
"target": "LOCAL"
}
},
{
"id": "b67caa26-4571-4cfe-9c15-68e022645fc5",
"name": "Username",
"identityProviderAlias": "oidc-people-local",
"identityProviderMapper": "oidc-username-idp-mapper",
"config": {
"template": "${CLAIM.email | lowercase}",
"are.claim.values.regex": "false",
"claims": "[{\"key\":\"\",\"value\":\"\"}]",
"syncMode": "FORCE",
"attributes": "[]",
"target": "BROKER_USERNAME"
}
},
{
"id": "4eef21ce-b5f7-4753-bd58-4e50eb2b5f31",
"name": "Email",
"identityProviderAlias": "oidc-people-local",
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
"config": {
"are.claim.values.regex": "false",
"claims": "[{\"key\":\"\",\"value\":\"\"}]",
"syncMode": "FORCE",
"claim": "email",
"user.attribute": "email",
"attributes": "[]"
}
},
{
"id": "084cdd0e-0794-4388-8474-84c9a7c1b9c8",
"name": "siret",
"identityProviderAlias": "oidc-people-local",
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
"config": {
"syncMode": "FORCE",
"claim": "siret",
"user.attribute": "siret"
}
}
],
"identityProviders": [],
"identityProviderMappers": [],
"components": {
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [
{
@@ -2317,7 +2195,7 @@
"authenticatorConfig": "review profile config",
"authenticator": "idp-review-profile",
"authenticatorFlow": false,
"requirement": "DISABLED",
"requirement": "REQUIRED",
"priority": 10,
"autheticatorFlow": false,
"userSetupAllowed": false
-100
View File
@@ -1,100 +0,0 @@
# People as an Identity Provider
The people project can be configured to act as an Identity Provider for an OIDC federation.
The model containing the "identity" in `people` is the `Mailbox` model.
The connection workflow looks like:
- The user tries to reach a service provider.
- The service provider redirects the user to OIDC federation (in dev we use Keycloak).
- The user asks (or is redirected) to the Identity Provider (people).
- The user enter their email and password.
- If successful, the user is redirected to the OIDC federation with a token.
- The federation make the same Authorization Flow as usual to authenticate on the Service Provider.
## Technical aspects
### The `Mailbox` model
The `Mailbox` model can behave like a usual Django user:
- It has a `password` field (and `last_login`)
- It is considered as `authenticated`.
- To be `active` it must have an `enabled` status.
- To be able to log in, it must also have a `MailboxDomain` with an `enabled` status and an associated `Organization`.
### The `MailboxModelBackend` authentication backend
This authentication backend only allow authentication with `email` and `password` with a `Mailbox` user.
This backend is combined with the `one_time_email_authenticated_session` middleware to restrict
the session to the OIDC login process.
A user connecting with this backend will only be able to access the `/o/authorize/` URL.
### The OIDC validators
There are two validators available:
- `BaseValidator`: This validator retrieves simple claims
- `ProConnectValidator`: This validator is a custom validator to allow ProConnect specific requirements.
## Configuration
The keycloak configuration is not described here, but you may find information in the code base.
### Django settings
The following settings are required to enable the OIDC Identity Provider feature:
- OAUTH2_PROVIDER_OIDC_ENABLED: True
- OAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY: ...
Optional:
- OAUTH2_PROVIDER_VALIDATOR_CLASS: "mailbox_oauth2.validators.ProConnectValidator"
If the `OAUTH2_PROVIDER_VALIDATOR_CLASS` is set to the `ProConnectValidator`, the claims will be
automatically defined to match the ProConnect requirements.
### Django OIDC application
To enable an OIDC client to use people, you must declare it.
```python
from oauth2_provider.models import Application
application = Application(
client_id="people-idp",
client_secret="local-tests-only",
client_type=Application.CLIENT_CONFIDENTIAL,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
name="People Identity Provider",
algorithm=Application.RS256_ALGORITHM,
redirect_uris="http://localhost:8083/realms/people/broker/oidc-people-local/endpoint",
skip_authorization=True,
)
application.clean()
application.save()
```
## Security concerns
### Failed login cooldown
To prevent brute force attacks, 5 failed login will trigger a cooldown of 5 minutes before being able to log in again.
### Password strength
There is currently no constraint on the password strength as it can only be defined by Django administrators,
and later we will generate it.
If at some point the user is allowed to set their password, we will need to enforce a password strength policy.
## What is missing (next steps)
- `Mailbox` password can only be set through Django Admin panel (or shell), the next step will be to generate
a secure password and send it or display it to the end user.
- `MailboxDomain` organization can only be set through Django Admin panel (or shell), we need to provide a
way to auto-select it or allow an administrator to do so.
+76 -20
View File
@@ -5,8 +5,6 @@ Tilt is a tool that helps you develop applications for Kubernetes.
It watches your files for changes, rebuilds your containers, and restarts your pods.
It's like having a conversation with your cluster.
This is particularly useful when working on integrations or to test your helm charts.
Otherwise, you can use your good old docker configuration as described in README.md.
## Prerequisites
@@ -15,55 +13,113 @@ This guide assumes you have the following tools installed:
- [Docker](https://docs.docker.com/get-docker/)
- [Kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/)
- [Kind](https://kind.sigs.k8s.io/docs/user/quick-start/)
* [mkcert](https://github.com/FiloSottile/mkcert)
* [mkcert](https://github.com/FiloSottile/mkcert)
- [ctlptl](https://github.com/tilt-dev/ctlptl)
- [Tilt](https://docs.tilt.dev/install.html)
* [helm](https://helm.sh/docs/intro/install/)
* [helmfile](https://github.com/helmfile/helmfile)
* [secrets](https://github.com/jkroepke/helm-secrets/wiki/Installation)
* [sops](https://github.com/getsops/sops)
### SOPS configuration
**Generate a SOPS key**
For this specific step you need to have the `age-keygen` tool installed.
See https://github.com/FiloSottile/age.
Then generate a key:
```bash
age-keygen -o my-age.key
```
**Install the SOPS key**
Read the SOPS documentation on how to install the key in your environment.
https://github.com/getsops/sops?tab=readme-ov-file#22encrypting-using-age
On Ubuntu it's like:
```bash
mkdir -p ~/.config/sops/age/
cp my-age.key ~/.config/sops/age/keys.txt
chmod 400 ~/.config/sops/age/keys.txt
```
**Add the SOPS key to the repository**
Update the [.sops.yaml](../.sops.yaml) file with the **public** key id you generated.
[Install_prereq script](https://github.com/numerique-gouv/dk8s/blob/main/scripts/install-prereq.sh) (not tested).
### Helmfile in Docker
If you use helmfile in Docker, you may need an additional configuration to make
it work with you age key.
You need to mount `-v "${HOME}/.config/sops/age/:/helm/.config/sops/age/"`
```bash
#!/bin/sh
docker run --rm --net=host \
-v "${HOME}/.kube:/root/.kube" \
-v "${HOME}/.config/helm:/root/.config/helm" \
-v "${HOME}/.config/sops/age/:/helm/.config/sops/age/" \
-v "${HOME}/.minikube:/${HOME}/.minikube" \
-v "${PWD}:/wd" \
-e KUBECONFIG=/root/.kube/config \
--workdir /wd ghcr.io/helmfile/helmfile:v0.150.0 helmfile "$@"
```
## Create the kubernetes cluster
## Getting started
### Create the kubernetes cluster
Run the following command to create a kubernetes cluster using kind:
```bash
make start-kind
./bin/start-kind.sh
```
# import your secrets from credentials manager
# ! don't forget "https" before your url
**or** run the equivalent using the makefile
```bash
make start-kind
```
### [Optional] Install the secret
You don't need to do this if you are running the stack with keycloak.
```bash
make install-external-secrets
```
### Deploy the application
```bash
# Pro Connect environment
tilt up -f ./bin/Tiltfile
# Standalone environment with keycloak
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
```
**or** run the equivalent using the makefile
```bash
# Pro Connect environment
make tilt-up
# Standalone environment with keycloak
make tilt-up-keycloak
```
That's it! You should now have a local development environment for Kubernetes.
## Start the application
```bash
# You can either start :
# ProConnect stack (but secrets must be set on your local cluster)
make tilt-up
# or standalone environment with keycloak
make start-tilt-keycloak
```
Access your application at https://desk.127.0.0.1.nip.io
You can access the application at https://desk.127.0.0.1.nip.io
## Management
+1 -1
View File
@@ -10,7 +10,7 @@ PYTHONPATH=/app
# People settings
# Mail
DJANGO_EMAIL_HOST="maildev"
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_PORT=1025
# Backend url
-3
View File
@@ -1,6 +1,3 @@
# For the CI job test-e2e
SUSTAINED_THROTTLE_RATES="200/hour"
BURST_THROTTLE_RATES="200/minute"
OAUTH2_PROVIDER_OIDC_ENABLED = True
OAUTH2_PROVIDER_VALIDATOR_CLASS: "mailbox_oauth2.validators.ProConnectValidator"
-1
View File
@@ -1 +0,0 @@
ORGANIZATION_PLUGINS=plugins.organizations.NameFromSiretOrganizationPlugin,plugins.organizations.CommuneCreation
+1 -1
View File
@@ -13,7 +13,7 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint", "@hookform/resolvers"]
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
}
]
}
+5 -5
View File
@@ -76,7 +76,7 @@ def update_changelog(path, version):
file.writelines(lines)
def deployment_part(version):
def deployment_part(version, kind):
""" Update helm file of preprod on deployment repository numerique-gouv/lasuite-deploiement
"""
# Move to lasuite-deploiement repository
@@ -95,7 +95,7 @@ def deployment_part(version):
path = f"manifests/regie/env.d/preprod/values.desk.yaml.gotmpl"
update_helm_files(path)
run_command(f"git add {path}", shell=True)
message = f"""🔖(people) bump preprod version to {version}"""
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}"""
run_command(f"git commit -m '{message}'", shell=True)
confirm = input(f"""\033[0;32m
### DEPLOYMENT ###
@@ -107,7 +107,7 @@ Continue ? (y,n)
run_command(f"git push origin {deployment_branch}", shell=True)
sys.stdout.write(f"""\033[1;34m
PLEASE DO THE FOLLOWING INSTRUCTIONS:
--> Please submit PR {deployment_branch} and merge code to main [https://github.com/numerique-gouv/lasuite-deploiement]
--> Please submit PR {deployment_branch} and merge code to main
\x1b[0m""")
@@ -137,7 +137,7 @@ Continue ? (y,n)
run_command(f"git push origin {branch_to_release}", shell=True)
sys.stdout.write(f"""
\033[1;34mPLEASE DO THE FOLLOWING INSTRUCTIONS:
--> Please submit PR {branch_to_release} and merge code to main [https://github.com/suitenumerique/people/]
--> Please submit PR {branch_to_release} and merge code to main
--> Then do:
>> git checkout main
>> git pull
@@ -158,4 +158,4 @@ if __name__ == "__main__":
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
if "--only-deployment-part" not in sys.argv:
project_part(version, kind)
deployment_part(version)
deployment_part(version, kind)
+1 -11
View File
@@ -60,17 +60,7 @@ class UserAdmin(auth_admin.UserAdmin):
)
},
),
(
_("Personal info"),
{
"fields": (
"name",
"email",
"language",
"timezone",
)
},
),
(_("Personal info"), {"fields": ("name", "email", "language", "timezone")}),
(
_("Permissions"),
{
+1 -2
View File
@@ -7,7 +7,6 @@ from functools import reduce
from django.conf import settings
from django.db.models import OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
@@ -606,7 +605,7 @@ class StatView(views.APIView):
context = {
"total_users": models.User.objects.count(),
"mau": models.User.objects.filter(
last_login__gte=timezone.now() - datetime.timedelta(30)
last_login__gte=datetime.datetime.now() - datetime.timedelta(30)
).count(),
"teams": models.Team.objects.count(),
"domains": domains_models.MailDomain.objects.count(),
+3 -2
View File
@@ -95,12 +95,13 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
)
# Get user's full name from OIDC fields defined in settings
full_name = self.compute_full_name(user_info)
email = user_info.get("email")
claims = {
"sub": sub,
"email": email,
"name": self.compute_full_name(user_info),
"name": full_name,
}
if settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD:
claims[settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD] = user_info.get(
@@ -119,7 +120,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
# Data cleaning, to be removed when user organization is null=False
# or all users have an organization.
# See https://github.com/suitenumerique/people/issues/504
# See https://github.com/numerique-gouv/people/issues/504
if not user.organization_id:
organization_registration_id = claims.get(
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
+2 -2
View File
@@ -2,7 +2,7 @@
from django.urls import path
from mozilla_django_oidc.urls import urlpatterns as mozilla_oidc_urls
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
from .views import OIDCLogoutCallbackView, OIDCLogoutView
@@ -14,5 +14,5 @@ urlpatterns = [
OIDCLogoutCallbackView.as_view(),
name="oidc_logout_callback",
),
*mozilla_oidc_urls,
*mozzila_oidc_urls,
]
-1
View File
@@ -1,4 +1,3 @@
# pylint: disable=too-many-ancestors
"""
Core application enums declaration
"""
+1 -1
View File
@@ -240,7 +240,7 @@ class TeamWebhookFactory(factory.django.DjangoModelFactory):
model = models.TeamWebhook
team = factory.SubFactory(TeamFactory)
url = factory.Sequence(lambda n: f"https://nosuchdomain.xyz/Groups/{n!s}")
url = factory.Sequence(lambda n: f"https://example.com/Groups/{n!s}")
class InvitationFactory(factory.django.DjangoModelFactory):
+1 -26
View File
@@ -29,10 +29,7 @@ from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager
from core.enums import WebhookStatusChoices
from core.plugins.loader import (
organization_plugins_run_after_create,
organization_plugins_run_after_grant_access,
)
from core.plugins.loader import organization_plugins_run_after_create
from core.utils.webhooks import scim_synchronizer
from core.validators import get_field_validators_from_setting
@@ -301,22 +298,6 @@ class OrganizationManager(models.Manager):
return instance
class OrganizationAccessManager(models.Manager):
"""
Custom manager for the OrganizationAccess model, to manage complexity/automation.
"""
def create(self, **kwargs):
"""
Create an organization access with the given kwargs.
This method is overridden to call the Organization plugins.
"""
instance = super().create(**kwargs)
organization_plugins_run_after_grant_access(instance)
return instance
class Organization(BaseModel):
"""
Organization model used to regroup Teams.
@@ -637,8 +618,6 @@ class OrganizationAccess(BaseModel):
default=OrganizationRoleChoices.ADMIN,
)
objects = OrganizationAccessManager()
class Meta:
db_table = "people_organization_access"
verbose_name = _("Organization/user relation")
@@ -1000,7 +979,3 @@ class Invitation(BaseModel):
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", self.email, exception)
# It's not clear yet how best to split this file.
# pylint: disable=C0302
-6
View File
@@ -11,9 +11,3 @@ class BaseOrganizationPlugin:
def run_after_create(self, organization) -> None:
"""Method called after creating an organization."""
raise NotImplementedError("Plugins must implement the run_after_create method")
def run_after_grant_access(self, organization_access) -> None:
"""Method called after creating an organization."""
raise NotImplementedError(
"Plugins must implement the run_after_grant_access method"
)
-12
View File
@@ -30,15 +30,3 @@ def organization_plugins_run_after_create(organization):
"""
for plugin_instance in get_organization_plugins():
plugin_instance.run_after_create(organization)
def organization_plugins_run_after_grant_access(organization_access):
"""
Run the after grant access method for all organization plugins.
Each plugin will be called in the order they are listed in the settings.
Each plugin is responsible to save changes if needed, this is not optimized
but this could be easily improved later if needed.
"""
for plugin_instance in get_organization_plugins():
plugin_instance.run_after_grant_access(organization_access)
@@ -16,7 +16,6 @@ pytestmark = pytest.mark.django_db
def test_openapi_client_schema():
"""
Generated and served OpenAPI client schema should be correct.
The spectacular command reloads test env.
"""
# Start by generating the swagger.json file
output = StringIO()
@@ -259,10 +259,10 @@ def test_models_team_invitations_email():
email = mail.outbox[0]
assert email.to == [invitation.email]
assert email.subject == "Invitation à rejoindre La Régie !"
assert email.subject == "Invitation à rejoindre La Régie!"
email_content = " ".join(email.body.split())
assert "Invitation à rejoindre La Régie !" in email_content
assert "Invitation à rejoindre La Régie!" in email_content
assert "[//example.com]" in email_content
@@ -9,19 +9,17 @@ from uuid import uuid4
from django import db
from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.core.management.base import BaseCommand, CommandError
from django.utils.text import slugify
from faker import Faker
from oauth2_provider.models import Application
from treebeard.mp_tree import MP_Node
from core import models
from demo import defaults
from mailbox_manager import models as mailbox_models
from mailbox_manager.enums import MailboxStatusChoices, MailDomainStatusChoices
from mailbox_manager.enums import MailDomainStatusChoices
fake = Faker()
@@ -135,48 +133,6 @@ class Timeit:
return elapsed_time
def create_oidc_people_idp_client():
"""Configure the OIDC client for the People Identity Provider if missing."""
try:
Application.objects.get(client_id="people-idp")
except Application.DoesNotExist:
application = Application(
client_id="people-idp",
client_secret="local-tests-only",
client_type=Application.CLIENT_CONFIDENTIAL,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
name="People Identity Provider",
algorithm=Application.RS256_ALGORITHM,
redirect_uris="http://localhost:8083/realms/people/broker/oidc-people-local/endpoint",
skip_authorization=True,
)
application.clean()
application.save()
def create_oidc_people_idp_client_user():
"""Provide a user for the People Identity Provider OIDC client."""
organization, _created = models.Organization.objects.get_or_create(
name="13002526500013",
registration_id_list=["13002526500013"],
)
mail_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="example.com",
organization=organization,
status=MailDomainStatusChoices.ENABLED,
support_email="support@example.com",
)
_mailbox, _created = mailbox_models.Mailbox.objects.get_or_create(
first_name="IdP User",
last_name="E2E",
domain=mail_domain,
local_part="user-e2e",
status=MailboxStatusChoices.ENABLED,
password=make_password("password-user-e2e"),
secondary_email="not-used@example.com",
)
def create_demo(stdout): # pylint: disable=too-many-locals
"""
Create a database with demo data for developers to work in a realistic environment.
@@ -359,12 +315,6 @@ def create_demo(stdout): # pylint: disable=too-many-locals
queue.flush()
# OIDC configuration
if settings.OAUTH2_PROVIDER.get("OIDC_ENABLED", False):
stdout.write("Creating OIDC client for People Identity Provider")
create_oidc_people_idp_client()
create_oidc_people_idp_client_user()
class Command(BaseCommand):
"""A management command to create a demo database."""
@@ -3,6 +3,7 @@
from unittest import mock
from django.core.management import call_command
from django.test import override_settings
import pytest
@@ -10,7 +11,6 @@ from core import models
from demo import defaults
from mailbox_manager import models as mailbox_models
from people.settings import Base
pytestmark = pytest.mark.django_db
@@ -23,13 +23,10 @@ TEST_NB_OBJECTS = {
}
@override_settings(DEBUG=True)
@mock.patch.dict(defaults.NB_OBJECTS, TEST_NB_OBJECTS)
def test_commands_create_demo(settings):
def test_commands_create_demo():
"""The create_demo management command should create objects as expected."""
settings.DEBUG = True
settings.OAUTH2_PROVIDER["OIDC_ENABLED"] = True
settings.OAUTH2_PROVIDER["OIDC_RSA_PRIVATE_KEY"] = Base.generate_temporary_rsa_key()
call_command("create_demo")
# Monique Test, Jeanne Test and Jean Something (quick fix for e2e)
@@ -40,7 +37,7 @@ def test_commands_create_demo(settings):
assert models.Team.objects.count() == TEST_NB_OBJECTS["teams"]
assert models.TeamAccess.objects.count() >= TEST_NB_OBJECTS["teams"]
assert mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"] + 1
assert mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"]
# 3 domain access for each user with domain rights
# 3 x 3 domain access for each user with both rights
Binary file not shown.
@@ -1,613 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-18 08:15+0000\n"
"PO-Revision-Date: 2025-02-18 08:22\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: en\n"
"X-Crowdin-File: backend.pot\n"
"X-Crowdin-File-ID: 2\n"
#: core/admin.py:63
msgid "Personal info"
msgstr ""
#: core/admin.py:65
msgid "Permissions"
msgstr ""
#: core/admin.py:77
msgid "Important dates"
msgstr ""
#: core/admin.py:116
msgid "User"
msgstr ""
#: core/admin.py:218
msgid "Run post creation plugins"
msgstr ""
#: core/admin.py:226
msgid "Post creation plugins have been run for the selected organizations."
msgstr ""
#: core/authentication/backends.py:94
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:115
msgid "User account is disabled"
msgstr ""
#: core/authentication/backends.py:161
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:180
msgid "Claims contained no recognizable organization identification"
msgstr ""
#: core/enums.py:23
msgid "Failure"
msgstr ""
#: core/enums.py:24 mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
msgid "Pending"
msgstr ""
#: core/enums.py:25
msgid "Success"
msgstr ""
#: core/models.py:50
msgid "Member"
msgstr ""
#: core/models.py:51 core/models.py:63 mailbox_manager/enums.py:14
msgid "Administrator"
msgstr ""
#: core/models.py:52 mailbox_manager/enums.py:15
msgid "Owner"
msgstr ""
#: core/models.py:75
msgid "id"
msgstr ""
#: core/models.py:76
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:82
msgid "created at"
msgstr ""
#: core/models.py:83
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:88
msgid "updated at"
msgstr ""
#: core/models.py:89
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:128
msgid "full name"
msgstr ""
#: core/models.py:129
msgid "short name"
msgstr ""
#: core/models.py:132
msgid "notes"
msgstr ""
#: core/models.py:134
msgid "contact information"
msgstr ""
#: core/models.py:135
msgid "A JSON object containing the contact information"
msgstr ""
#: core/models.py:149
msgid "contact"
msgstr ""
#: core/models.py:150
msgid "contacts"
msgstr ""
#: core/models.py:224 core/models.py:338 core/models.py:460
#: mailbox_manager/models.py:24
msgid "name"
msgstr ""
#: core/models.py:226
msgid "audience id"
msgstr ""
#: core/models.py:231
msgid "service provider"
msgstr ""
#: core/models.py:232
msgid "service providers"
msgstr ""
#: core/models.py:346
msgid "registration ID list"
msgstr ""
#: core/models.py:353
msgid "domain list"
msgstr ""
#: core/models.py:369
msgid "organization"
msgstr ""
#: core/models.py:370
msgid "organizations"
msgstr ""
#: core/models.py:377
msgid "An organization must have at least a registration ID or a domain."
msgstr ""
#: core/models.py:445
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:451
msgid "sub"
msgstr ""
#: core/models.py:453
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:459 core/models.py:901
msgid "email address"
msgstr ""
#: core/models.py:465
msgid "language"
msgstr ""
#: core/models.py:466
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:472
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:475
msgid "device"
msgstr ""
#: core/models.py:477
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:480
msgid "staff status"
msgstr ""
#: core/models.py:482
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:485
msgid "active"
msgstr ""
#: core/models.py:488
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:507
msgid "user"
msgstr ""
#: core/models.py:508
msgid "users"
msgstr ""
#: core/models.py:644
msgid "Organization/user relation"
msgstr ""
#: core/models.py:645
msgid "Organization/user relations"
msgstr ""
#: core/models.py:650
msgid "This user is already in this organization."
msgstr ""
#: core/models.py:727
msgid "Team"
msgstr ""
#: core/models.py:728
msgid "Teams"
msgstr ""
#: core/models.py:779
msgid "Team/user relation"
msgstr ""
#: core/models.py:780
msgid "Team/user relations"
msgstr ""
#: core/models.py:785
msgid "This user is already in this team."
msgstr ""
#: core/models.py:874
msgid "url"
msgstr ""
#: core/models.py:875
msgid "secret"
msgstr ""
#: core/models.py:884
msgid "Team webhook"
msgstr ""
#: core/models.py:885
msgid "Team webhooks"
msgstr ""
#: core/models.py:918
msgid "Team invitation"
msgstr ""
#: core/models.py:919
msgid "Team invitations"
msgstr ""
#: core/models.py:944
msgid "This email is already associated to a registered user."
msgstr ""
#: core/models.py:985
msgid "Invitation to join La Régie!"
msgstr ""
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a team"
msgstr ""
#: core/templates/mail/html/invitation.html:198
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to"
msgstr ""
#: core/templates/mail/html/invitation.html:216
#: core/templates/mail/text/invitation.txt:12
msgid "Logo"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid "We are delighted to welcome you to our community on La Régie, your new companion to simplify the management of your groups efficiently, intuitively, and securely."
msgstr ""
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:15
msgid "Our application is designed to help you organize, collaborate, and manage permissions."
msgstr ""
#: core/templates/mail/html/invitation.html:236
#: core/templates/mail/text/invitation.txt:16
msgid "With La Régie, you will be able to:"
msgstr ""
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Create customized groups according to your specific needs."
msgstr ""
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Invite members of your team or community in just a few clicks."
msgstr ""
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Plan events, meetings, or activities effortlessly with our integrated calendar."
msgstr ""
#: core/templates/mail/html/invitation.html:240
#: core/templates/mail/text/invitation.txt:20
msgid "Share documents, photos, and important information securely."
msgstr ""
#: core/templates/mail/html/invitation.html:241
#: core/templates/mail/text/invitation.txt:21
msgid "Facilitate exchanges and communication with our messaging and group discussion tools."
msgstr ""
#: core/templates/mail/html/invitation.html:252
#: core/templates/mail/text/invitation.txt:23
msgid "Visit La Régie"
msgstr ""
#: core/templates/mail/html/invitation.html:261
#: core/templates/mail/text/invitation.txt:25
msgid "We are confident that La Régie will help you increase efficiency and productivity while strengthening the bond among members."
msgstr ""
#: core/templates/mail/html/invitation.html:266
#: core/templates/mail/text/invitation.txt:26
msgid "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service."
msgstr ""
#: core/templates/mail/html/invitation.html:271
#: core/templates/mail/text/invitation.txt:27
msgid "Once again, welcome aboard! We are eager to accompany you on this group management adventure."
msgstr ""
#: core/templates/mail/html/invitation.html:278
#: core/templates/mail/html/new_mailbox.html:272
#: core/templates/mail/text/invitation.txt:29
#: core/templates/mail/text/new_mailbox.txt:15
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/invitation.html:279
#: core/templates/mail/text/invitation.txt:31
msgid "The La Suite Numérique Team"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:159
#: core/templates/mail/text/new_mailbox.txt:3
msgid "La Messagerie"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:188
#: core/templates/mail/text/new_mailbox.txt:5
msgid "Welcome to La Messagerie"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:193
#: core/templates/mail/text/new_mailbox.txt:6
msgid "La Messagerie is the email solution of La Suite."
msgstr ""
#: core/templates/mail/html/new_mailbox.html:199
#: core/templates/mail/text/new_mailbox.txt:7
msgid "Your mailbox has been created."
msgstr ""
#: core/templates/mail/html/new_mailbox.html:204
#: core/templates/mail/text/new_mailbox.txt:8
msgid "Please find below your login info: "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:228
#: core/templates/mail/text/new_mailbox.txt:10
msgid "Email address: "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:233
#: core/templates/mail/text/new_mailbox.txt:11
msgid "Temporary password (to be modify on first login): "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:261
#: core/templates/mail/text/new_mailbox.txt:13
msgid "Go to La Messagerie"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:273
#: core/templates/mail/text/new_mailbox.txt:17
msgid "La Suite Team"
msgstr ""
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: mailbox_manager/admin.py:16
msgid "Synchronise from dimail"
msgstr ""
#: mailbox_manager/admin.py:34
#, python-brace-format
msgid "Synchronisation failed for {domain.name} with message: [{err}]"
msgstr ""
#: mailbox_manager/admin.py:40
#, python-brace-format
msgid "Synchronisation succeed for {domain.name}. "
msgstr ""
#: mailbox_manager/admin.py:48
msgid "Sync require enabled domains. Excluded domains: {', '.join(excluded_domains)}"
msgstr ""
#: mailbox_manager/admin.py:53
msgid "Check and update status from dimail"
msgstr ""
#: mailbox_manager/admin.py:69
#, python-brace-format
msgid "- {domain.name} with message: '{err}'"
msgstr ""
#: mailbox_manager/admin.py:80
msgid "Check domains done with success."
msgstr ""
#: mailbox_manager/admin.py:81
msgid "Domains updated: {', '.join(domains_updated)}"
msgstr ""
#: mailbox_manager/admin.py:83
msgid "No domain updated."
msgstr ""
#: mailbox_manager/admin.py:90
msgid "Check domain failed for:"
msgstr ""
#: mailbox_manager/admin.py:99
msgid "Domains disabled are excluded from check: {', '.join(excluded_domains)}"
msgstr ""
#: mailbox_manager/admin.py:104
msgid "Fetch domain expected config from dimail"
msgstr ""
#: mailbox_manager/admin.py:118
#, python-brace-format
msgid "Domain expected config fetched with success for {domain.name}."
msgstr ""
#: mailbox_manager/admin.py:122
#, python-brace-format
msgid "Failed to fetch domain expected config for {domain.name}."
msgstr ""
#: mailbox_manager/admin.py:128
msgid "Domains disabled are excluded from fetch: {', '.join(excluded_domains)}"
msgstr ""
#: mailbox_manager/enums.py:13
msgid "Viewer"
msgstr ""
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
msgid "Enabled"
msgstr ""
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr ""
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
msgstr ""
#: mailbox_manager/enums.py:25
msgid "Action required"
msgstr ""
#: mailbox_manager/models.py:32
msgid "support email"
msgstr ""
#: mailbox_manager/models.py:36
msgid "last check details"
msgstr ""
#: mailbox_manager/models.py:37
msgid "A JSON object containing the last health check details"
msgstr ""
#: mailbox_manager/models.py:42
msgid "expected config"
msgstr ""
#: mailbox_manager/models.py:43
msgid "A JSON object containing the expected config"
msgstr ""
#: mailbox_manager/models.py:48
msgid "Mail domain"
msgstr ""
#: mailbox_manager/models.py:49
msgid "Mail domains"
msgstr ""
#: mailbox_manager/models.py:116
msgid "User/mail domain relation"
msgstr ""
#: mailbox_manager/models.py:117
msgid "User/mail domain relations"
msgstr ""
#: mailbox_manager/models.py:190
msgid "local_part"
msgstr ""
#: mailbox_manager/models.py:204
msgid "secondary email address"
msgstr ""
#: mailbox_manager/models.py:214
msgid "Mailbox"
msgstr ""
#: mailbox_manager/models.py:215
msgid "Mailboxes"
msgstr ""
#: mailbox_manager/models.py:240
msgid "You can't create or update a mailbox for a disabled domain."
msgstr ""
#: mailbox_manager/utils/dimail.py:266
msgid "Your new mailbox information"
msgstr ""
#: people/settings.py:146
msgid "English"
msgstr ""
#: people/settings.py:147
msgid "French"
msgstr ""
Binary file not shown.
+212 -191
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-18 08:15+0000\n"
"PO-Revision-Date: 2025-02-20 15:14\n"
"POT-Creation-Date: 2025-02-03 10:27+0000\n"
"PO-Revision-Date: 2024-01-03 23:15\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -19,7 +19,7 @@ msgstr ""
#: core/admin.py:63
msgid "Personal info"
msgstr "Infos Personnelles"
msgstr "Informations personnelles"
#: core/admin.py:65
msgid "Permissions"
@@ -39,23 +39,31 @@ msgstr "Exécuter les plugins de post-création"
#: core/admin.py:226
msgid "Post creation plugins have been run for the selected organizations."
msgstr "Les plugins de post-création ont été exécutés pour les organisations sélectionnées."
msgstr ""
"Les plugins de post-création ont été exécutés pour les organisations "
"sélectionnées."
#: core/authentication/backends.py:94
msgid "User info contained no recognizable user identification"
msgstr "Les informations de l'utilisateur ne contiennent aucune identification reconnaissable"
msgstr ""
"Les informations de l'utilisateur ne contiennent aucune identification "
"reconnaissable"
#: core/authentication/backends.py:115
#: core/authentication/backends.py:116
msgid "User account is disabled"
msgstr "Le compte de l'utilisateur est désactivé"
#: core/authentication/backends.py:161
#: core/authentication/backends.py:162
msgid "Claims contained no recognizable user identification"
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
msgstr ""
"Les claims ne contiennent aucune identification reconnaissable pour "
"l'utilisateur"
#: core/authentication/backends.py:180
#: core/authentication/backends.py:181
msgid "Claims contained no recognizable organization identification"
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
msgstr ""
"Les claims ne contiennent aucune identification reconnaissable pour "
"l'organisation"
#: core/enums.py:23
msgid "Failure"
@@ -69,234 +77,248 @@ msgstr "En attente"
msgid "Success"
msgstr "Réussi"
#: core/models.py:50
#: core/models.py:47
msgid "Member"
msgstr "Membre"
#: core/models.py:51 core/models.py:63 mailbox_manager/enums.py:14
#: core/models.py:48 core/models.py:60 mailbox_manager/enums.py:14
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:52 mailbox_manager/enums.py:15
#: core/models.py:49 mailbox_manager/enums.py:15
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:75
#: core/models.py:72
msgid "id"
msgstr "id"
msgstr "identifiant"
#: core/models.py:76
#: core/models.py:73
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement en tant que UUID"
msgstr "Clé primaire pour l'enregistrement en tant que UUID"
#: core/models.py:82
#: core/models.py:79
msgid "created at"
msgstr "créé le"
msgstr "Créé le"
#: core/models.py:83
#: core/models.py:80
msgid "date and time at which a record was created"
msgstr "date et heure de création de l'enregistrement"
msgstr "Date et heure de création de l'enregistrement"
#: core/models.py:88
#: core/models.py:85
msgid "updated at"
msgstr "mis à jour le"
#: core/models.py:89
#: core/models.py:86
msgid "date and time at which a record was last updated"
msgstr "date et heure de la dernière mise à jour de l'enregistrement"
#: core/models.py:128
#: core/models.py:125
msgid "full name"
msgstr "nom complet"
#: core/models.py:129
#: core/models.py:126
msgid "short name"
msgstr "nom court"
#: core/models.py:132
#: core/models.py:129
msgid "notes"
msgstr "notes"
#: core/models.py:134
#: core/models.py:131
msgid "contact information"
msgstr "informations de contact"
#: core/models.py:135
#: core/models.py:132
msgid "A JSON object containing the contact information"
msgstr "Un objet JSON contenant les informations de contact"
#: core/models.py:149
#: core/models.py:146
msgid "contact"
msgstr "contact"
#: core/models.py:150
#: core/models.py:147
msgid "contacts"
msgstr "contacts"
#: core/models.py:224 core/models.py:338 core/models.py:460
#: core/models.py:221 core/models.py:319 core/models.py:441
#: mailbox_manager/models.py:24
msgid "name"
msgstr "nom"
#: core/models.py:226
#: core/models.py:223
msgid "audience id"
msgstr ""
#: core/models.py:231
#: core/models.py:228
msgid "service provider"
msgstr "fournisseur de services"
msgstr "fournisseur de service"
#: core/models.py:232
#: core/models.py:229
msgid "service providers"
msgstr "fournisseurs de services"
msgstr "fournisseurs de service"
#: core/models.py:346
#: core/models.py:327
msgid "registration ID list"
msgstr "liste d'identifiants d'inscription"
#: core/models.py:353
#: core/models.py:334
msgid "domain list"
msgstr "liste des domaines"
msgstr "liste de domaines"
#: core/models.py:369
#: core/models.py:350
msgid "organization"
msgstr "organisation"
#: core/models.py:370
#: core/models.py:351
msgid "organizations"
msgstr "organisations"
#: core/models.py:377
#: core/models.py:358
msgid "An organization must have at least a registration ID or a domain."
msgstr "Une organisation doit avoir au moins un ID d'enregistrement ou un domaine."
msgstr ""
"Une organisation doit avoir au moins un identifiant d'inscription ou un "
"domaine."
#: core/models.py:445
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr "Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des chiffres et des caractères @/./+/-/_."
#: core/models.py:426
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Entrez une sub valide. Cette valeur peut contenir uniquement des lettres, "
"des chiffres et des caractères @/./+/-/_"
#: core/models.py:451
#: core/models.py:432
msgid "sub"
msgstr "sub"
#: core/models.py:453
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_ seulement."
#: core/models.py:434
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Requis. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_ "
"uniquement."
#: core/models.py:459 core/models.py:901
#: core/models.py:440 core/models.py:880
msgid "email address"
msgstr "adresse email"
#: core/models.py:465
#: core/models.py:446
msgid "language"
msgstr "langue"
#: core/models.py:466
#: core/models.py:447
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:472
#: core/models.py:453
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:475
#: core/models.py:456
msgid "device"
msgstr "appareil"
#: core/models.py:477
#: core/models.py:458
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:480
#: core/models.py:461
msgid "staff status"
msgstr "statut d'équipe"
msgstr ""
#: core/models.py:482
#: core/models.py:463
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
msgstr "Si l'utilisateur peut se connecter à cette interface d'administration."
#: core/models.py:485
#: core/models.py:466
msgid "active"
msgstr "actif"
msgstr ""
#: core/models.py:469
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Si cet utilisateur doit être considéré comme actif. Désélectionnez cette "
"option au lieu de supprimer les comptes."
#: core/models.py:488
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
#: core/models.py:507
msgid "user"
msgstr "utilisateur"
#: core/models.py:508
#: core/models.py:489
msgid "users"
msgstr "utilisateurs"
#: core/models.py:644
#: core/models.py:623
msgid "Organization/user relation"
msgstr "Relation organisation/utilisateur"
msgstr "Relation entre une organisation et un utilisateur"
#: core/models.py:645
#: core/models.py:624
msgid "Organization/user relations"
msgstr "Relations organisation/utilisateur"
msgstr "Relations entre une organisation et un utilisateur"
#: core/models.py:650
#: core/models.py:629
msgid "This user is already in this organization."
msgstr "Cet utilisateur est déjà dans cette organisation."
#: core/models.py:727
#: core/models.py:706
msgid "Team"
msgstr "Équipe"
msgstr "Equipe"
#: core/models.py:728
#: core/models.py:707
msgid "Teams"
msgstr "Équipes"
msgstr "Equipes"
#: core/models.py:779
#: core/models.py:758
msgid "Team/user relation"
msgstr "Relation équipe/utilisateur"
msgstr "Relation entre une équipe et un utilisateur"
#: core/models.py:780
#: core/models.py:759
msgid "Team/user relations"
msgstr "Relations équipe/utilisateur"
msgstr "Relations entre une équipe et un utilisateur"
#: core/models.py:785
#: core/models.py:764
msgid "This user is already in this team."
msgstr "Cet utilisateur est déjà dans cette équipe."
#: core/models.py:874
#: core/models.py:853
msgid "url"
msgstr "url"
#: core/models.py:875
#: core/models.py:854
msgid "secret"
msgstr "secret"
#: core/models.py:884
#: core/models.py:863
msgid "Team webhook"
msgstr "Webhook d'équipe"
#: core/models.py:885
#: core/models.py:864
msgid "Team webhooks"
msgstr "Webhooks d'équipe"
#: core/models.py:918
#: core/models.py:897
msgid "Team invitation"
msgstr "Invitation d'équipe"
#: core/models.py:919
#: core/models.py:898
msgid "Team invitations"
msgstr "Invitations d'équipe"
#: core/models.py:944
#: core/models.py:923
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur enregistré."
#: core/models.py:985
#: core/models.py:965 core/models.py:971
msgid "Invitation to join La Régie!"
msgstr "Invitation à rejoindre La Régie !"
msgstr "Invitation à rejoindre La Régie!"
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr "Logo de l'établissement"
msgstr "Logo de l'entreprise"
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
@@ -309,12 +331,14 @@ msgstr "Bonjour"
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr "Merci beaucoup pour votre visite !"
msgstr "Merci beaucoup pour votre visite!"
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr "Ce mail a été envoyé à %(email)s par <a href=\"%(href)s\">%(name)s</a>"
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
"Cette mail a été envoyée à %(email)s par <a href=\"%(href)s\">%(name)s</a>"
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/text/invitation.txt:3
@@ -329,7 +353,7 @@ msgstr "Invitation à rejoindre une équipe"
#: core/templates/mail/html/invitation.html:198
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to"
msgstr "Bienvenue dans"
msgstr "Bienvenue sur"
#: core/templates/mail/html/invitation.html:216
#: core/templates/mail/text/invitation.txt:12
@@ -338,13 +362,23 @@ msgstr "Logo"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid "We are delighted to welcome you to our community on La Régie, your new companion to simplify the management of your groups efficiently, intuitively, and securely."
msgstr "Nous sommes ravis de vous accueillir dans notre communauté sur Régie, votre nouveau compagnon pour simplifier la gestion de vos groupes de manière efficace, intuitive et sécurisée."
msgid ""
"We are delighted to welcome you to our community on La Régie, your new "
"companion to simplify the management of your groups efficiently, "
"intuitively, and securely."
msgstr ""
"Nous sommes ravis de vous accueillir dans notre communauté sur Régie, votre "
"nouveau compagnon pour simplifier la gestion de vos groupes de manière "
"efficace, intuitive et sécurisée."
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:15
msgid "Our application is designed to help you organize, collaborate, and manage permissions."
msgstr "Notre application est conçue pour vous aider à organiser, collaborer et gérer les permissions."
msgid ""
"Our application is designed to help you organize, collaborate, and manage "
"permissions."
msgstr ""
"Notre application est conçue pour vous aider à organiser, collaborer et "
"gérer les permissions."
#: core/templates/mail/html/invitation.html:236
#: core/templates/mail/text/invitation.txt:16
@@ -354,47 +388,74 @@ msgstr "Avec La Régie, vous pourrez :"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Create customized groups according to your specific needs."
msgstr "Créer des groupes personnalisés en fonction de vos besoins spécifiques."
msgstr ""
"Créer des groupes personnalisés en fonction de vos besoins spécifiques."
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Invite members of your team or community in just a few clicks."
msgstr "Inviter des membres de votre équipe ou de votre communauté en quelques clics."
msgstr ""
"Inviter des membres de votre équipe ou de votre communauté en quelques clics."
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Plan events, meetings, or activities effortlessly with our integrated calendar."
msgstr "Planifier des événements, des réunions ou des activités sans effort avec notre calendrier intégré."
msgid ""
"Plan events, meetings, or activities effortlessly with our integrated "
"calendar."
msgstr ""
"Planifier des événements, des réunions ou des activités sans effort avec "
"notre calendrier intégré."
#: core/templates/mail/html/invitation.html:240
#: core/templates/mail/text/invitation.txt:20
msgid "Share documents, photos, and important information securely."
msgstr "Partager des documents, des photos et des informations importantes de manière sécurisée."
msgstr ""
"Partager des documents, des photos et des informations importantes de "
"manière sécurisée."
#: core/templates/mail/html/invitation.html:241
#: core/templates/mail/text/invitation.txt:21
msgid "Facilitate exchanges and communication with our messaging and group discussion tools."
msgstr "Faciliter les échanges et la communication avec nos outils de messagerie et de discussion de groupe."
msgid ""
"Facilitate exchanges and communication with our messaging and group "
"discussion tools."
msgstr ""
"Faciliter les échanges et la communication avec nos outils de messagerie et "
"de discussion de groupe."
#: core/templates/mail/html/invitation.html:252
#: core/templates/mail/text/invitation.txt:23
msgid "Visit La Régie"
msgstr "Visiter La Rgie"
msgstr "Visiter La Régie"
#: core/templates/mail/html/invitation.html:261
#: core/templates/mail/text/invitation.txt:25
msgid "We are confident that La Régie will help you increase efficiency and productivity while strengthening the bond among members."
msgstr "Nous sommes convaincus que La Régie vous aidera à augmenter l'efficacité et la productivité tout en renforçant le lien entre les membres."
msgid ""
"We are confident that La Régie will help you increase efficiency and "
"productivity while strengthening the bond among members."
msgstr ""
"Nous sommes convaincus que La Régie vous aidera à augmenter l'efficacité et "
"la productivité tout en renforçant le lien entre les membres."
#: core/templates/mail/html/invitation.html:266
#: core/templates/mail/text/invitation.txt:26
msgid "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service."
msgstr "N'hésitez pas à explorer toutes les fonctionnalités de l'application et à partager vos commentaires et suggestions avec nous. Vos retours sont précieux pour nous et nous permettront de continuer à améliorer notre service."
msgid ""
"Feel free to explore all the features of the application and share your "
"feedback and suggestions with us. Your feedback is valuable to us and will "
"enable us to continually improve our service."
msgstr ""
"N'hésitez pas à explorer toutes les fonctionnalités de l'application et à "
"partager vos commentaires et suggestions avec nous. Vos retours sont "
"précieux pour nous et nous permettront de continuer à améliorer notre "
"service."
#: core/templates/mail/html/invitation.html:271
#: core/templates/mail/text/invitation.txt:27
msgid "Once again, welcome aboard! We are eager to accompany you on this group management adventure."
msgstr "Encore une fois, bienvenue parmi nous ! Nous sommes impatients de vous accompagner dans cette aventure de gestion de groupe."
msgid ""
"Once again, welcome aboard! We are eager to accompany you on this group "
"management adventure."
msgstr ""
"Encore une fois, bienvenue parmi nous ! Nous sommes impatients de vous "
"accompagner dans cette aventure de gestion de groupe."
#: core/templates/mail/html/invitation.html:278
#: core/templates/mail/html/new_mailbox.html:272
@@ -431,7 +492,7 @@ msgstr "Votre boîte mail a été créée."
#: core/templates/mail/html/new_mailbox.html:204
#: core/templates/mail/text/new_mailbox.txt:8
msgid "Please find below your login info: "
msgstr "Voici vos identifiants de connexion : "
msgstr "Voici vos identifiants de connexion :"
#: core/templates/mail/html/new_mailbox.html:228
#: core/templates/mail/text/new_mailbox.txt:10
@@ -456,72 +517,52 @@ msgstr "L'équipe de La Suite"
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr "Cet email a été envoyé à %(email)s par %(name)s [%(href)s]"
msgstr "Cette mail a été envoyée à %(email)s par %(name)s [%(href)s]"
#: mailbox_manager/admin.py:16
#: mailbox_manager/admin.py:13
msgid "Synchronise from dimail"
msgstr "Synchroniser à partir de dimail"
#: mailbox_manager/admin.py:34
#: mailbox_manager/admin.py:24
#, python-brace-format
msgid "Synchronisation failed for {domain.name} with message: [{err}]"
msgstr "Synchronisation échouée pour {domain.name} avec le message: [{err}]"
#: mailbox_manager/admin.py:40
#: mailbox_manager/admin.py:30
#, python-brace-format
msgid "Synchronisation succeed for {domain.name}. "
msgstr "Synchronisation réussie pour {domain.name}. "
#: mailbox_manager/admin.py:48
msgid "Sync require enabled domains. Excluded domains: {', '.join(excluded_domains)}"
msgstr "La synchro nécessite des domaines activés. Les domaines exclus sont : {', '.join(excluded_domains)}"
#: mailbox_manager/admin.py:53
#: mailbox_manager/admin.py:36
msgid "Check and update status from dimail"
msgstr "Vérifier et mettre à jour le statut à partir de dimail"
msgstr "Vérification et mise à jour de l'état à partir de dimail"
#: mailbox_manager/admin.py:69
#: mailbox_manager/admin.py:52
#, python-brace-format
msgid "- {domain.name} with message: '{err}'"
msgstr "- {domain.name} avec le message : '{err}'"
msgid "- <b>{domain.name}</b> with message: '{err}'"
msgstr "- <b>{domain.name}</b> avec le message: '{err}'"
#: mailbox_manager/admin.py:80
#: mailbox_manager/admin.py:63
msgid "Check domains done with success."
msgstr "Vérification des domaines effectuée avec succès."
msgstr "Vérification des domains réalisée avec success."
#: mailbox_manager/admin.py:81
#: mailbox_manager/admin.py:64
msgid "Domains updated: {', '.join(domains_updated)}"
msgstr "Domaines mis à jour : {', '.join(domains_updated)}"
#: mailbox_manager/admin.py:83
#: mailbox_manager/admin.py:66
msgid "No domain updated."
msgstr "Aucun domaine mis à jour."
msgstr "Aucun domain n'a été mis à jour."
#: mailbox_manager/admin.py:90
#: mailbox_manager/admin.py:70
msgid "Check domain failed for:"
msgstr "La vérification du domaine a échoué pour :"
msgstr "La vérification de domain a échoué pour :"
#: mailbox_manager/admin.py:99
#: mailbox_manager/admin.py:76
msgid "Domains disabled are excluded from check: {', '.join(excluded_domains)}"
msgstr "Les domaines désactivés sont exclus de la vérification : {', '.join(excluded_domains)}"
#: mailbox_manager/admin.py:104
msgid "Fetch domain expected config from dimail"
msgstr "Récupérer la configuration attendue du domaine depuis dimail"
#: mailbox_manager/admin.py:118
#, python-brace-format
msgid "Domain expected config fetched with success for {domain.name}."
msgstr "La configuration du domaine attendue a été récupérée avec succès pour {domain.name}."
#: mailbox_manager/admin.py:122
#, python-brace-format
msgid "Failed to fetch domain expected config for {domain.name}."
msgstr "Impossible de récupérer la configuration attendue pour {domain.name}."
#: mailbox_manager/admin.py:128
msgid "Domains disabled are excluded from fetch: {', '.join(excluded_domains)}"
msgstr "Les domaines désactivés sont exclus de la vérification : {', '.join(excluded_domains)}"
msgstr ""
"Les domains désactivés sont exclus de la vérification : {', '."
"join(excluded_domains)}"
#: mailbox_manager/enums.py:13
msgid "Viewer"
@@ -533,7 +574,7 @@ msgstr "Actif"
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr "En erreur"
msgstr "En échec"
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
@@ -543,65 +584,46 @@ msgstr "Désactivé"
msgid "Action required"
msgstr "Action requise"
#: mailbox_manager/models.py:32
msgid "support email"
msgstr "adresse email du support"
#: mailbox_manager/models.py:36
msgid "last check details"
msgstr "détails de la dernière vérification"
#: mailbox_manager/models.py:37
msgid "A JSON object containing the last health check details"
msgstr "Un objet JSON contenant les derniers détails du bilan de santé"
#: mailbox_manager/models.py:42
msgid "expected config"
msgstr "configuration attendue"
#: mailbox_manager/models.py:43
msgid "A JSON object containing the expected config"
msgstr "Un objet JSON contenant la configuration attendue"
#: mailbox_manager/models.py:48
#: mailbox_manager/models.py:35
msgid "Mail domain"
msgstr "Domaine de messagerie"
#: mailbox_manager/models.py:49
#: mailbox_manager/models.py:36
msgid "Mail domains"
msgstr "Domaines de messagerie"
#: mailbox_manager/models.py:116
#: mailbox_manager/models.py:102
msgid "User/mail domain relation"
msgstr "Relation utilisateur/domaine de messagerie"
msgstr "Relation entre un utilisateur et un domaine de mail"
#: mailbox_manager/models.py:117
#: mailbox_manager/models.py:103
msgid "User/mail domain relations"
msgstr "Relations utilisateur/domaine de messagerie"
msgstr "Relations entre un utilisateur et un domaine de mail"
#: mailbox_manager/models.py:190
#: mailbox_manager/models.py:175
msgid "local_part"
msgstr "local_part"
#: mailbox_manager/models.py:204
#: mailbox_manager/models.py:189
msgid "secondary email address"
msgstr "adresse email secondaire"
#: mailbox_manager/models.py:214
#: mailbox_manager/models.py:199
msgid "Mailbox"
msgstr "Boîte mail"
#: mailbox_manager/models.py:215
#: mailbox_manager/models.py:200
msgid "Mailboxes"
msgstr "Boîtes mail"
msgstr "Boîtes mails"
#: mailbox_manager/models.py:240
#: mailbox_manager/models.py:224
msgid "You can't create or update a mailbox for a disabled domain."
msgstr "Vous ne pouvez pas créer ou mettre à jour une boîte mail pour un domaine désactivé."
msgstr ""
"Vous ne pouvez pas créer ou modifier une boîte mail pour un domain désactivé."
#: mailbox_manager/utils/dimail.py:266
msgid "Your new mailbox information"
msgstr "Informations sur votre nouvelle boîte mail"
msgstr "Informations concernant votre nouvelle boîte mail"
#: people/settings.py:146
msgid "English"
@@ -610,4 +632,3 @@ msgstr "Anglais"
#: people/settings.py:147
msgid "French"
msgstr "Français"
+7 -75
View File
@@ -1,8 +1,7 @@
"""Admin classes and registrations for People's mailbox manager app."""
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin
from django.utils.html import format_html_join, mark_safe
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from requests import exceptions
@@ -10,9 +9,6 @@ from requests import exceptions
from mailbox_manager import enums, models
from mailbox_manager.utils.dimail import DimailAPIClient
# Prevent Ruff complaining about mark_safe below
# ruff: noqa: S308
@admin.action(description=_("Synchronise from dimail"))
def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
@@ -67,7 +63,7 @@ def fetch_domain_status_from_dimail(modeladmin, request, queryset): # pylint: d
try:
response = client.fetch_domain_status(domain)
except exceptions.HTTPError as err:
msg_error.append(_(f"- {domain.name} with message: '{err}'"))
msg_error.append(_(f"""- <b>{domain.name}</b> with message: '{err}'"""))
else:
success = True
# temporary (or not?) display content of the dimail response to debug broken state
@@ -83,16 +79,10 @@ def fetch_domain_status_from_dimail(modeladmin, request, queryset): # pylint: d
if domains_updated
else _("No domain updated."),
]
messages.success(
request,
format_html_join(mark_safe("<br> "), "{}", ([str(m)] for m in msg_success)),
)
messages.success(request, format_html("<br> ".join(map(str, msg_success))))
if msg_error:
msg_error.insert(0, _("Check domain failed for:"))
messages.error(
request,
format_html_join(mark_safe("<br> "), "{}", ([str(m)] for m in msg_error)),
)
messages.error(request, format_html("<br> ".join(map(str, msg_error))))
if excluded_domains:
messages.warning(
request,
@@ -102,35 +92,6 @@ def fetch_domain_status_from_dimail(modeladmin, request, queryset): # pylint: d
)
@admin.action(description=_("Fetch domain expected config from dimail"))
def fetch_domain_expected_config_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Admin action to fetch domain expected config from dimail."""
client = DimailAPIClient()
excluded_domains = []
for domain in queryset:
# do not check disabled domains
if domain.status == enums.MailDomainStatusChoices.DISABLED:
excluded_domains.append(domain.name)
continue
response = client.fetch_domain_expected_config(domain)
if response:
messages.success(
request,
_(f"Domain expected config fetched with success for {domain.name}."),
)
else:
messages.error(
request, _(f"Failed to fetch domain expected config for {domain.name}.")
)
if excluded_domains:
messages.warning(
request,
_(
f"Domains disabled are excluded from fetch: {', '.join(excluded_domains)}"
),
)
class UserMailDomainAccessInline(admin.TabularInline):
"""Inline admin class for mail domain accesses."""
@@ -145,26 +106,20 @@ class MailDomainAdmin(admin.ModelAdmin):
list_display = (
"name",
"organization",
"created_at",
"updated_at",
"slug",
"status",
)
search_fields = ("name", "organization__name")
search_fields = ("name",)
readonly_fields = ["created_at", "slug"]
list_filter = ("status",)
inlines = (UserMailDomainAccessInline,)
actions = (
sync_mailboxes_from_dimail,
fetch_domain_status_from_dimail,
fetch_domain_expected_config_from_dimail,
)
autocomplete_fields = ["organization"]
actions = (sync_mailboxes_from_dimail, fetch_domain_status_from_dimail)
@admin.register(models.Mailbox)
class MailboxAdmin(UserAdmin):
class MailboxAdmin(admin.ModelAdmin):
"""Admin for mailbox model."""
list_display = ("__str__", "domain", "status", "updated_at")
@@ -172,29 +127,6 @@ class MailboxAdmin(UserAdmin):
search_fields = ("local_part", "domain__name")
readonly_fields = ["updated_at", "local_part", "domain"]
fieldsets = None
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": (
"first_name",
"last_name",
"local_part",
"domain",
"secondary_email",
"status",
"usable_password",
"password1",
"password2",
),
},
),
)
ordering = ("local_part", "domain")
filter_horizontal = ()
@admin.register(models.MailDomainAccess)
class MailDomainAccessAdmin(admin.ModelAdmin):
@@ -2,8 +2,6 @@
from logging import getLogger
from django.contrib.auth.hashers import make_password
from requests.exceptions import HTTPError
from rest_framework import exceptions, serializers
@@ -35,16 +33,8 @@ class MailboxSerializer(serializers.ModelSerializer):
def create(self, validated_data):
"""
Override create function to fire a request on mailbox creation.
By default, we generate an unusable password for the mailbox, meaning that the mailbox
will not be able to be used as a login credential until the password is set.
"""
mailbox = super().create(
validated_data
| {
"password": make_password(None), # generate an unusable password
}
)
mailbox = super().create(validated_data)
if mailbox.domain.status == enums.MailDomainStatusChoices.ENABLED:
client = DimailAPIClient()
# send new mailbox request to dimail
@@ -67,7 +57,6 @@ class MailDomainSerializer(serializers.ModelSerializer):
abilities = serializers.SerializerMethodField(read_only=True)
count_mailboxes = serializers.SerializerMethodField(read_only=True)
action_required_details = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.MailDomain
@@ -81,10 +70,6 @@ class MailDomainSerializer(serializers.ModelSerializer):
"created_at",
"updated_at",
"count_mailboxes",
"support_email",
"last_check_details",
"action_required_details",
"expected_config",
]
read_only_fields = [
"id",
@@ -94,24 +79,8 @@ class MailDomainSerializer(serializers.ModelSerializer):
"created_at",
"updated_at",
"count_mailboxes",
"last_check_details",
"action_required_details",
"expected_config",
]
def get_action_required_details(self, domain) -> dict:
"""Return last check details of the domain."""
details = {}
if domain.last_check_details:
for check, value in domain.last_check_details.items():
if (
isinstance(value, dict)
and value.get("ok") is False
and value.get("internal") is False
):
details[check] = value["errors"][0].get("detail")
return details
def get_abilities(self, domain) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
@@ -130,18 +99,9 @@ class MailDomainSerializer(serializers.ModelSerializer):
# send new domain request to dimail
client = DimailAPIClient()
client.create_domain(validated_data["name"], self.context["request"].user.sub)
domain = super().create(validated_data)
# 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",
domain.name,
e,
)
return domain
# no exception raised ? Then actually save domain on our database
return super().create(validated_data)
class MailDomainAccessSerializer(serializers.ModelSerializer):
@@ -33,11 +33,7 @@ class MailDomainViewSet(
POST /api/<version>/mail-domains/ with expected data:
- name: str
- support_email: str
Return newly created domain
POST /api/<version>/mail-domains/<domain-slug>/fetch/
Fetch domain status and expected config from dimail.
"""
permission_classes = [permissions.AccessPermission]
@@ -64,17 +60,6 @@ class MailDomainViewSet(
}
)
@action(detail=True, methods=["post"], url_path="fetch")
def fetch_from_dimail(self, request, *args, **kwargs):
"""Fetch domain status and expected config from dimail."""
domain = self.get_object()
client = DimailAPIClient()
client.fetch_domain_status(domain)
client.fetch_domain_expected_config(domain)
return Response(
serializers.MailDomainSerializer(domain, context={"request": request}).data
)
# pylint: disable=too-many-ancestors
class MailDomainAccessViewSet(
-3
View File
@@ -2,7 +2,6 @@
Mailbox manager application factories
"""
from django.contrib.auth.hashers import make_password
from django.utils.text import slugify
import factory.fuzzy
@@ -28,7 +27,6 @@ class MailDomainFactory(factory.django.DjangoModelFactory):
name = factory.Faker("domain_name")
slug = factory.LazyAttribute(lambda o: slugify(o.name))
support_email = factory.Faker("email")
@factory.post_generation
def users(self, create, extracted, **kwargs):
@@ -77,7 +75,6 @@ class MailboxFactory(factory.django.DjangoModelFactory):
)
domain = factory.SubFactory(MailDomainEnabledFactory)
secondary_email = factory.Faker("email")
password = make_password("password")
class MailboxEnabledFactory(MailboxFactory):
@@ -48,7 +48,7 @@ class Command(BaseCommand):
# protected behind admin rights but dimail allows to create a first user
# when database is empty
self.create_user(
auth=("", ""),
auth=(None, None),
name=admin["username"],
password=admin["password"],
perms=[],
@@ -66,11 +66,7 @@ class Command(BaseCommand):
# we create a domain and add John Doe to it
domain_name = "test.domain.com"
domain = MailDomain.objects.get_or_create(
name=domain_name,
defaults={
"status": enums.MailDomainStatusChoices.ENABLED,
"support_email": f"support@{domain_name}",
},
name=domain_name, defaults={"status": enums.MailDomainStatusChoices.ENABLED}
)[0]
self.create_domain(domain_name)
@@ -1,31 +0,0 @@
# Generated by Django 5.1.5 on 2025-02-02 21:49
from django.db import migrations, models
def fill_support_email(apps, schema_editor):
MailDomain = apps.get_model('mailbox_manager', 'MailDomain')
for domain in MailDomain.objects.filter(support_email__isnull=True):
domain.support_email = "support-regie@numerique.gouv.fr"
domain.save()
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0017_alter_maildomain_status'),
]
operations = [
migrations.AddField(
model_name='maildomain',
name='support_email',
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='support email'),
),
migrations.RunPython(fill_support_email, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name='maildomain',
name='support_email',
field=models.EmailField(max_length=254, verbose_name='support email'),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.1.5 on 2025-02-07 20:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0018_maildomain_support_email'),
]
operations = [
migrations.AddField(
model_name='maildomain',
name='last_check_details',
field=models.JSONField(blank=True, help_text='A JSON object containing the last health check details', null=True, verbose_name='last check details'),
),
]
@@ -1,25 +0,0 @@
# Generated by Django 5.1.6 on 2025-02-14 17:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0019_maildomain_last_check_details'),
]
operations = [
migrations.AlterModelOptions(
name='mailbox',
options={'ordering': ['-created_at'], 'verbose_name': 'Mailbox', 'verbose_name_plural': 'Mailboxes'},
),
migrations.AlterModelOptions(
name='maildomain',
options={'ordering': ['-created_at'], 'verbose_name': 'Mail domain', 'verbose_name_plural': 'Mail domains'},
),
migrations.AlterModelOptions(
name='maildomainaccess',
options={'ordering': ['-created_at'], 'verbose_name': 'User/mail domain relation', 'verbose_name_plural': 'User/mail domain relations'},
),
]
@@ -1,18 +0,0 @@
# 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'),
),
]
@@ -1,20 +0,0 @@
# Generated by Django 5.1.5 on 2025-02-10 11:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0010_team_depth_team_numchild_team_path_and_more'),
('mailbox_manager', '0021_maildomain_expected_config'),
]
operations = [
migrations.AddField(
model_name='maildomain',
name='organization',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='mail_domains', to='core.organization'),
),
]
@@ -1,52 +0,0 @@
# Generated by Django 5.1.5 on 2025-02-07 16:13
import django.db.models.functions.text
from django.db import migrations, models
def fill_dn_email(apps, schema_editor):
"""Fill the dn_email field with the email field."""
Mailbox = apps.get_model("mailbox_manager", "Mailbox")
for mailbox in Mailbox.objects.select_related("domain").filter(
models.Q(dn_email="") | models.Q(dn_email__isnull=True)
):
# quite naive but we don't have many data
mailbox.dn_email = f"{mailbox.local_part}@{mailbox.domain.name}"
mailbox.save()
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0022_maildomain_organization'),
]
operations = [
migrations.AddField(
model_name='mailbox',
name='last_login',
field=models.DateTimeField(blank=True, null=True, verbose_name='last login'),
),
migrations.AddField(
model_name='mailbox',
name='password',
field=models.CharField(default='', max_length=128, verbose_name='password'),
preserve_default=False,
),
migrations.AddField(
model_name='mailbox',
name='dn_email',
field=models.EmailField(blank=True, editable=False, max_length=254, null=True, unique=True,
verbose_name='email'),
),
migrations.RunPython(
code=fill_dn_email,
reverse_code=migrations.RunPython.noop,
),
migrations.AlterField(
model_name='mailbox',
name='dn_email',
field=models.EmailField(blank=True, editable=False, max_length=254, unique=True, verbose_name='email'),
),
]
+2 -51
View File
@@ -3,13 +3,12 @@ Declare and configure the models for the People additional application : mailbox
"""
from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser
from django.core import exceptions, validators
from django.db import models
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from core.models import BaseModel, Organization
from core.models import BaseModel
from mailbox_manager.enums import (
MailboxStatusChoices,
@@ -24,38 +23,17 @@ class MailDomain(BaseModel):
name = models.CharField(
_("name"), max_length=150, null=False, blank=False, unique=True
)
organization = models.ForeignKey(
Organization,
on_delete=models.PROTECT,
related_name="mail_domains",
null=True,
blank=True,
)
slug = models.SlugField(null=False, blank=False, unique=True, max_length=80)
status = models.CharField(
max_length=20,
default=MailDomainStatusChoices.PENDING,
choices=MailDomainStatusChoices.choices,
)
support_email = models.EmailField(_("support email"), null=False, blank=False)
last_check_details = models.JSONField(
null=True,
blank=True,
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"
verbose_name = _("Mail domain")
verbose_name_plural = _("Mail domains")
ordering = ["-created_at"]
def __str__(self):
return self.name
@@ -95,14 +73,6 @@ class MailDomain(BaseModel):
"manage_accesses": is_owner_or_admin,
}
def is_identity_provider_ready(self) -> bool:
"""
Check if the identity provider is ready to manage the domain.
"""
return (
bool(self.organization) and self.status == MailDomainStatusChoices.ENABLED
)
class MailDomainAccess(BaseModel):
"""Allow to manage users' accesses to mail domains."""
@@ -132,7 +102,6 @@ class MailDomainAccess(BaseModel):
verbose_name = _("User/mail domain relation")
verbose_name_plural = _("User/mail domain relations")
unique_together = ("user", "domain")
ordering = ["-created_at"]
def __str__(self):
return f"Access of user {self.user} on domain {self.domain}."
@@ -197,7 +166,7 @@ class MailDomainAccess(BaseModel):
}
class Mailbox(AbstractBaseUser, BaseModel):
class Mailbox(BaseModel):
"""Mailboxes for users from mail domain."""
first_name = models.CharField(max_length=200, blank=False)
@@ -225,19 +194,11 @@ class Mailbox(AbstractBaseUser, BaseModel):
default=MailboxStatusChoices.PENDING,
)
# Store the denormalized email address to allow Django admin to work (USERNAME_FIELD)
# This field *must* not be used for authentication (or anything sensitive),
# use the `local_part` and `domain__name` fields
dn_email = models.EmailField(_("email"), blank=True, unique=True, editable=False)
USERNAME_FIELD = "dn_email"
class Meta:
db_table = "people_mail_box"
verbose_name = _("Mailbox")
verbose_name_plural = _("Mailboxes")
unique_together = ("local_part", "domain")
ordering = ["-created_at"]
def __str__(self):
return f"{self.local_part!s}@{self.domain.name:s}"
@@ -257,19 +218,9 @@ class Mailbox(AbstractBaseUser, BaseModel):
Override save function to not allow to create or update mailbox of a disabled domain.
"""
self.full_clean()
self.dn_email = self.get_email()
if self.domain.status == MailDomainStatusChoices.DISABLED:
raise exceptions.ValidationError(
_("You can't create or update a mailbox for a disabled domain.")
)
return super().save(*args, **kwargs)
@property
def is_active(self):
"""Return True if the mailbox is enabled."""
return self.status == MailboxStatusChoices.ENABLED
def get_email(self):
"""Return the email address of the mailbox."""
return f"{self.local_part}@{self.domain.name}"
@@ -2,7 +2,6 @@
Tests for MailDomains API endpoint in People's app mailbox_manager. Focus on "create" action.
"""
import json
import logging
import re
@@ -15,11 +14,6 @@ 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,
DOMAIN_SPEC,
)
pytestmark = pytest.mark.django_db
@@ -58,7 +52,6 @@ def test_api_mail_domains__create_name_unique():
assert response.json()["name"] == ["Mail domain with this name already exists."]
@responses.activate
def test_api_mail_domains__create_authenticated():
"""
Authenticated users should be able to create mail domains
@@ -71,64 +64,44 @@ def test_api_mail_domains__create_authenticated():
domain_name = "test.domain.fr"
responses.add(
responses.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
body_content_domain1 = CHECK_DOMAIN_BROKEN.copy()
body_content_domain1["name"] = domain_name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain_name}/check/"),
body=json.dumps(body_content_domain1),
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/",
{
"name": domain_name,
"context": "null",
"features": ["webmail"],
"support_email": f"support@{domain_name}",
},
format="json",
)
with responses.RequestsMock() as rsps:
rsps.add(
rsps.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
{"name": domain_name, "context": "null", "features": ["webmail"]},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
domain = models.MailDomain.objects.get()
@@ -137,43 +110,25 @@ def test_api_mail_domains__create_authenticated():
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"status": enums.MailDomainStatusChoices.ACTION_REQUIRED,
"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),
"count_mailboxes": 0,
"support_email": domain.support_email,
"last_check_details": body_content_domain1,
"action_required_details": {
"cname_imap": "Il faut un CNAME 'imap.example.fr' qui renvoie vers "
"'imap.ox.numerique.gouv.fr.'",
"cname_smtp": "Le CNAME pour 'smtp.example.fr' n'est pas bon, "
"il renvoie vers 'ns0.ovh.net.' et je veux 'smtp.ox.numerique.gouv.fr.'",
"cname_webmail": "Il faut un CNAME 'webmail.example.fr' qui "
"renvoie vers 'webmail.ox.numerique.gouv.fr.'",
"dkim": "Il faut un DKIM record, avec la bonne clef",
"mx": "Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr., "
"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
assert domain.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
assert domain.last_check_details == body_content_domain1
# a new domain with status "pending" is created and authenticated user is the owner
assert domain.status == enums.MailDomainStatusChoices.PENDING
assert domain.name == domain_name
assert domain.accesses.filter(role="owner", user=user).exists()
@responses.activate
def test_api_mail_domains__create_authenticated__dimail_failure(caplog):
def test_api_mail_domains__create_authenticated__dimail_failure():
"""
Despite a dimail failure for user and/or allow creation,
an authenticated user should be able to create a mail domain
and should automatically be added as owner of the newly created domain.
"""
caplog.set_level(logging.ERROR)
user = core_factories.UserFactory()
client = APIClient()
@@ -181,60 +136,45 @@ def test_api_mail_domains__create_authenticated__dimail_failure(caplog):
domain_name = "test.domain.fr"
responses.add(
responses.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
dimail_error = {
"status_code": status.HTTP_401_UNAUTHORIZED,
"detail": "Not authorized",
}
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain_name}/check/"),
body=json.dumps(dimail_error),
status=dimail_error["status_code"],
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": domain_name,
"context": "null",
"features": ["webmail"],
"support_email": f"support@{domain_name}",
},
format="json",
)
with responses.RequestsMock() as rsps:
rsps.add(
rsps.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
{"name": domain_name, "context": "null", "features": ["webmail"]},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
domain = models.MailDomain.objects.get()
# response is as expected
@@ -247,22 +187,15 @@ def test_api_mail_domains__create_authenticated__dimail_failure(caplog):
"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": None,
"action_required_details": {},
"expected_config": None,
}
# a new domain with status "failed" is created and authenticated user is the owner
assert domain.status == enums.MailDomainStatusChoices.FAILED
assert domain.name == domain_name
assert domain.accesses.filter(role="owner", user=user).exists()
assert caplog.records[0].levelname == "ERROR"
assert "Not authorized" in caplog.records[0].message
## SYNC TO DIMAIL
@responses.activate
def test_api_mail_domains__create_dimail_domain(caplog):
"""
Creating a domain should trigger a call to create a domain on dimail too.
@@ -274,65 +207,49 @@ def test_api_mail_domains__create_dimail_domain(caplog):
client.force_login(user)
domain_name = "test.fr"
responses.add(
responses.POST,
re.compile(r".*/domains/"),
body=str(
with responses.RequestsMock() as rsps:
rsp = rsps.add(
rsps.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
body_content_domain1 = CHECK_DOMAIN_OK.copy()
body_content_domain1["name"] = domain_name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain_name}/check/"),
body=json.dumps(body_content_domain1),
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/",
{
"name": domain_name,
"support_email": f"support@{domain_name}",
},
format="json",
)
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert rsp.call_count == 1 # endpoint was called
# Logger
assert len(caplog.records) == 4 # should be 3. Last empty info still here.
@@ -350,7 +267,6 @@ def test_api_mail_domains__create_dimail_domain(caplog):
)
@responses.activate
def test_api_mail_domains__no_creation_when_dimail_duplicate(caplog):
"""No domain should be created when dimail returns a 409 conflict."""
user = core_factories.UserFactory()
@@ -362,47 +278,27 @@ def test_api_mail_domains__no_creation_when_dimail_duplicate(caplog):
"status_code": status.HTTP_409_CONFLICT,
"detail": "Domain already exists",
}
responses.add(
responses.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/domains/"),
body=str({"detail": dimail_error["detail"]}),
status=dimail_error["status_code"],
content_type="application/json",
)
with pytest.raises(HTTPError):
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": domain_name,
"support_email": f"support@{domain_name}",
},
format="json",
with responses.RequestsMock() as rsps:
rsp = rsps.add(
rsps.POST,
re.compile(r".*/domains/"),
body=str({"detail": dimail_error["detail"]}),
status=dimail_error["status_code"],
content_type="application/json",
)
with pytest.raises(HTTPError):
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": domain_name,
},
format="json",
)
assert response.status_code == dimail_error["status_code"]
assert response.json() == {"detail": dimail_error["detail"]}
assert rsp.call_count == 1 # endpoint was called
assert response.status_code == dimail_error["status_code"]
assert response.json() == {"detail": dimail_error["detail"]}
# check logs
record = caplog.records[0]
@@ -1,143 +0,0 @@
"""
Tests for MailDomains API endpoint in People's mailbox manager app. Focus on "fetch" action.
"""
import json
import re
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
from mailbox_manager.tests.fixtures import dimail as dimail_fixtures
pytestmark = pytest.mark.django_db
@responses.activate
def test_api_mail_domains__fetch_from_dimail__anonymous():
"""
Anonymous users should not be allowed to fetch a domain from dimail.
"""
client = APIClient()
domain = factories.MailDomainFactory()
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/fetch/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@responses.activate
def test_api_mail_domains__fetch_from_dimail__unrelated():
"""
Authenticated users shouldn't be allowed to fetch
a domain from dimail if they are not an owner or admin.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
domain = factories.MailDomainFactory(
status=enums.MailDomainStatusChoices.PENDING,
)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/fetch/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_mail_domains__fetch_from_dimail__viewer():
"""
Authenticated users shouldn't be allowed to fetch a domain from dimail
if they are a viewer.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
access = factories.MailDomainAccessFactory(
user=user,
role=enums.MailDomainRoleChoices.VIEWER,
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/fetch/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize(
"role",
[
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.OWNER,
],
)
@responses.activate
def test_api_mail_domains__fetch_from_dimail(role):
"""
Authenticated users should be allowed to fetch a domain
from dimail if they are an owner or admin.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
domain = factories.MailDomainFactory(
status=enums.MailDomainStatusChoices.PENDING,
)
factories.MailDomainAccessFactory(
domain=domain,
user=user,
role=role,
)
assert domain.expected_config is None
assert domain.last_check_details is None
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
json=dimail_fixtures.CHECK_DOMAIN_OK,
status=200,
)
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/spec/"),
json=dimail_fixtures.DOMAIN_SPEC,
status=200,
)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/fetch/",
)
domain.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"status": str(enums.MailDomainStatusChoices.ENABLED),
"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_OK,
"action_required_details": {},
"expected_config": dimail_fixtures.DOMAIN_SPEC,
}
assert domain.expected_config == dimail_fixtures.DOMAIN_SPEC
assert domain.last_check_details == dimail_fixtures.CHECK_DOMAIN_OK
assert domain.status == enums.MailDomainStatusChoices.ENABLED
@@ -3,19 +3,16 @@ Tests for MailDomains API endpoint in People's mailbox manager app. Focus on "re
"""
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
from mailbox_manager.tests.fixtures import dimail as dimail_fixtures
from mailbox_manager import factories
pytestmark = pytest.mark.django_db
@responses.activate
def test_api_mail_domains__retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a domain."""
@@ -26,11 +23,8 @@ 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
@@ -44,11 +38,8 @@ 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
@@ -68,7 +59,6 @@ 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
@@ -97,123 +87,4 @@ def test_api_mail_domains__retrieve_authenticated_related():
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
"abilities": domain.get_abilities(user),
"count_mailboxes": 10,
"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
to which they have access and which has actions required to be done.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
domain = factories.MailDomainFactory(
status=enums.MailDomainStatusChoices.ACTION_REQUIRED,
last_check_details=dimail_fixtures.CHECK_DOMAIN_BROKEN_EXTERNAL,
)
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_EXTERNAL,
"action_required_details": {
"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
to which they have access and which has no actions required to be done.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
domain = factories.MailDomainEnabledFactory(
last_check_details=dimail_fixtures.CHECK_DOMAIN_OK,
)
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_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,
}
-19
View File
@@ -255,25 +255,6 @@ 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"})
@@ -18,7 +18,6 @@ from mailbox_manager import enums, factories, models
from .fixtures.dimail import (
CHECK_DOMAIN_BROKEN,
CHECK_DOMAIN_OK,
DOMAIN_SPEC,
TOKEN_OK,
response_mailbox_created,
)
@@ -151,56 +150,3 @@ def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(client):
assert "Check domains done with success" in response.content.decode("utf-8")
for mailbox in models.Mailbox.objects.filter(domain=domain1):
assert mailbox.status == enums.MailboxStatusChoices.ENABLED
@pytest.mark.parametrize(
"domain_status",
[
enums.MailDomainStatusChoices.PENDING,
enums.MailDomainStatusChoices.FAILED,
enums.MailDomainStatusChoices.ENABLED,
],
)
@responses.activate
@pytest.mark.django_db
def test_fetch_domain_expected_config(client, domain_status):
"""Test admin action to fetch domain expected config from dimail."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
domain = factories.MailDomainFactory(status=domain_status)
data = {
"action": "fetch_domain_expected_config_from_dimail",
"_selected_action": [domain.id],
}
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",
)
url = reverse("admin:mailbox_manager_maildomain_changelist")
response = client.post(url, data, follow=True)
assert response.status_code == status.HTTP_200_OK
domain.refresh_from_db()
assert domain.expected_config == DOMAIN_SPEC
@pytest.mark.django_db
def test_fetch_domain_expected_config__should_not_fetch_for_disabled_domain(client):
"""Test admin action to fetch domain expected config from dimail."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
domain = factories.MailDomainFactory(status=enums.MailDomainStatusChoices.DISABLED)
data = {
"action": "fetch_domain_expected_config_from_dimail",
"_selected_action": [domain.id],
}
url = reverse("admin:mailbox_manager_maildomain_changelist")
response = client.post(url, data, follow=True)
assert response.status_code == status.HTTP_200_OK
domain.refresh_from_db()
assert domain.expected_config is None
assert "Domains disabled are excluded from fetch" in response.content.decode(
"utf-8"
)
@@ -194,13 +194,11 @@ def test_dimail__fetch_domain_status__switch_to_enabled(domain_status):
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ENABLED
assert domain.last_check_details == body_content
# call again, should be ok
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ENABLED
assert domain.last_check_details == body_content
@pytest.mark.parametrize(
@@ -219,12 +217,12 @@ def test_dimail__fetch_domain_status__switch_to_action_required(
"""Domains should be in status action required when dimail check
returns broken status for external checks."""
domain = factories.MailDomainFactory(status=domain_status)
body_domain_broken = CHECK_DOMAIN_BROKEN_EXTERNAL.copy()
body_domain_broken["name"] = domain.name
body_content = CHECK_DOMAIN_BROKEN_EXTERNAL.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_broken),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -232,23 +230,21 @@ def test_dimail__fetch_domain_status__switch_to_action_required(
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
assert domain.last_check_details == body_domain_broken
# Support team fixes their part of the problem
# Now domain is OK again
body_domain_ok = CHECK_DOMAIN_OK.copy()
body_domain_ok["name"] = domain.name
body_content = CHECK_DOMAIN_OK.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_ok),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ENABLED
assert domain.last_check_details == body_domain_ok
@pytest.mark.parametrize(
@@ -265,12 +261,12 @@ def test_dimail__fetch_domain_status__switch_to_failed(domain_status):
for only internal checks dispite a fix call."""
domain = factories.MailDomainFactory(status=domain_status)
# nothing can be done by support team, domain should be in failed
body_domain_broken = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_domain_broken["name"] = domain.name
body_content = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_broken),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -278,7 +274,7 @@ def test_dimail__fetch_domain_status__switch_to_failed(domain_status):
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/fix/"),
body=json.dumps(body_domain_broken),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -286,7 +282,6 @@ def test_dimail__fetch_domain_status__switch_to_failed(domain_status):
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.FAILED
assert domain.last_check_details == body_domain_broken
@pytest.mark.parametrize(
@@ -303,12 +298,12 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
after a fix call."""
domain = factories.MailDomainFactory(status=domain_status)
# with all checks KO, domain should be in action required
body_domain_broken = CHECK_DOMAIN_BROKEN.copy()
body_domain_broken["name"] = domain.name
body_content = CHECK_DOMAIN_BROKEN.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_broken),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -316,28 +311,27 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
assert domain.last_check_details == body_domain_broken
# We assume that the support has fixed their part.
# So now dimail returns OK for external checks but still KO for internal checks.
# A call to dimail fix endpoint is necessary and will be done by
# the fetch_domain_status call
body_domain_broken_internal = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_domain_broken_internal["name"] = domain.name
body_content = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_broken_internal),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
# the endpoint fix is called and returns OK. Hooray!
body_domain_ok = CHECK_DOMAIN_OK.copy()
body_domain_ok["name"] = domain.name
body_content = CHECK_DOMAIN_OK.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/fix/"),
body=json.dumps(body_domain_ok),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -345,7 +339,6 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ENABLED
assert domain.last_check_details == body_domain_ok
def test_dimail__enable_pending_mailboxes(caplog):
@@ -398,7 +391,7 @@ def test_dimail__enable_pending_mailboxes(caplog):
)
assert (
caplog.records[2].message
== f"Information for mailbox mock@{domain.name} sent to {mailbox2.secondary_email}."
== f"Information for mailbox mock@{domain.name} sent to {mailbox1.secondary_email}."
)
assert (
caplog.records[4].message
@@ -406,5 +399,5 @@ def test_dimail__enable_pending_mailboxes(caplog):
)
assert (
caplog.records[5].message
== f"Information for mailbox mock@{domain.name} sent to {mailbox1.secondary_email}."
== f"Information for mailbox mock@{domain.name} sent to {mailbox2.secondary_email}."
)
+32 -105
View File
@@ -8,7 +8,6 @@ from email.headerregistry import Address
from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.contrib.sites.models import Site
from django.core import exceptions, mail
from django.template.loader import render_to_string
@@ -342,7 +341,6 @@ class DimailAPIClient:
# secondary email is mandatory. Unfortunately, dimail doesn't
# store any. We temporarily give current email as secondary email.
status=enums.MailboxStatusChoices.ENABLED,
password=make_password(None), # unusable password
)
imported_mailboxes.append(str(mailbox))
else:
@@ -420,20 +418,12 @@ class DimailAPIClient:
def check_domain(self, domain):
"""Send a request to dimail to check domain health."""
try:
response = session.get(
f"{self.API_URL}/domains/{domain.name}/check/",
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
verify=True,
timeout=20,
)
except requests.exceptions.ConnectionError as error:
logger.error(
"Connection error while trying to reach %s.",
self.API_URL,
exc_info=error,
)
raise error
response = session.get(
f"{self.API_URL}/domains/{domain.name}/check/",
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
verify=True,
timeout=10,
)
if response.status_code == status.HTTP_200_OK:
return response.json()
return self.raise_exception_for_unexpected_response(response)
@@ -458,61 +448,43 @@ class DimailAPIClient:
def fetch_domain_status(self, domain):
"""Send a request to check and update status of a domain."""
dimail_response = self.check_domain(domain)
if dimail_response:
dimail_state = dimail_response["state"]
# if domain is not enabled and dimail returns ok status, enable it
if (
domain.status != enums.MailDomainStatusChoices.ENABLED
and dimail_state == "ok"
):
self.enable_pending_mailboxes(domain)
domain.status = enums.MailDomainStatusChoices.ENABLED
domain.last_check_details = dimail_response
dimail_state = dimail_response["state"]
# if domain is not enabled and dimail returns ok status, enable it
if (
domain.status != enums.MailDomainStatusChoices.ENABLED
and dimail_state == "ok"
):
self.enable_pending_mailboxes(domain)
domain.status = enums.MailDomainStatusChoices.ENABLED
domain.save()
# if dimail returns broken status, we need to extract external and internal checks
# and manage the case where the domain has to be fixed by support
elif dimail_state == "broken":
external_checks = self._get_dimail_checks(dimail_response, internal=False)
internal_checks = self._get_dimail_checks(dimail_response, internal=True)
# manage the case where the domain has to be fixed by support
if not all(external_checks.values()):
domain.status = enums.MailDomainStatusChoices.ACTION_REQUIRED
domain.save()
# if dimail returns broken status, we need to extract external and internal checks
# and manage the case where the domain has to be fixed by support
elif dimail_state == "broken":
# if all external checks are ok but not internal checks, we need to fix internal checks
elif all(external_checks.values()) and not all(internal_checks.values()):
# a call to fix endpoint is needed because all external checks are ok
dimail_response = self.fix_domain(domain)
# we need to check again if all internal and external checks are ok
external_checks = self._get_dimail_checks(
dimail_response, internal=False
)
internal_checks = self._get_dimail_checks(
dimail_response, internal=True
)
# manage the case where the domain has to be fixed by support
if not all(external_checks.values()):
domain.status = enums.MailDomainStatusChoices.ACTION_REQUIRED
domain.last_check_details = dimail_response
if all(external_checks.values()) and all(internal_checks.values()):
domain.status = enums.MailDomainStatusChoices.ENABLED
domain.save()
# if all external checks are ok but not internal checks, we need to fix
# internal checks
elif all(external_checks.values()) and not all(
internal_checks.values()
):
# a call to fix endpoint is needed because all external checks are ok
dimail_response = self.fix_domain(domain)
# we need to check again if all internal and external checks are ok
external_checks = self._get_dimail_checks(
dimail_response, internal=False
)
internal_checks = self._get_dimail_checks(
dimail_response, internal=True
)
if all(external_checks.values()) and all(internal_checks.values()):
domain.status = enums.MailDomainStatusChoices.ENABLED
domain.last_check_details = dimail_response
domain.save()
elif all(external_checks.values()) and not all(
internal_checks.values()
):
domain.status = enums.MailDomainStatusChoices.FAILED
domain.last_check_details = dimail_response
domain.save()
# if no health check data is stored on the domain, we store it now
if not domain.last_check_details:
domain.last_check_details = dimail_response
domain.save()
domain.status = enums.MailDomainStatusChoices.FAILED
domain.save()
return dimail_response
def _get_dimail_checks(self, dimail_response, internal: bool):
@@ -522,48 +494,3 @@ 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 []
-1
View File
@@ -1 +0,0 @@
"""People application to allow OAuth2 authentication with OIDC provider using mailbox."""
-1
View File
@@ -1 +0,0 @@
"""People application to allow OAuth2 authentication with OIDC provider using mailbox."""
-129
View File
@@ -1,129 +0,0 @@
"""Authentication backend for OIDC provider"""
import logging
from email.errors import HeaderParseError
from email.headerregistry import Address
from django.conf import settings
from django.contrib.auth.backends import ModelBackend
from django.core.cache import cache
from django.utils.text import slugify
from mailbox_manager.models import Mailbox
logger = logging.getLogger(__name__)
def get_username_domain_from_email(email: str):
"""Extract local part and domain from email."""
try:
address = Address(addr_spec=email)
if len(address.username) > 64 or len(address.domain) > 255:
# Simple length validation using the RFC 5321 limits
return None, None
return address.username, address.domain
except (TypeError, ValueError, AttributeError, IndexError, HeaderParseError) as exc:
logger.exception(exc)
return None, None
class MailboxModelBackend(ModelBackend):
"""
Custom authentication backend for OIDC provider, enforce the use of email as the username.
Warning: This authentication backend is not suitable for general use, it is
tailored for the OIDC provider and will only authenticate user and allow
them to access the /o/authorize endpoint **only**.
"""
def _get_cache_key(self, email):
"""Generate a cache key for tracking login attempts."""
stringified_email = email.replace("@", "_at_").replace(".", "_dot_")
return f"login_attempts_{slugify(stringified_email)}"
def _increment_login_attempts(self, email):
"""Increment the number of failed login attempts."""
cache_key = self._get_cache_key(email)
attempts = cache.get(cache_key, 0) + 1
cache.set(cache_key, attempts, settings.ACCOUNT_LOCKOUT_TIME)
def _reset_login_attempts(self, email):
"""Reset the number of failed login attempts."""
cache_key = self._get_cache_key(email)
cache.delete(cache_key)
def _is_login_attempts_exceeded(self, email) -> bool:
"""Check if the account is locked due to too many failed attempts."""
cache_key = self._get_cache_key(email)
attempts = cache.get(cache_key, 0)
return attempts >= settings.MAX_LOGIN_ATTEMPTS
def get_user(self, user_id):
"""Retrieve a user, here a mailbox, by its unique identifier."""
try:
mailbox = Mailbox.objects.get(pk=user_id)
except Mailbox.DoesNotExist:
return None
if self.user_can_authenticate(mailbox):
return mailbox
return None
def authenticate(self, request, username=None, password=None, email=None, **kwargs):
"""Authenticate a user based on email and password"""
if username or email is None: # ignore if username is provided
return None
# Disable this backend if the corresponding middleware is not defined.
if (
"mailbox_oauth2.middleware.one_time_email_authenticated_session"
not in settings.MIDDLEWARE
):
logger.error(
"EmailModelBackend was triggered but the `one_time_email_authenticated_session` "
"is not set: ignoring authentication."
)
return None
# Check if the account is locked
if self._is_login_attempts_exceeded(email):
logger.warning("Account locked due to too many failed attempts: %s", email)
# Run the default password hasher once to reduce the timing
# difference between a locked account and valid one (django issue #20760)
Mailbox().set_password(password)
return None
local_part, domain = get_username_domain_from_email(email)
if local_part is None or domain is None:
return None
try:
user = Mailbox.objects.select_related("domain").get(
local_part__iexact=local_part, domain__name__iexact=domain
)
except Mailbox.DoesNotExist:
# Run the default password hasher once to reduce the timing
# difference between an existing and a nonexistent user (django issue #20760).
Mailbox().set_password(password)
else:
if not self.user_can_authenticate(user):
# Run the default password hasher once to reduce the timing
# difference between a user who can authenticate and another one.
Mailbox().set_password(password)
elif user.check_password(password):
# Reset attempts on successful login
self._reset_login_attempts(email)
return user
else:
# Track failed attempt
self._increment_login_attempts(email)
return None
def user_can_authenticate(self, user):
"""Verify the user can authenticate."""
user_can_authenticate = super().user_can_authenticate(user)
return user_can_authenticate and user.domain.is_identity_provider_ready()
-50
View File
@@ -1,50 +0,0 @@
"""Middleware to allow a user to authenticate against "/o/authorize" only once."""
from django.contrib.auth import BACKEND_SESSION_KEY
from django.contrib.sessions.exceptions import SuspiciousSession
from django.urls import reverse
def one_time_email_authenticated_session(get_response):
"""Middleware to allow a user to authenticate against "/o/authorize" only once."""
def middleware(request):
# Code executed for each request before the view (and later middleware) are called.
if not request.user.is_authenticated:
# If user is not authenticated, proceed:
# this is not this middleware's concern
return get_response(request)
# Check if the auth backend is stored in session
auth_backend = request.session.get(BACKEND_SESSION_KEY)
if auth_backend != "mailbox_oauth2.backends.MailboxModelBackend":
# If the backend is not MailboxModelBackend, proceed:
# this is not this middleware's concern
return get_response(request)
# Allow access only to /o/authorize path
if request.path != reverse("oauth2_provider:authorize"):
# Kill the session immediately
request.session.flush()
raise SuspiciousSession(
"Session was killed because user tried to access unauthorized path"
)
response = get_response(request)
# Code executed for each request/response after the view is called.
# When the response is a 200, the user might still be in the authentication flow
# otherwise, we can kill the session as the current login process is done,
# the user will be authenticated again when coming back from the OIDC federation.
# We preserve the oidc_states for the case when the user wants to access people
# in the first place (people -> keycloak -> people login -> keycloak -> people)
if response.status_code != 200:
state = request.session.get("oidc_states")
request.session.flush()
if state:
request.session["oidc_states"] = state
return response
return middleware
@@ -1,98 +0,0 @@
# Generated by Django 5.1.5 on 2025-02-07 10:49
import django.db.models.deletion
import oauth2_provider.models
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('mailbox_manager', '0023_mailbox_email_mailbox_last_login_mailbox_password'),
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
]
operations = [
migrations.CreateModel(
name='Grant',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('code', models.CharField(max_length=255, unique=True)),
('expires', models.DateTimeField()),
('redirect_uri', models.TextField()),
('scope', models.TextField(blank=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('code_challenge', models.CharField(blank=True, default='', max_length=128)),
('code_challenge_method', models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10)),
('nonce', models.CharField(blank=True, default='', max_length=255)),
('claims', models.TextField(blank=True)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to='mailbox_manager.mailbox')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='IDToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('jti', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='JWT Token ID')),
('expires', models.DateTimeField()),
('scope', models.TextField(blank=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to='mailbox_manager.mailbox')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='AccessToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('token', models.TextField()),
('token_checksum', oauth2_provider.models.TokenChecksumField(db_index=True, max_length=64, unique=True)),
('expires', models.DateTimeField()),
('scope', models.TextField(blank=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to='mailbox_manager.mailbox')),
('id_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='RefreshToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('token', models.CharField(max_length=255)),
('token_family', models.UUIDField(blank=True, editable=False, null=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('revoked', models.DateTimeField(null=True)),
('access_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to='mailbox_manager.mailbox')),
],
options={
'abstract': False,
'unique_together': {('token', 'revoked')},
},
),
migrations.AddField(
model_name='accesstoken',
name='source_refresh_token',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL),
),
]
-78
View File
@@ -1,78 +0,0 @@
"""
Project custom models for the OAuth2 provider.
We replace the former user model with a mailbox model.
"""
from django.db import models
from oauth2_provider.models import (
AbstractAccessToken,
AbstractGrant,
AbstractIDToken,
AbstractRefreshToken,
)
class Grant(AbstractGrant):
"""
A Grant instance represents a token with a short lifetime that can
be swapped for an access token, as described in :rfc:`4.1.2`
Replaces the user with a mailbox instance.
"""
user = models.ForeignKey(
"mailbox_manager.Mailbox",
on_delete=models.CASCADE,
related_name="%(app_label)s_%(class)s",
)
class IDToken(AbstractIDToken):
"""
An IDToken instance represents the actual token to
access user's resources, as in :openid:`2`.
Replaces the user with a mailbox instance.
"""
user = models.ForeignKey(
"mailbox_manager.Mailbox",
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="%(app_label)s_%(class)s",
)
class AccessToken(AbstractAccessToken):
"""
An AccessToken instance represents the actual access token to
access user's resources, as in :rfc:`5`.
Replaces the user with a mailbox instance.
"""
user = models.ForeignKey(
"mailbox_manager.Mailbox",
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="%(app_label)s_%(class)s",
)
class RefreshToken(AbstractRefreshToken):
"""
A RefreshToken instance represents a token that can be swapped for a new
access token when it expires.
Replaces the user with a mailbox instance.
"""
user = models.ForeignKey(
"mailbox_manager.Mailbox",
on_delete=models.CASCADE,
related_name="%(app_label)s_%(class)s",
)
-48
View File
@@ -1,48 +0,0 @@
"""
Serializers for the mailbox_oauth2 app.
"""
from django.contrib.auth import authenticate
from rest_framework import serializers
class LoginSerializer(serializers.Serializer):
"""
Serializer for user login authentication.
Validates the email and password fields required for user authentication.
"""
email = serializers.EmailField(
help_text="User's email address for authentication",
required=True,
)
password = serializers.CharField(
write_only=True,
help_text="User's password for authentication",
required=True,
)
def create(self, validated_data):
"""Do not allow creating instances from this serializer."""
raise RuntimeError("LoginSerializer does not support create method")
def update(self, instance, validated_data):
"""Do not allow updating instances from this serializer."""
raise RuntimeError("LoginSerializer does not support update method")
def validate(self, attrs):
"""
Validate the email and password fields.
"""
email = attrs.get("email")
password = attrs.get("password")
if not (email and password):
raise serializers.ValidationError('Must include "email" and "password"')
attrs["user"] = authenticate(
self.context["request"], email=email, password=password
)
return attrs
@@ -1 +0,0 @@
"""Tests for OAuth2 authentication with OIDC provider using mailbox."""
@@ -1,270 +0,0 @@
"""Test authentication backend for OIDC provider."""
from django.contrib.auth.hashers import make_password
import pytest
from core import factories as core_factories
from mailbox_manager import factories
from mailbox_oauth2.backends import MailboxModelBackend, get_username_domain_from_email
pytestmark = pytest.mark.django_db
def test_authenticate_valid_credentials():
"""Test authentication with valid credentials."""
organization = core_factories.OrganizationFactory(with_registration_id=True)
domain = factories.MailDomainEnabledFactory(organization=organization)
mailbox = factories.MailboxEnabledFactory(domain=domain)
assert domain.is_identity_provider_ready()
authenticated_user = MailboxModelBackend().authenticate(
None, email=f"{mailbox.local_part}@{domain.name}", password="password"
)
assert authenticated_user == mailbox
# Is case insensitive
authenticated_user = MailboxModelBackend().authenticate(
None, email=f"{mailbox.local_part.upper()}@{domain.name}", password="password"
)
assert authenticated_user == mailbox
def test_authenticate_no_organization():
"""Test authentication when domain don't have organization."""
domain = factories.MailDomainEnabledFactory()
mailbox = factories.MailboxEnabledFactory(domain=domain)
assert not domain.is_identity_provider_ready()
authenticated_user = MailboxModelBackend().authenticate(
None, email=f"{mailbox.local_part}@{domain.name}", password="password"
)
assert authenticated_user is None
def test_authenticate_invalid_email_format():
"""Test authentication with invalid email format."""
authenticated_user = MailboxModelBackend().authenticate(
None, email="invalid-email", password="any-password"
)
assert authenticated_user is None
def test_authenticate_nonexistent_user():
"""Test authentication with non-existent user."""
authenticated_user = MailboxModelBackend().authenticate(
None, email="nonexistent@domain.com", password="any-password"
)
assert authenticated_user is None
def test_authenticate_wrong_password():
"""Test authentication with wrong password."""
organization = core_factories.OrganizationFactory(with_registration_id=True)
domain = factories.MailDomainEnabledFactory(organization=organization)
mailbox = factories.MailboxEnabledFactory(domain=domain)
assert domain.is_identity_provider_ready()
authenticated_user = MailboxModelBackend().authenticate(
None, email=f"{mailbox.local_part}@{domain.name}", password="wrong-password"
)
assert authenticated_user is None
def test_authenticate_without_middleware():
"""Test authentication without required middleware."""
authenticated_user = MailboxModelBackend().authenticate(
None, email="any@domain.com", password="any-password"
)
assert authenticated_user is None
def test_authenticate_inactive_domain():
"""Test authentication with inactive identity provider domain."""
organization = core_factories.OrganizationFactory(with_registration_id=True)
domain = factories.MailDomainFactory(organization=organization)
mailbox = factories.MailboxEnabledFactory(domain=domain)
assert not domain.is_identity_provider_ready()
authenticated_user = MailboxModelBackend().authenticate(
None, email=f"{mailbox.local_part}@{domain.name}", password="password"
)
assert authenticated_user is None
def test_authenticate_unusable_password():
"""
Test authentication with valid mailbox. but with unusable password.
This test is important because we use "make_password(None)" to generate
an unusable password for the mailbox (instead of set_unusable_password()
which would require an extra query).
"""
organization = core_factories.OrganizationFactory(with_registration_id=True)
domain = factories.MailDomainEnabledFactory(organization=organization)
mailbox = factories.MailboxEnabledFactory(domain=domain)
mailbox.password = make_password(None)
mailbox.save()
assert domain.is_identity_provider_ready()
authenticated_user = MailboxModelBackend().authenticate(
None, email=f"{mailbox.local_part}@{domain.name}", password=mailbox.password
)
assert authenticated_user is None
def test_get_user_exists():
"""Test get_user with existing user."""
organization = core_factories.OrganizationFactory(with_registration_id=True)
domain = factories.MailDomainEnabledFactory(organization=organization)
mailbox = factories.MailboxEnabledFactory(domain=domain)
user = MailboxModelBackend().get_user(mailbox.pk)
assert user == mailbox
def test_get_user_does_not_exist():
"""Test get_user with non-existent user."""
user = MailboxModelBackend().get_user(999999)
assert user is None
def test_authenticate_with_username_only():
"""Test authentication fails when only username is provided (no email)."""
authenticated_user = MailboxModelBackend().authenticate(
None, username="test", password="password"
)
assert authenticated_user is None
def test_authenticate_sql_injection():
"""Test authentication is safe against SQL injection attempts."""
organization = core_factories.OrganizationFactory(with_registration_id=True)
domain = factories.MailDomainEnabledFactory(organization=organization)
mailbox = factories.MailboxEnabledFactory(domain=domain)
# Test SQL injection in email field
authenticated_user = MailboxModelBackend().authenticate(
None, email="' OR '1'='1", password="password"
)
assert authenticated_user is None
# Test SQL injection in password field
authenticated_user = MailboxModelBackend().authenticate(
None, email=f"{mailbox.local_part}@{domain.name}", password="' OR '1'='1"
)
assert authenticated_user is None
def test_get_inactive_mailbox():
"""Test get_user with inactive user."""
organization = core_factories.OrganizationFactory(with_registration_id=True)
domain = factories.MailDomainEnabledFactory(organization=organization)
mailbox = factories.MailboxFactory(domain=domain)
user = MailboxModelBackend().get_user(mailbox.pk)
assert user is None
@pytest.mark.parametrize(
"email, expected_username, expected_domain",
[
("test@example.com", "test", "example.com"),
("test+label@example.com", "test+label", "example.com"),
("invalid-email", None, None),
("", None, None),
("üser@exämple.com", None, None),
("user@admin@example.com", None, None),
("unicodeonly@üser.com", "unicodeonly", "üser.com"),
("spaces in@domain.com", None, None),
("verylong" + "a" * 1000 + "@example.com", None, None),
("verylong@ex" + "a" * 1000 + "mple.com", None, None),
("weird\0char@domain.com", None, None),
("@domain.com", None, None),
("username@", None, None),
("quotes'and,commas@example.com", None, None),
],
)
def test_get_username_domain_from_email(email, expected_username, expected_domain):
"""Test extracting username and domain from email."""
username, domain = get_username_domain_from_email(email)
assert username == expected_username
assert domain == expected_domain
def test_login_attempts_tracking(settings):
"""Test tracking of failed login attempts."""
organization = core_factories.OrganizationFactory(with_registration_id=True)
domain = factories.MailDomainEnabledFactory(organization=organization)
mailbox = factories.MailboxEnabledFactory(domain=domain)
email = f"{mailbox.local_part}@{domain.name}"
backend = MailboxModelBackend()
# First attempts should allow authentication (but fail due to wrong password)
for _ in range(settings.MAX_LOGIN_ATTEMPTS - 1):
authenticated_user = backend.authenticate(
None, email=email, password="wrong-password"
)
assert authenticated_user is None
# Last attempt before lockout
authenticated_user = backend.authenticate(
None, email=email, password="wrong-password"
)
assert authenticated_user is None
# Account should now be locked
authenticated_user = backend.authenticate(None, email=email, password="password")
assert authenticated_user is None
def test_login_attempts_reset_on_success(settings):
"""Test that successful login resets the failed attempts counter."""
organization = core_factories.OrganizationFactory(with_registration_id=True)
domain = factories.MailDomainEnabledFactory(organization=organization)
mailbox = factories.MailboxEnabledFactory(domain=domain)
email = f"{mailbox.local_part}@{domain.name}"
backend = MailboxModelBackend()
# Make some failed attempts
for _ in range(settings.MAX_LOGIN_ATTEMPTS - 1):
authenticated_user = backend.authenticate(
None, email=email, password="wrong-password"
)
assert authenticated_user is None
# Successful login should reset counter
authenticated_user = backend.authenticate(None, email=email, password="password")
assert authenticated_user == mailbox
# Should be able to attempt again after reset
authenticated_user = backend.authenticate(
None, email=email, password="wrong-password"
)
assert authenticated_user is None
def test_login_attempts_cache_key():
"""Test the cache key generation for login attempts."""
backend = MailboxModelBackend()
email = "test@example.com"
cache_key = backend._get_cache_key(email) # pylint: disable=protected-access
assert cache_key == "login_attempts_test_at_example_dot_com"
@@ -1,150 +0,0 @@
"""
Tests for the mailbox_oauth2.middleware.one_time_email_authenticated_session
middleware.
"""
import pytest
from oauth2_provider.models import Application
from core import factories as core_factories
from mailbox_manager import factories
pytestmark = pytest.mark.django_db
@pytest.fixture(name="authorize_data")
def authorize_data_fixture():
"""Return the authorize data for the OIDC IdP process."""
yield {
"scope": "openid",
"state": (
"3C0yk2i25wrx6fNf9zn9287idFqFHGsGIu7UhuJaP0I"
".xYBWCFWCFmQ.hpSB0Fd0TmS8MP7cfFiVjw"
".eyJydSI6Imh0dHBzOi8vZGVzay4xMjcuMC4wLjEubml"
"wLmlvL2FwaS92MS4wL2NhbGxiYWNrLyIsInJ0IjoiY29"
"kZSIsInN0IjoiZ2MzazVSdzREZ0tySERBbHlYaW9vaXg"
"wa2IzVkMyMTMifQ"
),
"response_type": "code",
"client_id": "people-idp",
"redirect_uri": "https://test",
"acr_values": "eidas1",
"code_challenge": "36Tcgz62tUu7XvNj_g_jYu6IBi-j7BL-5ZwkW-rI9qc",
"code_challenge_method": "S256",
"nonce": "9CTyx0RNzP6kkywLyK6pwQ",
}
def test_one_time_email_authenticated_session_flow_unallowed_url(client):
"""
Test the middleware with a user that is authenticated during the
OIDC IdP process cannot access random pages.
"""
organization = core_factories.OrganizationFactory(with_registration_id=True)
domain = factories.MailDomainEnabledFactory(
organization=organization, name="example.com"
)
factories.MailboxEnabledFactory(domain=domain, local_part="user")
response = client.post(
"/api/v1.0/login/", {"email": "user@example.com", "password": "password"}
)
assert response.status_code == 200
# assert the user has a session
assert not client.session.is_empty()
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 400
# assert the user has no more session
assert client.session.is_empty()
def test_one_time_email_authenticated_session_flow_allowed_url(client, authorize_data):
"""
Test the middleware with a user that is authenticated during the
OIDC IdP process can access the OIDC authorize page.
"""
organization = core_factories.OrganizationFactory(with_registration_id=True)
domain = factories.MailDomainEnabledFactory(
organization=organization, name="example.com"
)
mailbox = factories.MailboxEnabledFactory(domain=domain, local_part="user")
response = client.post(
"/api/v1.0/login/", {"email": "user@example.com", "password": "password"}
)
assert response.status_code == 200
# assert the user has a session
assert not client.session.is_empty()
# to properly test the /o/authorize/ we need to setup OIDC identity provider
Application.objects.create(
name="people-idp",
client_id="people-idp",
redirect_uris="https://test",
client_type=Application.CLIENT_CONFIDENTIAL,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
)
response = client.get(
f"/o/authorize/?{'&'.join(f'{k}={v}' for k, v in authorize_data.items())}"
)
assert response.status_code == 200
assert response.context["user"] == mailbox
# assert the user has a session
assert not client.session.is_empty()
response = client.post(
"/o/authorize/",
authorize_data,
)
assert response.status_code == 302
# assert the user has no more session
assert client.session.is_empty()
def test_one_time_email_authenticated_session_flow_allowed_url_skip_authorization(
client,
authorize_data,
):
"""
Test the middleware with a user that is authenticated during the
OIDC IdP process can access the OIDC authorize page.
"""
organization = core_factories.OrganizationFactory(with_registration_id=True)
domain = factories.MailDomainEnabledFactory(
organization=organization, name="example.com"
)
factories.MailboxEnabledFactory(domain=domain, local_part="user")
response = client.post(
"/api/v1.0/login/", {"email": "user@example.com", "password": "password"}
)
assert response.status_code == 200
# assert the user has a session
assert not client.session.is_empty()
# to properly test the /o/authorize/ we need to setup OIDC identity provider
Application.objects.create(
name="people-idp",
client_id="people-idp",
redirect_uris="https://test",
client_type=Application.CLIENT_CONFIDENTIAL,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
skip_authorization=True,
)
response = client.get(
f"/o/authorize/?{'&'.join(f'{k}={v}' for k, v in authorize_data.items())}"
)
assert response.status_code == 302
# assert the user has no more session
assert client.session.is_empty()
@@ -1,194 +0,0 @@
"""Tests for OAuth2 validators."""
from django.contrib.auth.models import AnonymousUser
import pytest
from oauth2_provider.models import Application
from core import factories as core_factories
from mailbox_manager import factories
from mailbox_oauth2.validators import BaseValidator, ProConnectValidator
pytestmark = pytest.mark.django_db
@pytest.fixture(name="mailbox")
def mailbox_fixture():
"""Create a mailbox for testing."""
organization = core_factories.OrganizationFactory(with_registration_id=True)
domain = factories.MailDomainEnabledFactory(
organization=organization, name="example.com"
)
return factories.MailboxEnabledFactory(
domain=domain, local_part="user", first_name="John", last_name="Doe"
)
@pytest.fixture(name="oauth_request_authenticated")
def oauth_request_authenticated_fixture(mailbox):
"""Create a mock OAuth request object with authenticated user."""
class MockRequest: # pylint: disable=missing-class-docstring
def __init__(self):
self.user = mailbox
self.scopes = set()
self.claims = None
self.acr_values = None
return MockRequest()
@pytest.fixture(name="oauth_request_anonymous")
def oauth_request_anonymous_fixture():
"""Create a mock OAuth request object with anonymous user."""
class MockRequest: # pylint: disable=missing-class-docstring
def __init__(self):
self.user = AnonymousUser()
self.scopes = set()
self.claims = None
self.acr_values = None
return MockRequest()
@pytest.fixture(name="oauth_request_for_auth_code")
def oauth_request_for_auth_code_fixture(oauth_request_authenticated):
"""Create a mock OAuth request object with full authorization code attributes."""
class MockRequestWithClient: # pylint: disable=missing-class-docstring,too-many-instance-attributes
def __init__(self, base_request):
self.user = base_request.user
self.scopes = {"openid"}
self.claims = None
self.acr_values = None
# Required OAuth2 attributes for authorization code
self.client = Application.objects.create(
name="test_app",
client_type=Application.CLIENT_CONFIDENTIAL,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
skip_authorization=True,
)
self.redirect_uri = "https://example.com/callback"
self.code_challenge = "test_challenge"
self.code_challenge_method = "S256"
self.nonce = "test_nonce"
return MockRequestWithClient(oauth_request_authenticated)
# Base Validator Tests
def test_get_additional_claims_basic(oauth_request_authenticated):
"""Test basic additional claims without scopes."""
validator = BaseValidator()
claims = validator.get_additional_claims(oauth_request_authenticated)
assert claims["sub"] == str(oauth_request_authenticated.user.pk)
assert claims["amr"] == "pwd"
assert "email" not in claims
def test_get_additional_claims_with_email_scope(oauth_request_authenticated):
"""Test additional claims with email scope."""
validator = BaseValidator()
oauth_request_authenticated.scopes = {"email"}
claims = validator.get_additional_claims(oauth_request_authenticated)
assert claims["email"] == oauth_request_authenticated.user.get_email()
def test_validate_silent_authorization_authenticated(oauth_request_authenticated):
"""Test silent authorization with authenticated user."""
validator = BaseValidator()
assert validator.validate_silent_authorization(oauth_request_authenticated) is True
def test_validate_silent_authorization_unauthenticated(oauth_request_anonymous):
"""Test silent authorization with unauthenticated user."""
validator = BaseValidator()
assert validator.validate_silent_authorization(oauth_request_anonymous) is False
def test_validate_silent_login_authenticated(oauth_request_authenticated):
"""Test silent login with authenticated user."""
validator = BaseValidator()
assert validator.validate_silent_login(oauth_request_authenticated) is True
def test_validate_silent_login_unauthenticated(oauth_request_anonymous):
"""Test silent login with unauthenticated user."""
validator = BaseValidator()
assert validator.validate_silent_login(oauth_request_anonymous) is False
def test_introspect_token_not_implemented():
"""Test that introspect_token raises RuntimeError."""
validator = BaseValidator()
with pytest.raises(RuntimeError, match="Introspection not implemented"):
validator.introspect_token(None, None, None)
# ProConnect Validator Tests
def test_proconnect_get_additional_claims_given_name(oauth_request_authenticated):
"""Test getting given_name claim."""
validator = ProConnectValidator()
oauth_request_authenticated.scopes = {"given_name"}
claims = validator.get_additional_claims(oauth_request_authenticated)
assert claims["given_name"] == "John"
def test_proconnect_get_additional_claims_usual_name(oauth_request_authenticated):
"""Test getting usual_name claim."""
validator = ProConnectValidator()
oauth_request_authenticated.scopes = {"usual_name"}
claims = validator.get_additional_claims(oauth_request_authenticated)
assert claims["usual_name"] == "Doe"
def test_proconnect_get_additional_claims_siret(oauth_request_authenticated):
"""Test getting siret claim."""
validator = ProConnectValidator()
oauth_request_authenticated.scopes = {"siret"}
claims = validator.get_additional_claims(oauth_request_authenticated)
assert (
claims["siret"]
== oauth_request_authenticated.user.domain.organization.registration_id_list[0]
)
def test_proconnect_get_additional_claims_with_acr_claim(oauth_request_authenticated):
"""Test getting acr claim when eidas1 is requested."""
validator = ProConnectValidator()
oauth_request_authenticated.claims = {"acr": "eidas1"}
claims = validator.get_additional_claims(oauth_request_authenticated)
assert claims["acr"] == "eidas1"
def test_proconnect_get_additional_claims_without_acr_claim(
oauth_request_authenticated,
):
"""Test no acr claim when not requested."""
validator = ProConnectValidator()
claims = validator.get_additional_claims(oauth_request_authenticated)
assert "acr" not in claims
def test_proconnect_create_authorization_code_with_eidas1(oauth_request_for_auth_code):
"""Test creating authorization code with eidas1 acr value."""
validator = ProConnectValidator()
oauth_request_for_auth_code.acr_values = "eidas1"
code = {"code": "test_code"} # OAuth2 provider expects a dict with 'code' key
validator._create_authorization_code(oauth_request_for_auth_code, code) # pylint: disable=protected-access
assert oauth_request_for_auth_code.claims == {"acr": "eidas1"}
def test_proconnect_create_authorization_code_without_eidas1(
oauth_request_for_auth_code,
):
"""Test creating authorization code without eidas1 acr value."""
validator = ProConnectValidator()
oauth_request_for_auth_code.acr_values = "other_value"
code = {"code": "test_code"} # OAuth2 provider expects a dict with 'code' key
validator._create_authorization_code(oauth_request_for_auth_code, code) # pylint: disable=protected-access
assert not oauth_request_for_auth_code.claims
@@ -1,83 +0,0 @@
"""Tests for the mailbox_oauth2.views module."""
import datetime
from django.utils import timezone
import pytest
from core import factories as core_factories
from mailbox_manager import factories
pytestmark = pytest.mark.django_db
def test_login_view_options(client):
"""Test the OPTIONS method on the login view."""
response = client.options("/api/v1.0/login/")
assert response.status_code == 200
assert response.headers == {
"Content-Type": "application/json",
"Vary": "Accept, Authorization, origin, Accept-Language, Cookie",
"Allow": "POST, OPTIONS",
"Content-Length": "209",
"X-Frame-Options": "DENY",
"Content-Language": "en-us",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "same-origin",
"Cross-Origin-Opener-Policy": "same-origin",
}
def test_login_view_authorize(client):
"""Test the login view with valid data."""
organization = core_factories.OrganizationFactory(with_registration_id=True)
domain = factories.MailDomainEnabledFactory(
organization=organization, name="example.com"
)
factories.MailboxEnabledFactory(domain=domain, local_part="user")
response = client.post(
"/api/v1.0/login/", {"email": "user@example.com", "password": "password"}
)
assert response.status_code == 200
# assert the user has a session
assert not client.session.is_empty()
assert client.session.get_expiry_date() < timezone.now() + datetime.timedelta(
minutes=1
)
assert response.headers == {
"Content-Type": "application/json",
"Vary": "Accept, Authorization, Cookie, origin, Accept-Language",
"Allow": "POST, OPTIONS",
"Content-Length": "36",
"X-Frame-Options": "DENY",
"Content-Language": "en-us",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "same-origin",
"Cross-Origin-Opener-Policy": "same-origin",
}
@pytest.mark.parametrize(
"email, password, status_code, response_json",
[
("", "password", 400, {"email": ["This field may not be blank."]}),
(
"email@test.com",
"",
400,
{"password": ["This field may not be blank."]},
),
],
)
def test_login_view_invalid_data(client, email, password, status_code, response_json):
"""Test the login view with invalid data."""
response = client.post("/api/v1.0/login/", {"email": email, "password": password})
assert response.status_code == status_code
assert response.json() == response_json
-9
View File
@@ -1,9 +0,0 @@
"""URLs for the mailbox_oauth2 app."""
from django.urls import path
from .views import LoginView
urlpatterns = [
path("login/", LoginView.as_view(), name="api_login"),
]
-180
View File
@@ -1,180 +0,0 @@
"""
Module for OIDC authentication.
Contains all related code for OIDC authentication using
people as an Identity Provider.
"""
from oauth2_provider.oauth2_validators import OAuth2Validator
class BaseValidator(OAuth2Validator):
"""This validator adds additional claims to the token based on the requested scopes."""
def get_additional_claims(self, request):
"""
Generate additional claims to be included in the token.
Warning, here the request.user is a Mailbox object.
Args:
request: The OAuth2 request object containing user and scope information.
Returns:
dict: A dictionary of additional claims to be included in the token.
"""
additional_claims = super().get_additional_claims(request)
# Enforce the use of the sub instead of the user pk as sub
additional_claims["sub"] = str(request.user.pk)
# Authentication method reference
additional_claims["amr"] = "pwd"
# Include the user's email if 'email' scope is requested
if "email" in request.scopes:
additional_claims["email"] = request.user.get_email()
return additional_claims
def introspect_token(self, token, token_type_hint, request, *args, **kwargs):
"""Introspect an access or refresh token.
Called once the introspect request is validated. This method should
verify the *token* and either return a dictionary with the list of
claims associated, or `None` in case the token is unknown.
Below the list of registered claims you should be interested in:
- scope : space-separated list of scopes
- client_id : client identifier
- username : human-readable identifier for the resource owner
- token_type : type of the token
- exp : integer timestamp indicating when this token will expire
- iat : integer timestamp indicating when this token was issued
- nbf : integer timestamp indicating when it can be "not-before" used
- sub : subject of the token - identifier of the resource owner
- aud : list of string identifiers representing the intended audience
- iss : string representing issuer of this token
- jti : string identifier for the token
Note that most of them are coming directly from JWT RFC. More details
can be found in `Introspect Claims`_ or `JWT Claims`_.
The implementation can use *token_type_hint* to improve lookup
efficiency, but must fallback to other types to be compliant with RFC.
The dict of claims is added to request.token after this method.
"""
raise RuntimeError("Introspection not implemented")
def validate_silent_authorization(self, request):
"""Ensure the logged in user has authorized silent OpenID authorization.
Silent OpenID authorization allows access tokens and id tokens to be
granted to clients without any user prompt or interaction.
:param request: OAuthlib request.
:type request: oauthlib.common.Request
:rtype: True or False
Method is used by:
- OpenIDConnectAuthCode
- OpenIDConnectImplicit
- OpenIDConnectHybrid
"""
return request.user.is_authenticated
def validate_silent_login(self, request):
"""Ensure session user has authorized silent OpenID login.
If no user is logged in or has not authorized silent login, this
method should return False.
If the user is logged in but associated with multiple accounts and
not selected which one to link to the token then this method should
raise an oauthlib.oauth2.AccountSelectionRequired error.
:param request: OAuthlib request.
:type request: oauthlib.common.Request
:rtype: True or False
Method is used by:
- OpenIDConnectAuthCode
- OpenIDConnectImplicit
- OpenIDConnectHybrid
"""
return request.user.is_authenticated
class ProConnectValidator(BaseValidator):
"""
This validator adds additional claims to be compatible with
the french ProConnect API, but not only.
"""
oidc_claim_scope = OAuth2Validator.oidc_claim_scope | {
"given_name": "given_name",
"usual_name": "usual_name",
"siret": "profile",
}
def get_additional_claims(self, request):
"""
Generate additional claims to be included in the token.
Args:
request: The OAuth2 request object containing user and scope information.
Returns:
dict: A dictionary of additional claims to be included in the token.
"""
additional_claims = super().get_additional_claims(request)
# Include the user's name if 'profile' scope is requested
if "given_name" in request.scopes:
additional_claims["given_name"] = request.user.first_name
if "usual_name" in request.scopes:
additional_claims["usual_name"] = request.user.last_name
if "siret" in request.scopes:
# The following line will fail on purpose if we don't have the proper information
additional_claims["siret"] = (
request.user.domain.organization.registration_id_list[0]
)
# Include 'acr' claim if it is present in the request claims and equals 'eidas1'
# see _create_authorization_code method for more details
if request.claims and request.claims.get("acr") == "eidas1":
additional_claims["acr"] = "eidas1"
return additional_claims
def _create_authorization_code(self, request, code, expires=None):
"""
Create an authorization code and handle 'acr_values' in the request.
Args:
request: The OAuth2 request object containing user and scope information.
code: The authorization code to be created.
expires: The expiration time of the authorization code.
Returns:
The created authorization code.
"""
# Split and strip 'acr_values' from the request, if present
acr_values = (
[value.strip() for value in request.acr_values.split(",")]
if request.acr_values
else []
)
# If 'eidas1' is in 'acr_values', add 'acr' claim to the request claims
# This allows the token to have this information and pass it to the /token
# endpoint and return it in the token response
if "eidas1" in acr_values:
request.claims = request.claims or {}
request.claims["acr"] = "eidas1"
# Call the superclass method to create the authorization code
return super()._create_authorization_code(request, code, expires)
-43
View File
@@ -1,43 +0,0 @@
"""Views for handling OAuth2 authentication via API."""
import datetime
from django.contrib.auth import login
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.status import HTTP_401_UNAUTHORIZED
from rest_framework.views import APIView
from .serializers import LoginSerializer
class LoginView(APIView):
"""Login view to allow users to authenticate and create a session from the frontend."""
permission_classes = [AllowAny]
def post(self, request):
"""
Authenticate user and create session.
"""
serializer = LoginSerializer(data=request.data, context={"request": request})
serializer.is_valid(raise_exception=True)
# User is None if the credentials are invalid
user = serializer.validated_data["user"]
if user is not None:
login(request, user)
# In this context we need a session long enough to allow the user to
# authenticate to make the OIDC loop. A minute should be enough.
# Even if the session is longer, the one_time_email_authenticated_session
# middleware will kill the session as soon as the user tries to access
# paths outside the OIDC process or when the OIDC process is done here.
request.session.set_expiry(datetime.timedelta(minutes=1))
return Response({"message": "Successfully logged in"})
return Response(
{"error": "Invalid credentials"},
status=HTTP_401_UNAUTHORIZED,
)
-3
View File
@@ -9,8 +9,6 @@ from core.api.client import viewsets
from core.authentication.urls import urlpatterns as oidc_urls
from core.resource_server.urls import urlpatterns as resource_server_urls
from mailbox_oauth2.urls import urlpatterns as mailbox_oauth2_urls
# - Main endpoints
router = DefaultRouter()
router.register("contacts", viewsets.ContactViewSet, basename="contacts")
@@ -42,7 +40,6 @@ urlpatterns = [
include(
[
*router.urls,
*mailbox_oauth2_urls,
*oidc_urls,
*resource_server_urls,
re_path(
+45 -160
View File
@@ -17,8 +17,6 @@ from django.utils.translation import gettext_lazy as _
import sentry_sdk
from configurations import Configuration, values
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.logging import ignore_logger
@@ -78,10 +76,7 @@ class Base(Configuration):
"""
DEBUG = False
USE_SWAGGER = values.BooleanValue(
default=False,
environ_name="USE_SWAGGER",
)
USE_SWAGGER = False
API_VERSION = "v1.0"
@@ -198,15 +193,12 @@ class Base(Configuration):
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"mailbox_oauth2.middleware.one_time_email_authenticated_session",
"oauth2_provider.middleware.OAuth2TokenMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
]
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"mailbox_oauth2.backends.MailboxModelBackend",
"core.authentication.backends.OIDCAuthenticationBackend",
]
@@ -217,17 +209,14 @@ class Base(Configuration):
"core",
"demo",
"mailbox_manager",
"mailbox_oauth2",
"drf_spectacular",
"drf_spectacular_sidecar", # required for Django collectstatic discovery
# Third party apps
"corsheaders",
"dockerflow.django",
"easy_thumbnails",
"oauth2_provider",
"parler",
"rest_framework",
"parler",
"treebeard",
"easy_thumbnails",
# Django
"django.contrib.auth",
"django.contrib.contenttypes",
@@ -334,42 +323,6 @@ class Base(Configuration):
SESSION_CACHE_ALIAS = "default"
SESSION_COOKIE_AGE = 60 * 60 * 12 # 12 hours to match Agent Connect
# Python loggers configuration (and env var overrides)
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {
"format": "{asctime} {name} {levelname} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
},
},
# Override root logger to send it to console
"root": {
"handlers": ["console"],
"level": values.Value(
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
),
},
"loggers": {
"core": {
"handlers": ["console"],
"level": values.Value(
"INFO",
environ_name="LOGGING_LEVEL_LOGGERS_APP",
environ_prefix=None,
),
"propagate": False,
},
},
}
# OIDC - Authorization Code Flow
OIDC_CREATE_USER = values.BooleanValue(
default=True,
@@ -512,21 +465,6 @@ class Base(Configuration):
environ_name="MAIL_PROVISIONING_API_CREDENTIALS",
environ_prefix=None,
)
DNS_PROVISIONING_API_URL = values.Value(
default="https://api.scaleway.com",
environ_name="DNS_PROVISIONING_API_URL",
environ_prefix=None,
)
DNS_PROVISIONING_RESOURCE_ID = values.Value(
default=None,
environ_name="DNS_PROVISIONING_RESOURCE_ID",
environ_prefix=None,
)
DNS_PROVISIONING_API_CREDENTIALS = values.Value(
default=None,
environ_name="DNS_PROVISIONING_API_CREDENTIALS",
environ_prefix=None,
)
# Organizations
ORGANIZATION_REGISTRATION_ID_VALIDATORS = json.loads(
@@ -542,26 +480,6 @@ class Base(Configuration):
environ_prefix=None,
)
OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application"
OAUTH2_PROVIDER_GRANT_MODEL = "mailbox_oauth2.Grant"
OAUTH2_PROVIDER_ID_TOKEN_MODEL = "mailbox_oauth2.IDToken" # noqa: S105
OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "mailbox_oauth2.AccessToken" # noqa: S105
OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "mailbox_oauth2.RefreshToken" # noqa: S105
# Security settings for login attempts
# - Maximum number of failed login attempts before lockout
MAX_LOGIN_ATTEMPTS = values.IntegerValue(
default=5,
environ_name="MAX_LOGIN_ATTEMPTS",
environ_prefix=None,
)
# - Lockout time in seconds (default to 5 minutes)
ACCOUNT_LOCKOUT_TIME = values.IntegerValue(
default=5 * 60,
environ_name="ACCOUNT_LOCKOUT_TIME",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -621,46 +539,6 @@ class Base(Configuration):
},
}
@property
def OAUTH2_PROVIDER(self) -> dict:
"""OAuth2 Provider settings."""
OIDC_ENABLED = values.BooleanValue(
default=False,
environ_name="OAUTH2_PROVIDER_OIDC_ENABLED",
environ_prefix=None,
)
OIDC_RSA_PRIVATE_KEY = values.Value(
environ_name="OAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY",
environ_prefix=None,
)
OAUTH2_VALIDATOR_CLASS = values.Value(
default="mailbox_oauth2.validators.BaseValidator",
environ_name="OAUTH2_PROVIDER_VALIDATOR_CLASS",
environ_prefix=None,
)
SCOPES = {
"openid": "OpenID Connect scope",
"email": "Email address",
}
if OAUTH2_VALIDATOR_CLASS == "mailbox_oauth2.validators.ProConnectValidator":
SCOPES["given_name"] = "First name"
SCOPES["usual_name"] = "Last name"
SCOPES["siret"] = "SIRET number"
return {
"OIDC_ENABLED": OIDC_ENABLED,
"OIDC_RSA_PRIVATE_KEY": OIDC_RSA_PRIVATE_KEY,
"SCOPES": SCOPES,
"OAUTH2_VALIDATOR_CLASS": OAUTH2_VALIDATOR_CLASS,
}
@property
def LOGIN_URL(self):
"""
Define the LOGIN_URL (Django) for the OIDC provider (reuse LOGIN_REDIRECT_URL)
"""
return f"{self.LOGIN_REDIRECT_URL}/login/"
@classmethod
def post_setup(cls):
"""Post setup configuration.
@@ -686,24 +564,6 @@ class Base(Configuration):
# Ignore the logs added by the DockerflowMiddleware
ignore_logger("request.summary")
@classmethod
def generate_temporary_rsa_key(cls):
"""Generate a temporary RSA key for OIDC Provider."""
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=4096,
)
# - Serialize private key to PEM format
private_key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
return private_key_pem.decode("utf-8")
class Build(Base):
"""Settings used when the application is built.
@@ -740,23 +600,54 @@ class Development(Base):
SESSION_COOKIE_NAME = "people_sessionid"
USE_SWAGGER = True
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {
"format": "{asctime} {name} {levelname} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
},
},
# Override root logger to send it to console
"root": {
"handlers": ["console"],
"level": values.Value(
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
),
},
"loggers": {
"core": {
"handlers": ["console"],
"level": values.Value(
"INFO",
environ_name="LOGGING_LEVEL_LOGGERS_APP",
environ_prefix=None,
),
"propagate": False,
},
},
}
# this is a dev credentials for mail provisioning API
MAIL_PROVISIONING_API_CREDENTIALS = "bGFfcmVnaWU6cGFzc3dvcmQ="
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "siret"
ORGANIZATION_PLUGINS = ["plugins.organizations.NameFromSiretOrganizationPlugin"]
def __init__(self):
"""In dev, force installs needed for Swagger API."""
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["django_extensions"]
@property
def OAUTH2_PROVIDER(self):
"""OAuth2 Provider settings."""
OAUTH2_PROVIDER = super().OAUTH2_PROVIDER # pylint: disable=invalid-name
if not OAUTH2_PROVIDER["OIDC_RSA_PRIVATE_KEY"]:
OAUTH2_PROVIDER["OIDC_RSA_PRIVATE_KEY"] = Base.generate_temporary_rsa_key()
return OAUTH2_PROVIDER
self.INSTALLED_APPS += ["django_extensions", "drf_spectacular_sidecar"]
class Test(Base):
@@ -800,6 +691,8 @@ class Test(Base):
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "siret"
ORGANIZATION_PLUGINS = ["plugins.organizations.NameFromSiretOrganizationPlugin"]
ORGANIZATION_REGISTRATION_ID_VALIDATORS = [
{
"NAME": "django.core.validators.RegexValidator",
@@ -921,14 +814,6 @@ class Local(Production):
nota bene: it should inherit from the Production environment.
"""
@property
def OAUTH2_PROVIDER(self):
"""OAuth2 Provider settings."""
OAUTH2_PROVIDER = super().OAUTH2_PROVIDER # pylint: disable=invalid-name
if not OAUTH2_PROVIDER["OIDC_RSA_PRIVATE_KEY"]:
OAUTH2_PROVIDER["OIDC_RSA_PRIVATE_KEY"] = Base.generate_temporary_rsa_key()
return OAUTH2_PROVIDER
class Staging(Production):
"""
+1 -3
View File
@@ -4,14 +4,13 @@ from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import include, path, re_path
from django.urls import path, re_path
from drf_spectacular.views import (
SpectacularJSONAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
)
from oauth2_provider import urls as oauth2_urls
from debug import urls as debug_urls
@@ -22,7 +21,6 @@ API_VERSION = settings.API_VERSION
urlpatterns = (
[
path("admin/", admin.site.urls),
path("o/", include(oauth2_urls)),
]
+ api_urls.urlpatterns
+ resource_server_urls.urlpatterns
+6 -238
View File
@@ -1,19 +1,12 @@
"""Organization related plugins."""
import logging
import re
from django.conf import settings
from django.utils.text import slugify
import requests
from requests.adapters import HTTPAdapter, Retry
from core.plugins.base import BaseOrganizationPlugin
from mailbox_manager.enums import MailDomainRoleChoices
from mailbox_manager.models import MailDomain, MailDomainAccess
logger = logging.getLogger(__name__)
@@ -34,8 +27,14 @@ class NameFromSiretOrganizationPlugin(BaseOrganizationPlugin):
def get_organization_name_from_results(data, siret):
"""Return the organization name from the results of a SIRET search."""
for result in data["results"]:
is_commune = (
result.get("nature_juridique") == "7210"
) # INSEE code for commune
for organization in result["matching_etablissements"]:
if organization.get("siret") == siret:
if is_commune:
return organization["libelle_commune"].title()
store_signs = organization.get("liste_enseignes") or []
if store_signs:
return store_signs[0].title()
@@ -77,234 +76,3 @@ class NameFromSiretOrganizationPlugin(BaseOrganizationPlugin):
organization.name = name
organization.save(update_fields=["name", "updated_at"])
logger.info("Organization %s name updated to %s", organization, name)
def run_after_grant_access(self, organization_access):
"""After granting an organization access, we don't need to do anything."""
class ApiCall:
"""Encapsulates a call to an external API"""
inputs: dict = {}
method: str = "GET"
base: str = ""
url: str = ""
params: dict = {}
headers: dict = {}
response_data = None
def execute(self):
"""Call the specified API endpoint with supplied parameters and record response"""
if self.method in ("POST", "PATCH"):
response = requests.request(
method=self.method,
url=f"{self.base}/{self.url}",
json=self.params,
headers=self.headers,
timeout=20,
)
else:
response = requests.request(
method=self.method,
url=f"{self.base}/{self.url}",
params=self.params,
headers=self.headers,
timeout=20,
)
self.response_data = response.json()
logger.info(
"API call: %s %s %s %s",
self.method,
self.url,
self.params,
self.response_data,
)
class CommuneCreation(BaseOrganizationPlugin):
"""
This plugin handles setup tasks for French communes.
"""
_api_url = "https://recherche-entreprises.api.gouv.fr/search?q={siret}"
def get_organization_name_from_results(self, data, siret):
"""Return the organization name from the results of a SIRET search."""
for result in data["results"]:
nature = "nature_juridique"
commune = nature in result and result[nature] == "7210"
if commune:
return result["siege"]["libelle_commune"].title()
logger.warning("Not a commune: SIRET %s", siret)
return None
def dns_call(self, spec):
"""Call to add a DNS record"""
zone_name = self.zone_name(spec.inputs["name"])
records = [
{
"name": item["target"],
"type": item["type"].upper(),
"data": item["value"],
"ttl": 3600,
}
for item in spec.response_data
]
result = ApiCall()
result.method = "PATCH"
result.base = "https://api.scaleway.com"
result.url = f"/domain/v2beta1/dns-zones/{zone_name}/records"
result.params = {"changes": [{"add": {"records": records}}]}
result.headers = {"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS}
return result
def normalize_name(self, name: str) -> str:
"""Map the name to a standard form"""
name = re.sub("'", "-", name)
return slugify(name)
def zone_name(self, name: str) -> str:
"""Derive the zone name from the commune name"""
normalized = self.normalize_name(name)
return f"{normalized}.collectivite.fr"
def complete_commune_creation(self, name: str) -> ApiCall:
"""Specify the tasks to be completed after a commune is created."""
inputs = {"name": self.normalize_name(name)}
create_zone = ApiCall()
create_zone.method = "POST"
create_zone.base = "https://api.scaleway.com"
create_zone.url = "/domain/v2beta1/dns-zones"
create_zone.params = {
"project_id": settings.DNS_PROVISIONING_RESOURCE_ID,
"domain": "collectivite.fr",
"subdomain": inputs["name"],
}
create_zone.headers = {
"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS
}
zone_name = self.zone_name(inputs["name"])
create_domain = ApiCall()
create_domain.method = "POST"
create_domain.base = settings.MAIL_PROVISIONING_API_URL
create_domain.url = "/domains/"
create_domain.params = {
"name": zone_name,
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"context_name": zone_name,
}
create_domain.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
spec_domain = ApiCall()
spec_domain.inputs = inputs
spec_domain.base = settings.MAIL_PROVISIONING_API_URL
spec_domain.url = f"/domains/{zone_name}/spec"
spec_domain.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
return [create_zone, create_domain, spec_domain]
def complete_zone_creation(self, spec_call):
"""Specify the tasks to be performed to set up the zone."""
return self.dns_call(spec_call)
def run_after_create(self, organization):
"""After creating an organization, update the organization name."""
logger.info("In CommuneCreation")
if not organization.registration_id_list:
# No registration ID to convert...
return
# In the nominal case, there is only one registration ID because
# the organization has been created from it.
try:
# Retry logic as the API may be rate limited
s = requests.Session()
retries = Retry(total=5, backoff_factor=0.1, status_forcelist=[429])
s.mount("https://", HTTPAdapter(max_retries=retries))
siret = organization.registration_id_list[0]
response = s.get(self._api_url.format(siret=siret), timeout=10)
response.raise_for_status()
data = response.json()
name = self.get_organization_name_from_results(data, siret)
# Not a commune ?
if not name:
return
except requests.RequestException as exc:
logger.exception("%s: Unable to fetch organization name from SIRET", exc)
return
organization.name = name
organization.save(update_fields=["name", "updated_at"])
logger.info("Organization %s name updated to %s", organization, name)
zone_name = self.zone_name(name)
support = "support-regie@numerique.gouv.fr"
MailDomain.objects.get_or_create(name=zone_name, support_email=support)
# Compute and execute the rest of the process
tasks = self.complete_commune_creation(name)
for task in tasks:
task.execute()
last_task = self.complete_zone_creation(tasks[-1])
last_task.execute()
def complete_grant_access(self, sub, zone_name):
"""Specify the tasks to be completed after making a user admin"""
create_user = ApiCall()
create_user.method = "POST"
create_user.base = settings.MAIL_PROVISIONING_API_URL
create_user.url = "/users/"
create_user.params = {
"name": sub,
"password": "no",
"is_admin": False,
"perms": [],
}
create_user.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
grant_access = ApiCall()
grant_access.method = "POST"
grant_access.base = settings.MAIL_PROVISIONING_API_URL
grant_access.url = "/allows/"
grant_access.params = {
"user": sub,
"domain": zone_name,
}
grant_access.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
return [create_user, grant_access]
def run_after_grant_access(self, organization_access):
"""After granting an organization access, check for needed domain access grant."""
orga = organization_access.organization
user = organization_access.user
zone_name = self.zone_name(orga.name)
try:
domain = MailDomain.objects.get(name=zone_name)
except MailDomain.DoesNotExist:
domain = None
if domain:
MailDomainAccess.objects.create(
domain=domain, user=user, role=MailDomainRoleChoices.OWNER
)
tasks = self.complete_grant_access(user.sub, zone_name)
for task in tasks:
task.execute()
@@ -1,224 +0,0 @@
"""Tests for the CommuneCreation plugin."""
from django.conf import settings
import pytest
import responses
from plugins.organizations import ApiCall, CommuneCreation
pytestmark = pytest.mark.django_db
def test_extract_name_from_org_data_when_commune():
"""Test the name is extracted correctly for a French commune."""
data = {
"results": [
{
"nom_complet": "COMMUNE DE VARZY",
"nom_raison_sociale": "COMMUNE DE VARZY",
"siege": {
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
"siret": "21580304000017",
},
"nature_juridique": "7210",
"matching_etablissements": [
{
"siret": "21580304000017",
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
}
],
}
]
}
plugin = CommuneCreation()
name = plugin.get_organization_name_from_results(data, "21580304000017")
assert name == "Varzy"
def test_api_call_execution():
"""Test that calling execute() faithfully executes the API call"""
task = ApiCall()
task.method = "POST"
task.base = "https://some_host"
task.url = "some_url"
task.params = {"some_key": "some_value"}
task.headers = {"Some-Header": "Some-Header-Value"}
with responses.RequestsMock() as rsps:
rsps.add(
rsps.POST,
url="https://some_host/some_url",
body='{"some_key": "some_value"}',
content_type="application/json",
headers={"Some-Header": "Some-Header-Value"},
)
task.execute()
def test_tasks_on_commune_creation_include_zone_creation():
"""Test the first task in commune creation: creating the DNS sub-zone"""
plugin = CommuneCreation()
name = "Varzy"
tasks = plugin.complete_commune_creation(name)
assert tasks[0].base == "https://api.scaleway.com"
assert tasks[0].url == "/domain/v2beta1/dns-zones"
assert tasks[0].method == "POST"
assert tasks[0].params == {
"project_id": settings.DNS_PROVISIONING_RESOURCE_ID,
"domain": "collectivite.fr",
"subdomain": "varzy",
}
assert tasks[0].headers["X-Auth-Token"] == settings.DNS_PROVISIONING_API_CREDENTIALS
def test_tasks_on_commune_creation_include_dimail_domain_creation():
"""Test the second task in commune creation: creating the domain in Dimail"""
plugin = CommuneCreation()
name = "Merlaut"
tasks = plugin.complete_commune_creation(name)
assert tasks[1].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[1].url == "/domains/"
assert tasks[1].method == "POST"
assert tasks[1].params == {
"name": "merlaut.collectivite.fr",
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"context_name": "merlaut.collectivite.fr",
}
assert (
tasks[1].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
def test_tasks_on_commune_creation_include_fetching_spec():
"""Test the third task in commune creation: asking Dimail for the spec"""
plugin = CommuneCreation()
name = "Loc-Eguiner"
tasks = plugin.complete_commune_creation(name)
assert tasks[2].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[2].url == "/domains/loc-eguiner.collectivite.fr/spec"
assert tasks[2].method == "GET"
assert (
tasks[2].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
def test_tasks_on_commune_creation_include_dns_records():
"""Test the next several tasks in commune creation: creating records"""
plugin = CommuneCreation()
name = "Abidos"
spec_response = [
{"target": "", "type": "mx", "value": "mx.dev.ox.numerique.gouv.fr."},
{
"target": "dimail._domainkey",
"type": "txt",
"value": "v=DKIM1; h=sha256; k=rsa; p=MIICIjANB<truncated>AAQ==",
},
{"target": "imap", "type": "cname", "value": "imap.dev.ox.numerique.gouv.fr."},
{"target": "smtp", "type": "cname", "value": "smtp.dev.ox.numerique.gouv.fr."},
{
"target": "",
"type": "txt",
"value": "v=spf1 include:_spf.dev.ox.numerique.gouv.fr -all",
},
{
"target": "webmail",
"type": "cname",
"value": "webmail.dev.ox.numerique.gouv.fr.",
},
]
tasks = plugin.complete_commune_creation(name)
tasks[2].response_data = spec_response
expected = {
"changes": [
{
"add": {
"records": [
{
"name": item["target"],
"type": item["type"].upper(),
"data": item["value"],
"ttl": 3600,
}
for item in spec_response
]
}
}
]
}
zone_call = plugin.complete_zone_creation(tasks[2])
assert zone_call.params == expected
assert zone_call.url == "/domain/v2beta1/dns-zones/abidos.collectivite.fr/records"
assert (
zone_call.headers["X-Auth-Token"] == settings.DNS_PROVISIONING_API_CREDENTIALS
)
def test_tasks_on_grant_access():
"""Test the final tasks after making user admin of an org"""
plugin = CommuneCreation()
tasks = plugin.complete_grant_access("some-sub", "mezos.collectivite.fr")
assert tasks[0].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[0].url == "/users/"
assert tasks[0].method == "POST"
assert tasks[0].params == {
"name": "some-sub",
"password": "no",
"is_admin": False,
"perms": [],
}
assert (
tasks[0].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
assert tasks[1].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[1].url == "/allows/"
assert tasks[1].method == "POST"
assert tasks[1].params == {
"user": "some-sub",
"domain": "mezos.collectivite.fr",
}
assert (
tasks[1].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
def test_normalize_name():
"""Test name normalization"""
plugin = CommuneCreation()
assert plugin.normalize_name("Asnières-sur-Saône") == "asnieres-sur-saone"
assert plugin.normalize_name("Bâgé-le-Châtel") == "bage-le-chatel"
assert plugin.normalize_name("Courçais") == "courcais"
assert plugin.normalize_name("Moÿ-de-l'Aisne") == "moy-de-l-aisne"
assert plugin.normalize_name("Salouël") == "salouel"
assert (
plugin.normalize_name("Bors (Canton de Tude-et-Lavalette)")
== "bors-canton-de-tude-et-lavalette"
)
def test_zone_name():
"""Test transforming a commune name to a sub-zone of collectivite.fr"""
plugin = CommuneCreation()
assert plugin.zone_name("Bâgé-le-Châtel") == "bage-le-chatel.collectivite.fr"
@@ -6,6 +6,8 @@ import responses
from core.models import Organization
from core.plugins.loader import get_organization_plugins
from plugins.organizations import NameFromSiretOrganizationPlugin
pytestmark = pytest.mark.django_db
@@ -137,6 +139,37 @@ def test_organization_plugins_run_after_create_name_already_set(
assert organization.name == "Magic WOW"
def test_extract_name_from_org_data_when_commune(
organization_plugins_settings,
):
"""Test the name is extracted correctly for a French commune."""
data = {
"results": [
{
"nom_complet": "COMMUNE DE VARZY",
"nom_raison_sociale": "COMMUNE DE VARZY",
"siege": {
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
"siret": "21580304000017",
},
"nature_juridique": "7210",
"matching_etablissements": [
{
"siret": "21580304000017",
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
}
],
}
]
}
plugin = NameFromSiretOrganizationPlugin()
name = plugin.get_organization_name_from_results(data, "21580304000017")
assert name == "Varzy"
@responses.activate
def test_organization_plugins_run_after_create_no_list_enseignes(
organization_plugins_settings,
+27 -35
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "people"
version = "1.13.1"
version = "1.10.1"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,70 +17,68 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.10",
]
description = "An application to handle contacts and teams."
keywords = ["Django", "Contacts", "Teams", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.11"
requires-python = ">=3.10"
dependencies = [
"boto3==1.36.11",
"Brotli==1.1.0",
"PyJWT==2.10.1",
"boto3==1.37.4",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-cors-headers==4.6.0",
"django-countries==7.6.1",
"django-oauth-toolkit==3.0.1",
"django-parler==2.3",
"django-redis==5.4.0",
"django-storages==1.14.5",
"django-timezone-field>=5.1",
"django-treebeard==4.7.1",
"django==5.1.6",
"redis==5.2.1",
"django-redis==5.4.0",
"django-storages==1.14.4",
"django-timezone-field>=5.1",
"django==5.1.5",
"djangorestframework==3.15.2",
"dockerflow==2024.4.2",
"drf_spectacular==0.28.0",
"drf_spectacular[sidecar]==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"joserfc==1.0.4",
"jsonschema==4.23.0",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.5",
"redis==5.2.1",
"psycopg[binary]==3.2.4",
"PyJWT==2.10.1",
"joserfc==1.0.2",
"requests==2.32.3",
"sentry-sdk[django]==2.22.0",
"whitenoise==6.9.0",
"sentry-sdk[django]==2.20.0",
"whitenoise==6.8.2",
"mozilla-django-oidc==4.0.1",
]
[project.urls]
"Bug Tracker" = "https://github.com/suitenumerique/people/issues/new"
"Changelog" = "https://github.com/suitenumerique/people/blob/main/CHANGELOG.md"
"Homepage" = "https://github.com/suitenumerique/people"
"Repository" = "https://github.com/suitenumerique/people"
"Bug Tracker" = "https://github.com/numerique-gouv/people/issues/new"
"Changelog" = "https://github.com/numerique-gouv/people/blob/main/CHANGELOG.md"
"Homepage" = "https://github.com/numerique-gouv/people"
"Repository" = "https://github.com/numerique-gouv/people"
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2025.3.1",
"drf-spectacular-sidecar==2025.2.1",
"ipdb==0.13.13",
"ipython==9.0.1",
"ipython==8.32.0",
"jq==1.8.0",
"pyfakefs==5.7.4",
"pylint-django==2.6.1",
"pylint==3.3.4",
"pytest-cov==6.0.0",
"pytest-django==4.10.0",
"pytest==8.3.5",
"pytest-django==4.9.0",
"pytest==8.3.4",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.6",
"ruff==0.9.9",
"types-requests==2.32.0.20250301",
"ruff==0.9.4",
"types-requests==2.32.0.20241016",
"freezegun==1.5.1",
]
@@ -142,12 +140,6 @@ python_files = [
"test_*.py",
"tests.py",
]
filterwarnings = [
# This one can be removed when upgrading to Django 6.0
'ignore:The FORMS_URLFIELD_ASSUME_HTTPS transitional setting is deprecated.',
# This one can be removed after upgrading DRF to 3.16
"ignore:Converter 'drf_format_suffix'"
]
[tool.coverage.run]
branch = true
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "app-desk",
"version": "1.13.1",
"version": "1.10.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -16,36 +16,36 @@
},
"dependencies": {
"@gouvfr-lasuite/integration": "1.0.2",
"@hookform/resolvers": "4.0.0",
"@hookform/resolvers": "3.10.0",
"@openfun/cunningham-react": "3.0.0",
"@tanstack/react-query": "5.66.11",
"@tanstack/react-query": "5.66.0",
"i18next": "24.2.2",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "15.2.0",
"next": "15.1.6",
"react": "*",
"react-dom": "*",
"react-hook-form": "7.54.2",
"react-i18next": "15.4.1",
"react-i18next": "15.4.0",
"react-select": "5.10.0",
"sass": "1.85.1",
"styled-components": "6.1.15",
"zod": "3.24.2",
"sass": "1.83.4",
"styled-components": "6.1.14",
"zod": "3.24.1",
"zustand": "5.0.3"
},
"devDependencies": {
"@hookform/devtools": "4.3.3",
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.66.11",
"@tanstack/react-query-devtools": "5.66.0",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.2.0",
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.16",
"@types/lodash": "4.17.15",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "19.0.10",
"@types/react": "19.0.8",
"@types/react-dom": "*",
"dotenv": "16.4.7",
"eslint-config-people": "*",
@@ -53,8 +53,8 @@
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.5.3",
"stylelint": "16.15.0",
"prettier": "3.4.2",
"stylelint": "16.14.1",
"stylelint-config-standard": "37.0.0",
"stylelint-prettier": "5.0.3",
"typescript": "*"
@@ -15,7 +15,7 @@ export const Modal: React.FC<ModalProps> = ({ children, ...props }) => {
/**
* @description used to prevent elements to be navigable by keyboard when only a DOM mutation causes the elements to be
* in the document
* @see https://github.com/suitenumerique/people/pull/379
* @see https://github.com/numerique-gouv/people/pull/379
*/
export const usePreventFocusVisible = (elements: string[]) => {
useEffect(() => {
@@ -1,5 +1,4 @@
import { Loader } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
@@ -8,16 +7,12 @@ import { useAuthStore } from './useAuthStore';
export const Auth = ({ children }: PropsWithChildren) => {
const { authenticated, initAuth } = useAuthStore();
const router = useRouter();
const isLoginPage = router.pathname === '/login';
useEffect(() => {
if (!isLoginPage) {
initAuth();
}
}, [initAuth, isLoginPage]);
initAuth();
}, [initAuth]);
if (!authenticated && !isLoginPage) {
if (!authenticated) {
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
<Loader />
@@ -62,7 +62,7 @@ export const LanguagePicker = () => {
/**
* @description prevent select div to receive focus on keyboard navigation so the focus goes directly to inner button
* @see https://github.com/suitenumerique/people/pull/379
* @see https://github.com/numerique-gouv/people/pull/379
*/
useEffect(() => {
if (!document) {
@@ -1,54 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AppWrapper } from '@/tests/utils';
import { InputUserEmail } from '../components/InputUserEmail';
describe('InputUserEmail', () => {
const mockSetEmail = jest.fn();
const defaultProps = {
label: 'Email Address',
email: '',
setEmail: mockSetEmail,
};
beforeEach(() => {
jest.clearAllMocks();
});
const renderInput = (props = defaultProps) => {
render(<InputUserEmail {...props} />, { wrapper: AppWrapper });
};
it('renders the email input with correct label', () => {
renderInput();
expect(screen.getByLabelText('Email Address')).toBeInTheDocument();
});
it('calls setEmail when input value changes', async () => {
renderInput();
const input = screen.getByLabelText('Email Address');
await userEvent.type(input, 'test@example.com');
expect(mockSetEmail).toHaveBeenCalledWith('test@example.com');
});
it('displays the current email value', () => {
renderInput({ ...defaultProps, email: 'test@example.com' });
const input: HTMLInputElement = screen.getByLabelText('Email Address');
expect(input.value).toBe('test@example.com');
});
it('has required attribute', () => {
renderInput();
const input = screen.getByLabelText('Email Address');
expect(input).toHaveAttribute('required');
});
it('has correct type and autocomplete attributes', () => {
renderInput();
const input = screen.getByLabelText('Email Address');
expect(input).toHaveAttribute('type', 'email');
expect(input).toHaveAttribute('autocomplete', 'username');
});
});
@@ -1,47 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AppWrapper } from '@/tests/utils';
import { InputUserPassword } from '../components/InputUserPassword';
describe('InputUserPassword', () => {
const mockSetPassword = jest.fn();
const defaultProps = {
label: 'Password',
setPassword: mockSetPassword,
};
beforeEach(() => {
jest.clearAllMocks();
});
const renderInput = (props = defaultProps) => {
render(<InputUserPassword {...props} />, { wrapper: AppWrapper });
};
it('renders the password input with correct label', () => {
renderInput();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
});
it('calls setPassword when input value changes', async () => {
renderInput();
const input = screen.getByLabelText('Password');
await userEvent.type(input, 'mypassword123');
expect(mockSetPassword).toHaveBeenCalledWith('mypassword123');
});
it('has required attribute', () => {
renderInput();
const input = screen.getByLabelText('Password');
expect(input).toHaveAttribute('required');
});
it('has correct type and autocomplete attributes', () => {
renderInput();
const input = screen.getByLabelText('Password');
expect(input).toHaveAttribute('type', 'password');
expect(input).toHaveAttribute('autocomplete', 'current-password');
});
});
@@ -1,117 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AppWrapper } from '@/tests/utils';
import { LoginForm } from '../components/LoginForm';
jest.mock('next/navigation', () => ({
useRouter: jest.fn(),
}));
jest.mock('next/router', () => ({
useRouter: jest.fn(),
}));
describe('LoginForm', () => {
const mockHandleSubmit = jest.fn((e) => e.preventDefault());
const mockSetEmail = jest.fn();
const mockSetPassword = jest.fn();
const defaultProps = {
title: 'Login',
labelEmail: 'Email',
labelPassword: 'Password',
labelSignIn: 'Sign In',
email: '',
setEmail: mockSetEmail,
setPassword: mockSetPassword,
error: '',
handleSubmit: mockHandleSubmit,
blockingError: '',
};
beforeEach(() => {
jest.clearAllMocks();
});
const renderLoginForm = () => {
render(<LoginForm {...defaultProps} />, { wrapper: AppWrapper });
};
it('should render the login form', async () => {
renderLoginForm();
await waitFor(() => {
expect(screen.getByText('Login')).toBeInTheDocument();
});
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.getByText('Sign In')).toBeInTheDocument();
});
it('should handle email input', async () => {
renderLoginForm();
await waitFor(() => {
expect(screen.getByLabelText('Email')).toBeInTheDocument();
});
const emailInput = screen.getByLabelText('Email');
await userEvent.type(emailInput, 'test@example.com');
expect(mockSetEmail).toHaveBeenCalledWith('test@example.com');
});
it('should handle password input', async () => {
renderLoginForm();
await waitFor(() => {
expect(screen.getByLabelText('Password')).toBeInTheDocument();
});
const passwordInput = screen.getByLabelText('Password');
await userEvent.type(passwordInput, 'password123');
expect(mockSetPassword).toHaveBeenCalledWith('password123');
});
it('should submit the form', async () => {
renderLoginForm();
await waitFor(() => {
expect(screen.getByText('Sign In')).toBeInTheDocument();
});
const form = screen.getByTestId('login-form');
fireEvent.submit(form);
expect(mockHandleSubmit).toHaveBeenCalled();
});
it('should display error message when provided', async () => {
const errorMessage = 'Invalid credentials';
render(<LoginForm {...defaultProps} error={errorMessage} />, {
wrapper: AppWrapper,
});
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
it('should display blocking error and hide form when provided', async () => {
const blockingError = 'Service unavailable';
render(<LoginForm {...defaultProps} blockingError={blockingError} />, {
wrapper: AppWrapper,
});
await waitFor(() => {
expect(screen.getByText(blockingError)).toBeInTheDocument();
});
expect(screen.queryByLabelText('Email')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Password')).not.toBeInTheDocument();
});
});
@@ -1,25 +0,0 @@
import { Input } from '@openfun/cunningham-react';
interface InputUserEmailProps {
label: string;
email: string;
setEmail: (newEmail: string) => void;
}
export const InputUserEmail = ({
label,
email,
setEmail,
}: InputUserEmailProps) => {
return (
<Input
label={label}
type="email"
onChange={(e) => setEmail(e.target.value)}
required
fullWidth
autoComplete="username"
value={email}
/>
);
};
@@ -1,22 +0,0 @@
import { Input } from '@openfun/cunningham-react';
interface InputUserEmailProps {
label: string;
setPassword: (newEmail: string) => void;
}
export const InputUserPassword = ({
label,
setPassword,
}: InputUserEmailProps) => {
return (
<Input
label={label}
type="password"
onChange={(e) => setPassword(e.target.value)}
required
fullWidth
autoComplete="current-password"
/>
);
};
@@ -1,95 +0,0 @@
import { Button } from '@openfun/cunningham-react';
import { Box, Text } from '@/components';
import { InputUserEmail, InputUserPassword } from '@/features/login';
interface LoginFormProps {
title: string;
labelEmail: string;
labelPassword: string;
labelSignIn: string;
email: string;
setEmail: (newEmail: string) => void;
setPassword: (newPassword: string) => void;
error: string;
handleSubmit: (e: React.FormEvent) => void;
blockingError: string;
}
export const LoginForm = ({
title,
labelEmail,
labelPassword,
labelSignIn,
email,
setEmail,
setPassword,
error,
handleSubmit,
blockingError,
}: LoginFormProps) => {
return (
<Box $width="100%" $maxWidth="30rem" $margin="4rem auto" $padding="0 1rem">
<Box>
<Text
as="h1"
$textAlign="center"
$size="h3"
$theme="primary"
$variation="text"
style={{ marginBottom: '2rem' }}
>
{title}
</Text>
<Box>
{!!blockingError ? (
<Text
$theme="danger"
$variation="text"
$textAlign="center"
style={{ marginBottom: '1rem', display: 'block' }}
>
{blockingError === 'loading' ? '' : blockingError}
</Text>
) : (
<form onSubmit={handleSubmit} data-testId="login-form">
<Box $padding="tiny">
<InputUserEmail
setEmail={setEmail}
email={email}
label={labelEmail}
/>
</Box>
<Box $padding="tiny">
<InputUserPassword
label={labelPassword}
setPassword={setPassword}
/>
</Box>
{error && (
<Box $padding="tiny">
<Text
$theme="danger"
$variation="text"
$textAlign="center"
style={{ marginBottom: '1rem', display: 'block' }}
>
{error}
</Text>
</Box>
)}
<Box $padding="tiny">
<Button color="primary" type="submit" fullWidth>
{labelSignIn}
</Button>
</Box>
</form>
)}
</Box>{' '}
</Box>
</Box>
);
};
@@ -1,19 +0,0 @@
import { PropsWithChildren } from 'react';
import { Box } from '@/components';
import { Footer } from '@/features/footer/Footer';
import { Header } from '@/features/header';
export function LoginLayout({ children }: PropsWithChildren) {
return (
<Box>
<Box $height="100vh">
<Header />
<Box $css="flex: 1;" $direction="row">
{children}
</Box>
<Footer />
</Box>
</Box>
);
}
@@ -1,4 +0,0 @@
export * from './LoginLayout';
export * from './LoginForm';
export * from './InputUserEmail';
export * from './InputUserPassword';
@@ -1 +0,0 @@
export * from './components';
@@ -99,7 +99,6 @@ describe('MailDomainAccessesPage', () => {
status: 'enabled',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
support_email: 'support@example.com',
abilities: {
get: true,
patch: true,
@@ -25,7 +25,6 @@ describe('AccessAction', () => {
status: 'enabled',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
support_email: 'support@example.com',
abilities: {
get: true,
patch: true,
@@ -31,7 +31,6 @@ describe('AccessesContent', () => {
status: 'enabled',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
support_email: 'support@example.com',
abilities: {
get: true,
patch: true,
@@ -26,7 +26,6 @@ const mockMailDomain: MailDomain = {
status: 'enabled',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
support_email: 'support@example.com',
abilities: {
manage_accesses: true,
get: true,
@@ -36,7 +36,6 @@ describe('ModalDelete', () => {
status: 'enabled',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
support_email: 'support@example.com',
abilities: {
get: true,
patch: true,
@@ -1,140 +0,0 @@
import { VariantType } from '@openfun/cunningham-react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { MailDomain } from '@/features/mail-domains/domains';
import { AppWrapper } from '@/tests/utils';
import { MailDomainView } from '../components/MailDomainView';
const mockMailDomain: MailDomain = {
id: '123e4567-e89b-12d3-a456-426614174000',
name: 'example.com',
status: 'action_required',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
slug: 'example-com',
support_email: 'support@example.com',
abilities: {
delete: false,
manage_accesses: true,
get: true,
patch: true,
put: true,
post: true,
},
action_required_details: {
mx: 'Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr....',
},
expected_config: [
{ 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.',
},
],
};
const toast = jest.fn();
jest.mock('@openfun/cunningham-react', () => ({
...jest.requireActual('@openfun/cunningham-react'),
useToastProvider: () => ({
toast,
}),
}));
describe('<MailDomainView />', () => {
const apiUrl = `end:/mail-domains/${mockMailDomain.slug}/fetch/`;
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
fetchMock.restore();
});
it('display action required button and open modal with information when domain status is action_required', () => {
render(<MailDomainView mailDomain={mockMailDomain} />, {
wrapper: AppWrapper,
});
// Check if action required button is displayed
const actionButton = screen.getByText('Actions required');
expect(actionButton).toBeInTheDocument();
// Click the button and verify modal content
fireEvent.click(actionButton);
// Verify modal title and content
expect(screen.getByText('Required actions on domain')).toBeInTheDocument();
expect(
screen.getByText(
/Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr./i,
),
).toBeInTheDocument();
// Verify DNS configuration section
expect(
screen.getByText(/DNS Configuration Required:/i),
).toBeInTheDocument();
expect(screen.getByText(/imap.ox.numerique.gouv.fr./i)).toBeInTheDocument();
expect(
screen.getByText(/webmail.ox.numerique.gouv.fr./i),
).toBeInTheDocument();
});
it('allows re-running domain check when clicking re-run button', async () => {
// Mock the fetch call
fetchMock.postOnce(apiUrl, {
status: 200,
body: mockMailDomain,
});
render(
<MailDomainView
mailDomain={mockMailDomain}
onMailDomainUpdate={jest.fn()}
/>,
{ wrapper: AppWrapper },
);
// Check if action required button is displayed
const actionButton = screen.getByText('Actions required');
expect(actionButton).toBeInTheDocument();
// Click the button and verify modal content
fireEvent.click(actionButton);
// Verify modal title and content
expect(screen.getByText('Required actions on domain')).toBeInTheDocument();
expect(
screen.getByText(
/Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr./,
),
).toBeInTheDocument();
// Find and click re-run button
const reRunButton = screen.getByText('Re-run check');
fireEvent.click(reRunButton);
await waitFor(() => {
expect(fetchMock.called(apiUrl)).toBeTruthy();
});
expect(toast).toHaveBeenCalledWith(
'Domain data fetched successfully',
VariantType.SUCCESS,
);
});
});
@@ -19,7 +19,6 @@ describe('ModalAddMailDomain', () => {
modalElement: screen.getByText('Add a mail domain'),
formTag: screen.getByTitle('Mail domain addition form'),
inputName: screen.getByLabelText(/Domain name/i),
inputSupportEmail: screen.getByLabelText(/Support email address/i),
buttonCancel: screen.getByRole('button', { name: /Cancel/i, hidden: true }),
buttonSubmit: screen.getByRole('button', {
name: /Add the domain/i,
@@ -38,19 +37,12 @@ describe('ModalAddMailDomain', () => {
it('renders all the elements', () => {
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const {
modalElement,
formTag,
inputName,
inputSupportEmail,
buttonCancel,
buttonSubmit,
} = getElements();
const { modalElement, formTag, inputName, buttonCancel, buttonSubmit } =
getElements();
expect(modalElement).toBeVisible();
expect(formTag).toBeVisible();
expect(inputName).toBeVisible();
expect(inputSupportEmail).toBeVisible();
expect(screen.getByText('Example: saint-laurent.fr')).toBeVisible();
expect(buttonCancel).toBeVisible();
expect(buttonSubmit).toBeVisible();
@@ -112,18 +104,20 @@ describe('ModalAddMailDomain', () => {
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, inputSupportEmail, buttonSubmit } = getElements();
const { inputName, buttonSubmit } = getElements();
await user.type(inputName, 'domain.fr');
await user.type(inputSupportEmail, 'support@domain.fr');
await user.click(buttonSubmit);
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(fetchMock.lastUrl()).toContain('/mail-domains/');
expect(fetchMock.lastOptions()).toEqual({
body: JSON.stringify({
name: 'domain.fr',
support_email: 'support@domain.fr',
}),
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
@@ -133,6 +127,29 @@ describe('ModalAddMailDomain', () => {
expect(mockPush).toHaveBeenCalledWith(`/mail-domains/domainfr`);
});
it('submits the form on key enter press', async () => {
fetchMock.mock(`end:mail-domains/`, 201);
const user = userEvent.setup();
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName } = getElements();
await user.type(inputName, 'domain.fr');
await user.type(inputName, '{enter}');
expect(fetchMock.lastUrl()).toContain('/mail-domains/');
expect(fetchMock.lastOptions()).toEqual({
body: JSON.stringify({
name: 'domain.fr',
}),
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
});
it('displays right error message error when maildomain name is already used', async () => {
fetchMock.mock(`end:mail-domains/`, {
status: 400,
@@ -145,10 +162,10 @@ describe('ModalAddMailDomain', () => {
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, inputSupportEmail, buttonSubmit } = getElements();
const { inputName, buttonSubmit } = getElements();
await user.type(inputName, 'domain.fr');
await user.type(inputSupportEmail, 'support@domain.fr');
await user.click(buttonSubmit);
await waitFor(() => {
@@ -162,7 +179,6 @@ describe('ModalAddMailDomain', () => {
expect(inputName).toHaveFocus();
await user.type(inputName, 'domain2.fr');
expect(buttonSubmit).toBeEnabled();
});
@@ -178,10 +194,9 @@ describe('ModalAddMailDomain', () => {
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, inputSupportEmail, buttonSubmit } = getElements();
const { inputName, buttonSubmit } = getElements();
await user.type(inputName, 'domainfr');
await user.type(inputSupportEmail, 'support@domain.fr');
await user.click(buttonSubmit);
@@ -209,10 +224,9 @@ describe('ModalAddMailDomain', () => {
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, inputSupportEmail, buttonSubmit } = getElements();
const { inputName, buttonSubmit } = getElements();
await user.type(inputName, 'domain.fr');
await user.type(inputSupportEmail, 'support@domain.fr');
await user.click(buttonSubmit);
@@ -1,76 +0,0 @@
import fetchMock from 'fetch-mock';
import { APIError } from '@/api';
import { fetchMailDomain } from '@/features/mail-domains/domains/api/useFetchMailDomain';
import { MailDomain } from '@/features/mail-domains/domains/types';
const mockMailDomain: MailDomain = {
id: '123e4567-e89b-12d3-a456-426614174000',
name: 'example.com',
status: 'enabled',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
slug: 'example-com',
support_email: 'support@example.com',
abilities: {
delete: false,
manage_accesses: true,
get: true,
patch: true,
put: true,
post: true,
},
action_required_details: {
mx: 'Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr.',
},
expected_config: [
{ target: '', type: 'mx', value: 'mx.ox.numerique.gouv.fr.' },
{
target: 'dimail._domainkey',
type: 'txt',
value: 'v=DKIM1; h=sha256; k=rsa; p=X...X',
},
{ 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.',
},
],
};
describe('fetchMailDomain', () => {
afterEach(() => {
fetchMock.restore();
});
it('fetch the domain successfully', async () => {
fetchMock.postOnce('end:/mail-domains/example-slug/fetch/', {
status: 200,
body: mockMailDomain,
});
const result = await fetchMailDomain('example-slug');
expect(result).toEqual(mockMailDomain);
expect(fetchMock.calls()).toHaveLength(1);
expect(fetchMock.lastUrl()).toContain('/mail-domains/example-slug/fetch/');
});
it('throw an error when the domain is not found', async () => {
fetchMock.postOnce('end:/mail-domains/example-slug/fetch/', {
status: 404,
body: { cause: ['Domain not found'] },
});
await expect(fetchMailDomain('example-slug')).rejects.toThrow(APIError);
expect(fetchMock.calls()).toHaveLength(1);
expect(fetchMock.lastUrl()).toContain('/mail-domains/example-slug/fetch/');
});
});
@@ -8,16 +8,16 @@ import { KEY_LIST_MAIL_DOMAIN } from './useMailDomains';
export interface AddMailDomainParams {
name: string;
supportEmail: string;
}
export const addMailDomain = async ({
name,
supportEmail,
}: AddMailDomainParams): Promise<MailDomain> => {
export const addMailDomain = async (
name: AddMailDomainParams['name'],
): Promise<MailDomain> => {
const response = await fetchAPI(`mail-domains/`, {
method: 'POST',
body: JSON.stringify({ name, support_email: supportEmail }),
body: JSON.stringify({
name,
}),
});
if (!response.ok) {
@@ -38,7 +38,7 @@ export const useAddMailDomain = ({
onError: (error: APIError) => void;
}) => {
const queryClient = useQueryClient();
return useMutation<MailDomain, APIError, AddMailDomainParams>({
return useMutation<MailDomain, APIError, string>({
mutationFn: addMailDomain,
onSuccess: (data) => {
void queryClient.invalidateQueries({
@@ -1,38 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { MailDomain } from '../types';
export const fetchMailDomain = async (slug: string): Promise<MailDomain> => {
const response = await fetchAPI(`mail-domains/${slug}/fetch/`, {
method: 'POST',
});
if (!response.ok) {
throw new APIError(
'Failed to fetch domain from Dimail',
await errorCauses(response),
);
}
return response.json() as Promise<MailDomain>;
};
export const useFetchFromDimail = ({
onSuccess,
onError,
}: {
onSuccess: (data: MailDomain) => void;
onError: (error: APIError) => void;
}) => {
return useMutation<MailDomain, APIError, string>({
mutationFn: fetchMailDomain,
onSuccess: (data) => {
onSuccess(data);
},
onError: (error) => {
onError(error);
},
});
};
@@ -1,11 +1,3 @@
import {
Button,
Loader,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import * as React from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -17,17 +9,11 @@ import MailDomainsLogo from '@/features/mail-domains/assets/mail-domains-logo.sv
import { MailDomain, Role } from '@/features/mail-domains/domains';
import { MailDomainsContent } from '@/features/mail-domains/mailboxes';
import { useFetchFromDimail } from '../api/useFetchMailDomain';
type Props = {
mailDomain: MailDomain;
onMailDomainUpdate?: (updatedDomain: MailDomain) => void;
};
export const MailDomainView = ({ mailDomain, onMailDomainUpdate }: Props) => {
export const MailDomainView = ({ mailDomain }: Props) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const [showModal, setShowModal] = React.useState(false);
const currentRole = mailDomain.abilities.delete
? Role.OWNER
: mailDomain.abilities.manage_accesses
@@ -55,180 +41,28 @@ export const MailDomainView = ({ mailDomain, onMailDomainUpdate }: Props) => {
];
}, [t, currentRole, mailDomain]);
const handleShowModal = () => {
setShowModal(true);
};
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
toast(t('copy done'), VariantType.SUCCESS);
};
const { mutate: fetchMailDomain, isPending: fetchPending } =
useFetchFromDimail({
onSuccess: (data: MailDomain) => {
setShowModal(false);
toast(t('Domain data fetched successfully'), VariantType.SUCCESS);
onMailDomainUpdate?.(data);
},
onError: () => {
toast(t('Failed to fetch domain data'), VariantType.ERROR);
},
});
return (
<>
{showModal && (
<Modal
isOpen
size={ModalSize.EXTRA_LARGE}
onClose={() => setShowModal(false)}
title={t('Required actions on domain')}
>
<p>
{t(
'The domain is currently in action required status. Please take the necessary actions to resolve those following issues.',
)}
</p>
<h3>{t('Actions required detail')}</h3>
<pre>
{mailDomain.action_required_details &&
Object.entries(mailDomain.action_required_details).map(
([check, value], index) => (
<ul key={`action-required-list-${index}`}>
<li key={`action-required-${index}`}>
<b>{check}</b>: {value}
</li>
</ul>
),
)}
</pre>
{mailDomain.expected_config && (
<Box $margin={{ bottom: 'medium' }}>
<h3>{t('DNS Configuration Required:')}</h3>
<pre>
<div
style={{
whiteSpace: 'pre-wrap',
overflowWrap: 'break-word',
}}
>
{t('Add the following DNS values:')}
<ul>
{mailDomain.expected_config.map((item, index) => (
<li
key={`dns-record-${index}`}
style={{ marginBottom: '10px' }}
>
{item.target && (
<>
<b>{item.target.toUpperCase()}</b> -{' '}
</>
)}
<b>{item.type.toUpperCase()}</b> {t('with value:')}{' '}
<span style={{ backgroundColor: '#d4e5f5' }}>
{item.value}
</span>
<button
style={{
padding: '2px 5px',
marginLeft: '10px',
backgroundColor: '#cccccc',
border: 'none',
color: 'white',
cursor: 'pointer',
fontWeight: '500',
borderRadius: '5px',
}}
onClick={() => {
void copyToClipboard(item.value);
}}
>
{t('Copy')}
</button>
</li>
))}
</ul>
</div>
</pre>
</Box>
)}
<pre>
<div style={{ display: 'flex', justifyContent: 'center' }}>
{fetchPending ? (
<Loader />
) : (
<Button
onClick={() => {
void fetchMailDomain(mailDomain.slug);
}}
>
{t('Re-run check')}
</Button>
)}
</div>
</pre>
</Modal>
)}
<Box $padding="big">
<Box $padding="big">
<Box
$width="100%"
$direction="row"
$align="center"
$gap="2.25rem"
$justify="center"
>
<Box
$width="100%"
$direction="row"
$align="center"
$gap="2.25rem"
$justify="center"
$margin={{ bottom: 'big' }}
$gap="0.5rem"
>
<Box
$direction="row"
$justify="center"
$margin={{ bottom: 'big' }}
$gap="0.5rem"
>
<MailDomainsLogo aria-hidden="true" />
<Text $margin="none" as="h3" $size="h3">
{mailDomain?.name}
</Text>
{/* TODO: remove when pending status will be removed */}
{mailDomain?.status === 'pending' && (
<button
onClick={handleShowModal}
style={{
padding: '5px 10px',
marginLeft: '10px',
backgroundColor: '#cccccc',
border: 'none',
color: 'white',
cursor: 'pointer',
fontWeight: '500',
borderRadius: '5px',
}}
data-modal="mail-domain-status"
>
{t('Pending')}
</button>
)}
{mailDomain?.status === 'action_required' && (
<button
onClick={handleShowModal}
style={{
padding: '5px 10px',
marginLeft: '10px',
backgroundColor: '#f37802',
border: 'none',
color: 'white',
cursor: 'pointer',
fontWeight: '500',
borderRadius: '5px',
}}
data-modal="mail-domain-status"
>
{t('Actions required')}
</button>
)}
</Box>
<MailDomainsLogo aria-hidden="true" />
<Text $margin="none" as="h3" $size="h3">
{mailDomain?.name}
</Text>
</Box>
<CustomTabs tabs={tabs} />
</Box>
</>
<CustomTabs tabs={tabs} />
</Box>
);
};
@@ -6,7 +6,6 @@ import { Controller, FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { APIError } from '@/api';
import { parseAPIError } from '@/api/parseAPIError';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
@@ -24,14 +23,12 @@ export const ModalAddMailDomain = () => {
const addMailDomainValidationSchema = z.object({
name: z.string().min(1, t('Example: saint-laurent.fr')),
supportEmail: z.string().email(t('Please enter a valid email address')),
});
const methods = useForm<{ name: string; supportEmail: string }>({
const methods = useForm<{ name: string }>({
delayError: 0,
defaultValues: {
name: '',
supportEmail: '',
},
mode: 'onChange',
reValidateMode: 'onChange',
@@ -42,7 +39,7 @@ export const ModalAddMailDomain = () => {
onSuccess: (mailDomain) => {
router.push(`/mail-domains/${mailDomain.slug}`);
},
onError: (error: APIError) => {
onError: (error) => {
const unhandledCauses = parseAPIError({
error,
errorParams: [
@@ -90,8 +87,8 @@ export const ModalAddMailDomain = () => {
const onSubmitCallback = (event: React.FormEvent) => {
event.preventDefault();
void methods.handleSubmit(({ name, supportEmail }) => {
void addMailDomain({ name, supportEmail });
void methods.handleSubmit(({ name }) => {
void addMailDomain(name);
})();
};
@@ -170,27 +167,6 @@ export const ModalAddMailDomain = () => {
/>
)}
/>
<Box $margin={{ vertical: '10px' }}>
<Controller
control={methods.control}
name="supportEmail"
render={({ fieldState }) => (
<Input
aria-invalid={!!fieldState.error}
aria-required
required
label={t('Support email address')}
state={fieldState.error ? 'error' : 'default'}
text={
fieldState?.error?.message
? fieldState.error.message
: t('E.g. : support@example.fr')
}
{...methods.register('supportEmail')}
/>
)}
/>
</Box>
</form>
{isPending && (

Some files were not shown because too many files have changed in this diff Show More