Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7522569ce0 |
@@ -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! 👌
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ PYTHONPATH=/app
|
||||
# People settings
|
||||
|
||||
# Mail
|
||||
DJANGO_EMAIL_HOST="maildev"
|
||||
DJANGO_EMAIL_HOST="mailcatcher"
|
||||
DJANGO_EMAIL_PORT=1025
|
||||
|
||||
# Backend url
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
ORGANIZATION_PLUGINS=plugins.organizations.NameFromSiretOrganizationPlugin,plugins.organizations.CommuneCreation
|
||||
+1
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -60,17 +60,7 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Personal info"),
|
||||
{
|
||||
"fields": (
|
||||
"name",
|
||||
"email",
|
||||
"language",
|
||||
"timezone",
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Personal info"), {"fields": ("name", "email", "language", "timezone")}),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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,4 +1,3 @@
|
||||
# pylint: disable=too-many-ancestors
|
||||
"""
|
||||
Core application enums declaration
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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.
@@ -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 Régie"
|
||||
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"
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
-25
@@ -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'),
|
||||
),
|
||||
]
|
||||
-52
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
+138
-242
@@ -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
|
||||
+1
-130
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}."
|
||||
)
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
"""People application to allow OAuth2 authentication with OIDC provider using mailbox."""
|
||||
@@ -1 +0,0 @@
|
||||
"""People application to allow OAuth2 authentication with OIDC provider using mailbox."""
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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';
|
||||
-1
@@ -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,
|
||||
|
||||
-1
@@ -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,
|
||||
|
||||
-1
@@ -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,
|
||||
|
||||
-1
@@ -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,
|
||||
|
||||
-1
@@ -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,
|
||||
|
||||
-140
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
+34
-20
@@ -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);
|
||||
|
||||
|
||||
-76
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
+17
-183
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-28
@@ -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
Reference in New Issue
Block a user