Compare commits

..

2 Commits

Author SHA1 Message Date
daproclaima 23060ddb09 💄(frontend) update member list style
- apply css style to names column
2024-08-08 13:54:33 +02:00
daproclaima f20a6da846 💬(frontend) fix group creation error message
- use a more meaningful error message while creating a group
and its name is already used
- update translations and related e2e tests

closes #293
2024-08-08 13:18:31 +02:00
217 changed files with 3236 additions and 13608 deletions
-22
View File
@@ -1,22 +0,0 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "people,secrets"
+80 -18
View File
@@ -150,7 +150,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set services env variables
run: |
make create-env-files
@@ -175,29 +175,15 @@ jobs:
with:
path: src/frontend/apps/desk/out/
key: build-front-${{ github.run_id }}
# Pull the latest image to build, and avoid caching pull-only images.
# (docker pull is faster than caching in most cases.)
- run: docker compose pull
# In this step, this action saves a list of existing images,
# the cache is created without them in the post run.
# It also restores the cache if it exists.
- uses: satackey/action-docker-layer-caching@v0.0.11
# Ignore the failure of a step and avoid terminating the job.
continue-on-error: true
- name: Build and Start Docker Servers
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
run: |
docker compose build --build-arg BUILDKIT_INLINE_CACHE=1
docker compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
make run
# Finally, "Post Run satackey/action-docker-layer-caching@v0.0.11",
# which is the process of saving the cache, will be executed.
- name: Apply DRF migrations
run: |
make migrate
@@ -320,3 +306,79 @@ jobs:
run: python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
i18n-crowdin:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
- name: Install gettext (required to make messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install development dependencies
working-directory: src/backend
run: pip install --user .[dev]
- name: Generate the translation base file
run: ~/.local/bin/django-admin makemessages --keep-pot --all
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18.x'
cache: 'yarn'
cache-dependency-path: src/frontend/yarn.lock
- name: Install dependencies
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Download sources from Crowdin to stay synchronized
run: |
docker run \
--rm \
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
-v "${{ github.workspace }}:/app" \
crowdin/cli:3.16.0 \
crowdin download sources -c /app/crowdin/config.yml
- name: Extract the frontend translation
run: make frontend-i18n-extract
- name: Upload files to Crowdin
run: |
docker run \
--rm \
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
-v "${{ github.workspace }}:/app" \
crowdin/cli:3.16.0 \
crowdin upload sources -c /app/crowdin/config.yml
-10
View File
@@ -1,10 +0,0 @@
creation_rules:
- path_regex: ./*
key_groups:
- age:
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa # Marie Pupo Jeammet
-128
View File
@@ -7,131 +7,3 @@ and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.4.1] - 2024-10-23
### Fixed
- 🚑️(frontend) fix MailDomainsLayout
## [1.4.0] - 2024-10-23
### Added
- ✨(frontend) add tabs inside #466
### Fixed
- ✏️(mail) fix typo into mailbox creation email
- 🐛(sentry) fix duplicated sentry errors #479
- 🐛(script) improve and fix release script
## [1.3.1] - 2024-10-18
## [1.3.0] - 2024-10-18
### Added
- ✨(api) add RELEASE version on config endpoint #459
- ✨(backend) manage roles on domain admin view
- ✨(frontend) show version number in footer #369
### Changed
- 🛂(backend) match email if no existing user matches the sub
### Fixed
- 💄(mail) improve mailbox creation email #462
- 🐛(frontend) fix update accesses form #448
- 🛂(backend) do not duplicate user when disabled
## [1.2.1] - 2024-10-03
### Fixed
- 🔧(mail) use new scaleway email gateway #435
## [1.2.0] - 2024-09-30
### Added
- ✨(ci) add helmfile linter and fix argocd sync #424
- ✨(domains) add endpoint to list and retrieve domain accesses #404
- 🍱(dev) embark dimail-api as container #366
- ✨(dimail) allow la regie to request a token for another user #416
- ✨(frontend) show username on AccountDropDown #412
- 🥅(frontend) improve add & update group forms error handling #387
- ✨(frontend) allow group members filtering #363
- ✨(mailbox) send new mailbox confirmation email #397
- ✨(domains) domain accesses update API #423
- ✨(backend) domain accesses create API #428
- 🥅(frontend) catch new errors on mailbox creation #392
- ✨(api) domain accesses delete API #433
- ✨(frontend) add mail domain access management #413
### Fixed
- ♿️(frontend) fix left nav panel #396
- 🔧(backend) fix configuration to avoid different ssl warning #432
### Changed
- ♻️(serializers) move business logic to serializers #414
## [1.1.0] - 2024-09-10
### Added
- 📈(monitoring) configure sentry monitoring #378
- 🥅(frontend) improve api error handling #355
### Changed
- 🗃️(models) move dimail 'secret' to settings #372
### Fixed
- 🐛(dimail) improve handling of dimail errors on failed mailbox creation #377
- 💬(frontend) fix group member removal text #382
- 💬(frontend) fix add mail domain text #382
- 🐛(frontend) fix keyboard navigation #379
- 🐛(frontend) fix add mail domain form submission #355
## [1.0.2] - 2024-08-30
### Added
- 🔧Runtime config for the frontend (#345)
- 🔧(helm) configure resource server in staging
### Fixed
- 👽️(mailboxes) fix mailbox creation after dimail api improvement (#360)
## [1.0.1] - 2024-08-19
### Fixed
- ✨(frontend) user can add mail domains
## [1.0.0] - 2024-08-09
### Added
- ✨(domains) create and manage domains on admin + API
- ✨(domains) mailbox creation + link to email provisioning API
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.4.1...main
[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
-19
View File
@@ -88,7 +88,6 @@ bootstrap: \
back-i18n-compile \
mails-install \
mails-build \
dimail-setup-db \
install-front-desk
.PHONY: bootstrap
@@ -110,7 +109,6 @@ run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d app-dev
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d keycloak
@$(COMPOSE) up -d dimail
@echo "Wait for postgresql to be up..."
@$(WAIT_KC_DB)
@$(WAIT_DB)
@@ -131,12 +129,6 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
reset-dimail-container:
@$(COMPOSE) up --force-recreate -d dimail
@$(MAKE) setup-dimail-db
.PHONY: reset-dimail-container
# Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources
lint: \
@@ -280,13 +272,6 @@ i18n-generate-and-upload: \
crowdin-upload
.PHONY: i18n-generate-and-upload
# -- INTEROPERABILTY
# -- Dimail configuration
dimail-setup-db:
@echo "$(BOLD)Populating database of local dimail API container$(RESET)"
@$(MANAGE) setup_dimail_db
.PHONY: dimail-setup-db
# -- Mail generator
@@ -358,7 +343,3 @@ start-kind: ## Create the kubernetes cluster
tilt-up: ## start tilt - k8s local development
tilt up -f ./bin/Tiltfile
.PHONY: tilt-up
release: ## helper for release and deployment
python scripts/release.py
.PHONY: release
+18 -25
View File
@@ -1,12 +1,12 @@
# People
People is an application to handle users and teams, and distribute permissions accross [La Suite](https://lasuite.numerique.gouv.fr/).
People is an application to handle users and teams.
It is built on top of [Django Rest
As of today, this project is **not yet ready for production**. Expect breaking changes.
People is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/).
All interoperabilities will be described in `docs/interoperability`.
## Getting started
### Prerequisite
@@ -25,7 +25,7 @@ $ docker compose -v
> ⚠️ You may need to run the following commands with `sudo` but this can be
> avoided by assigning your user to the `docker` group.
### Bootstrap project
### Project bootstrap
The easiest way to start working on the project is to use GNU Make:
@@ -38,7 +38,7 @@ database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
dependency-related or migration-related issues.
Your Docker services should now be up and running! 🎉
Your Docker services should now be up and running 🎉
Note that if you need to run them afterward, you can use the eponym Make rule:
@@ -46,7 +46,13 @@ Note that if you need to run them afterward, you can use the eponym Make rule:
$ make run
```
You can check all available Make rules using:
### Adding content
You can create a basic demo site by running:
$ make demo
Finally, you can check all available Make rules using:
```bash
$ make help
@@ -65,23 +71,6 @@ $ make superuser
You can then login with sub `admin` and password `admin`.
### Adding demo content
You can create a basic demo site by running:
```bash
$ make demo
```
### Setting dimail database
To ease local development when working on interoperability between people and dimail, we embark dimail-api in a container running in "fake" mode.
To populate dimail local database with users/domains/permissions needed for basic development:
- log in with "people" user
- run `make dimail-setup-db`
### Run frontend
Run the front with:
@@ -90,10 +79,14 @@ Run the front with:
$ make run-front-desk
```
Then access [http://localhost:3000](http://localhost:3000) with :
Then access at
[http://localhost:3000](http://localhost:3000)
user: people
password: people
## Contributing
This project is intended to be community-driven, so please, do not hesitate to
+2 -8
View File
@@ -1,3 +1,5 @@
version: '3.8'
services:
postgresql:
image: postgres:16
@@ -166,11 +168,3 @@ services:
- "8080:8080"
depends_on:
- kc_postgresql
dimail:
image: registry.mim-libre.fr/dimail/dimail-api:latest
environment:
DIMAIL_MODE: FAKE
DIMAIL_JWT_SECRET: fake_jwt_secret
ports:
- "8001:8000"
-35
View File
@@ -1,35 +0,0 @@
# dimail
## What is dimail ?
The mailing solution provided in La Suite is [Open-XChange](https://www.open-xchange.com/) (OX).
OX not having a provisioning API, 'dimail-api' or 'dimail' was created to allow mail-provisioning through People.
API and its documentation can be found [here](https://api.dev.ox.numerique.gouv.fr/docs#/).
## Architectural links of dimail
As dimail's primary goal is to act as an interface between People and OX, its architecture is similar to that of People. A series of requests are sent from People to dimail upon creating domains, users and accesses.
### Domains
Upon creating a domain on People, the same domain is created on dimail and will undergo a series of checks. When all checks have passed, the domain is considered valid and mailboxes can be created on it.
### Users
The ones issuing requests. Dimail users will reflect domains owners and administrators on People, for logging purposes and to allow direct use of dimail, if desired. User reconciliation is made on user uuid provided by ProConnect.
### Accesses
As for People, an access - a permissions (or "allows" in dimail) - grants an user permission to create objects on a domain.
Permissions requests are sent automatically upon :
- dimail database initialisation:
+ permission for dimail user People to create users and domains
- domain creation :
+ permission for dimail user People to manage domain
+ permission for new owner on new domain
- user creation:
+ permission for People to manage user
- access creation, if owner or admin:
+ permission for this user to manage domain
-164
View File
@@ -1,164 +0,0 @@
## Resource Server
For detailed information, please refer to the [OAuth 2.0 Resource Server documentation](https://www.oauth.com/oauth2-servers/the-resource-server/) and review the relevant commits that implement the resource server backend.
---
#### Overview
A resource server is a crucial component in an OAuth 2.0 ecosystem. It is responsible for accepting access tokens issued by the authorization server (in this case, Agent Connect), introspecting and validating those tokens, and then returning the requested protected resources to the client.
By implementing a resource server, we can securely share data between different services within La Suite. This ensures that only clients with the appropriate scopes and permissions can access specific resources, thereby enhancing security and maintaining proper access control across services.
---
#### Disclaimer
- Currently compatible only with Agent Connect.
- The development setup requires simplification, with dependencies on Agent Connect ideally mocked.
- Terminology aligns with the specification: what is referred to as a "resource server" is known as a "data provider" in Agent Connect.
- This documentation is WIP.
---
## Running Locally
#### Prerequisites
- **Agent Connect Stack**: Ensure the local Agent Connect stack is running. A solid understanding of its configuration and operation is recommended (advanced level).
- **People Stack**: Make sure the People stack is up and running.
- **Ngrok**: Install and set up Ngrok for secure tunneling.
---
### Update People's configurations
#### Environment variables
Agent Connect includes two pre-configured mocked data providers in its default stack (`bdd-fca-low`).
Use the client ID and client secret from one of these data providers. **Note:** Make sure to retrieve the decrypted secret, as it is stored encrypted in the database. You can find these values in the `dp.js` fixture file, where they are exposed in a comment.
Configure your environment with the following values from Agent Connect:
```
OIDC_RS_CLIENT_ID=<your-client-id-from-ac>
OIDC_RS_CLIENT_SECRET=<your-decrypted-client-secret-from-ac>
# In development, the resource server use insecure settings
OIDC_VERIFY_SSL=False
# Update the endpoints as follows
OIDC_OP_JWKS_ENDPOINT=https://core-fca-low.docker.dev-franceconnect.fr/api/v2/jwks
OIDC_OP_INTROSPECTION_ENDPOINT=https://core-fca-low.docker.dev-franceconnect.fr/api/v2/checktoken
OIDC_OP_URL=https://core-fca-low.docker.dev-franceconnect.fr/api/v2
```
#### Docker Network Configuration
To enable communication between the Docker networks for People and Agent Connect, update your docker-compose configuration. This setup is required because the Authorization Server and Resource Server will exchange requests over a back-channel, necessitating their accessibility to each other.
1. **Create a Network**: Define a new network to bridge the two Docker networks.
```yaml
networks:
authorization_server:
external: true
driver: bridge
name: "${DESK_NETWORK:-fc_public}"
```
2. **Update Network for `app-dev`**: Ensure your `app-dev` service is connected to both the default network and the new `authorization_server` network.
```yaml
app-dev:
...
networks:
- default
- authorization_server
```
#### Ngrok
To expose your local resource server through an HTTP tunnel, use Ngrok. This is necessary because, in the Agent Connect development stack, the resource server needs to be accessible to a user agent via a publicly accessible URL.
```
$ ngrok http 8071
```
---
### Update AgentConnect's configurations
Modify the AgentConnect configuration to include the local resource server settings.
**Update `fsa1-low` Environment File**:
Add your Ngrok URL and configure the `DATA_APIS` list with the appropriate values.
```env
App_DATA_APIS=[{"name":"Data Provider 1","url":"https://your-ngrok-url/api/v1.0/any-path","secret":"***"}, ...]
```
**Update Fixture in `dp.js`**:
Adjust the configuration for the data provider to match your local setup. Ensure that the `client_id`, `client_secret`, and other parameters are correctly set and aligned with your environment. This can be configured through environment variables.
```javascript
const dps = [
// Data Provider Configuration
{
uid: "6f21b751-ed06-48b6-a59c-36e1300a368a",
title: "Mock Data Provider - 1",
active: true,
slug: "DESK",
client_id: "***",
client_secret: "***",
// client_secret decrypted : ****
jwks_uri: "https://your-ngrok-url/api/v1.0/jwks", // Update this line
checktoken_signed_response_alg: "ES256",
checktoken_encrypted_response_alg: "RSA-OAEP",
checktoken_encrypted_response_enc: "A256GCM",
},
];
```
**Note**: Ensure that the `jwks_uri` and other cryptographic parameters (e.g., `checktoken_signed_response_alg`, `checktoken_encrypted_response_alg`, and `checktoken_encrypted_response_enc`) match your actual setup and are configured via environment variables where necessary.
---
### Usage
This section is a work in progress. Please note the following important points:
#### User `sub` Matching
Ensure that the `sub` (subject) field for users in AgentConnect matches the corresponding value in the People database. To synchronize this, you can run `make demo`, then edit the user's `sub` field to match the value returned by AgentConnect. For this, you'll need to update the editable field in Django Admin, specifically in `admin.py`. Adjust the `get_readonly_fields` method as follows:
```python
def get_readonly_fields(self, request, obj=None):
"""The 'sub' field should only be editable during creation, not for updates."""
if obj:
return self.readonly_fields
return self.readonly_fields + ["sub"] # update this line adding 'sub'
```
#### Scope for `groups`
Ensure that the `groups` scope is requested from the service provider during authentication with AgentConnect.
#### Resource Server Requests
By default, the `fsa1-low` environment calls the resource server using a POST request.
#### Testing
Most of the testing has been done using the `/users/me` endpoint. Update the `api/viewset.py` configuration to allow both GET and POST methods for this endpoint:
```python
@decorators.action(
detail=False,
methods=["get", "post"], # update this line adding 'post'
url_name="me",
url_path="me",
)
```
-32
View File
@@ -33,35 +33,3 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
OIDC_RS_CLIENT_ID=people
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RS_PRIVATE_KEY_STR="-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3boG1kwEGUYL+
U58RPrVToIsF9jHB64S6WJIIInPmAclBciXFb6BWG11mbRIgo8ha3WVnC/tGHbXb
ndiKdrH2vKHOsDhV9AmgHgNgWaUK9L0uuKEb/xMLePYWsYlgzcQJx8RZY7RQyWqE
20WfzFxeuCE7QMb6VXSOgwQMnJsKocguIh3VCI9RIBq3B1kdgW35AD63YKOygmGx
qjcWwbjhKLvkF7LpBdlyAEzOKqg4T5uCcHMfksMW2+foTJx70RrZM/KHU+Zysuw7
uhhVsgPBG+CsqBSjHQhs7jzymqxtQAfe1FkrCRxOq5Pv2Efr7kgtVSkJJiX3KutM
vnWuEypxAgMBAAECggEAGqKS9pbrN+vnmb7yMsqYgVVnQn0aggZNHlLkl4ZLLnuV
aemlhur7zO0JzajqUC+AFQOfaQxiFu8S/FoJ+qccFdATrcPEVmTKbgPVqSyzLKlX
fByGll5eOVT95NMwN8yBGgt2HSW/ZditXS/KxxahVgamGqjAC9MTSutGz/8Ae1U+
DNDBJCc6RAqu3T02tV9A2pSpVC1rSktDMpLUTscnsfxpaEQATd9DJUcHEvIwoX8q
GJpycPEhNhdPXqpln5SoMHcf/zS5ssF/Mce0lJJXYyE0LnEk9X12jMWyBqmLqXUY
cKLyynaFbis0DpQppwKx2y8GpL76k+Ci4dOHIvFknQKBgQDj/2WRMcWOvfBrggzj
FHpcme2gSo5A5c0CVyI+Xkf1Zab6UR6T7GiImEoj9tq0+o2WEix9rwoypgMBq8rz
/rrJAPSZjgv6z71k4EnO2FIB5R03vQmoBRCN8VlgvLM0xv52zyjV4Wx66Q4MDjyH
EgkpHyB0FzRZh0UzhnE/pYSetQKBgQDN9eLB1nA4CBSr1vMGNfQyfBQl3vpO9EP4
VSS3KnUqCIjJeLu682Ylu7SFxcJAfzUpy5S43hEvcuJsagsVKfmCAGcYZs9/xq3I
vzYyhaEOS5ezNxLSh4+yCNBPlmrmDyoazag0t8H8YQFBN6BVcxbATHqdWGUhIhYN
eEpEMOh2TQKBgGBr7kRNTENlyHtu8IxIaMcowfn8DdUcWmsW9oBx1vTNHKTYEZp1
bG/4F8LF7xCCtcY1wWMV17Y7xyG5yYcOv2eqY8dc72wO1wYGZLB5g5URlB2ycJcC
LVIaM7ZZl2BGl+8fBSIOx5XjYfFvQ+HLmtwtMchm19jVAEseHF7SXRfRAoGAK15j
aT2mU6Yf9C9G7T/fM+I8u9zACHAW/+ut14PxN/CkHQh3P16RW9CyqpiB1uLyZuKf
Zm4cYElotDuAKey0xVMgYlsDxnwni+X3m5vX1hLE1s/5/qrc7zg75QZfbCI1U3+K
s88d4e7rPLhh4pxhZgy0pP1ADkIHMr7ppIJH8OECgYEApNfbgsJVPAMzucUhJoJZ
OmZHbyCtJvs4b+zxnmhmSbopifNCgS4zjXH9qC7tsUph1WE6L2KXvtApHGD5H4GQ
IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
MbyqKyC6DAzv4jEEhHaN7oY=
-----END PRIVATE KEY-----
"
+7 -1
View File
@@ -13,7 +13,13 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
"matchPackageNames": [
"fetch-mock",
"node",
"node-fetch",
"i18next-parser",
"eslint"
]
}
]
}
-115
View File
@@ -1,115 +0,0 @@
import datetime
import os
import re
import sys
from utils import run_command
RELEASE_KINDS = {'p': 'patch', 'm': 'minor', 'mj': 'major'}
def update_files(version):
"""Update all files needed with new release version"""
# pyproject.toml
sys.stdout.write("Update pyproject.toml...\n")
path = "src/backend/pyproject.toml"
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if line.startswith("version = "):
lines[index] = re.sub(r'\"(.*?)\"', f'"{version}"', line)
with open(path, 'w+') as file:
file.writelines(lines)
# helm files
sys.stdout.write("Update helm files...\n")
for env in ["preprod", "production"]:
path = f"src/helm/env.d/{env}/values.desk.yaml.gotmpl"
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if "tag:" in line:
lines[index] = re.sub(r'\"(.*?)\"', f'"v{version}"', line)
with open(path, 'w+') as file:
file.writelines(lines)
# frontend package.json
sys.stdout.write("Update package.json...\n")
files_to_modify = []
filename = "package.json"
for root, _dir, files in os.walk("src/frontend"):
if filename in files and "node_modules" not in root and ".next" not in root:
files_to_modify.append(os.path.join(root, filename))
for path in files_to_modify:
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if "version" in line:
lines[index] = re.sub(r'"version": \"(.*?)\"', f'"version": "{version}"', line)
with open(path, 'w+') as file:
file.writelines(lines)
return
def update_changelog(path, version):
"""Update changelog file with release info
"""
sys.stdout.write("Update CHANGELOG...\n")
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if "## [Unreleased]" in line:
today = datetime.date.today()
lines.insert(index + 1, f"\n## [{version}] - {today}\n")
if line.startswith("[unreleased]"):
last_version = lines[index + 1].split("]")[0][1:]
new_unreleased_line = line.replace(last_version, version)
new_release_line = lines[index + 1].replace(last_version, version)
lines[index] = new_unreleased_line
lines.insert(index + 1, new_release_line)
break
with open(path, 'w+') as file:
file.writelines(lines)
def prepare_release(version, kind):
sys.stdout.write('Let\'s go to create branch to release\n')
branch_to_release = f"release/{version}"
run_command(f"git checkout -b {branch_to_release}", shell=True)
run_command("git pull --rebase origin main", shell=True)
update_changelog("CHANGELOG.md", version)
update_files(version)
run_command("git add CHANGELOG.md", shell=True)
run_command("git add src/", shell=True)
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
run_command(f"git commit -m '{message}'", shell=True)
confirm = input(f"\nNEXT COMMAND: \n>> git push origin {branch_to_release}\nContinue ? (y,n) ")
if confirm == 'y':
run_command(f"git push origin {branch_to_release}", shell=True)
sys.stdout.write(f"""
PLEASE DO THE FOLLOWING INSTRUCTIONS:
--> Please submit PR and merge code to main than tag version
>> git tag -a v{version}
>> git push origin v{version}
--> Please check docker image: https://hub.docker.com/r/lasuite/people-backend/tags
>> git tag -d preprod
>> git tag preprod
>> git push -f origin preprod
--> Now please generate release on github interface for the current tag v{version}
""")
if __name__ == "__main__":
version, kind = None, None
while not version:
version = input("Enter your release version:")
while kind not in RELEASE_KINDS:
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
prepare_release(version, kind)
-14
View File
@@ -1,14 +0,0 @@
import subprocess
import sys
def run_command(cmd, msg=None, shell=False, cwd='.', stdout=None):
if msg is None:
msg = f"# Running: {cmd}"
if stdout is not None:
stdout.write(msg + '\n')
if stdout != sys.stdout:
sys.stdout(msg)
subprocess.check_call(cmd, shell=shell, cwd=cwd, stdout=stdout, stderr=stdout)
if stdout is not None:
stdout.flush()
+1 -1
Submodule secrets updated: b7ab5f1411...d7cfe7bcdc
+5 -14
View File
@@ -4,8 +4,6 @@ from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from mailbox_manager.admin import MailDomainAccessInline
from . import models
@@ -42,7 +40,7 @@ class UserAdmin(auth_admin.UserAdmin):
)
},
),
(_("Personal info"), {"fields": ("name", "email", "language", "timezone")}),
(_("Personal info"), {"fields": ("email", "language", "timezone")}),
(
_("Permissions"),
{
@@ -67,9 +65,10 @@ class UserAdmin(auth_admin.UserAdmin):
},
),
)
inlines = (TeamAccessInline, MailDomainAccessInline)
inlines = (TeamAccessInline,)
list_display = (
"get_user",
"sub",
"email",
"created_at",
"updated_at",
"is_active",
@@ -80,7 +79,7 @@ class UserAdmin(auth_admin.UserAdmin):
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
readonly_fields = ["id", "created_at", "updated_at"]
search_fields = ("id", "email", "sub", "name")
search_fields = ("id", "email", "sub")
def get_readonly_fields(self, request, obj=None):
"""The sub should only be editable for a create, not for updates."""
@@ -88,14 +87,6 @@ class UserAdmin(auth_admin.UserAdmin):
return self.readonly_fields + ["sub"]
return self.readonly_fields
def get_user(self, obj):
"""Provide a nice display for user"""
return (
obj.name if obj.name else (obj.email if obj.email else f"[sub] {obj.sub}")
)
get_user.short_description = _("User")
@admin.register(models.Team)
class TeamAdmin(admin.ModelAdmin):
-21
View File
@@ -1,6 +1,5 @@
"""API endpoints"""
from django.conf import settings
from django.contrib.postgres.search import TrigramSimilarity
from django.db.models import Func, Max, OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce
@@ -13,10 +12,8 @@ from rest_framework import (
pagination,
response,
throttling,
views,
viewsets,
)
from rest_framework.permissions import AllowAny
from core import models
@@ -494,21 +491,3 @@ class InvitationViewset(
.distinct()
)
return queryset
class ConfigView(views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
def get(self, request):
"""
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = ["LANGUAGES", "FEATURES", "RELEASE"]
dict_settings = {}
for setting in array_settings:
dict_settings[setting] = getattr(settings, setting)
return response.Response(dict_settings)
+19 -51
View File
@@ -1,7 +1,6 @@
"""Authentication Backends for the People core app."""
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
@@ -10,8 +9,6 @@ from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
User = get_user_model()
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
@@ -51,7 +48,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
return userinfo
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Create a new user if no match is found.
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
Parameters:
- access_token (str): The access token.
@@ -67,30 +64,30 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
user_info = self.get_userinfo(access_token, id_token, payload)
# 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 = {
"email": email,
"name": full_name,
}
# Compute user name from OIDC name fields as defined in settings
names_list = [
user_info[field]
for field in settings.USER_OIDC_FIELDS_TO_NAME
if user_info.get(field)
]
user_info["name"] = " ".join(names_list) or None
sub = user_info.get("sub")
if not sub:
if sub is None:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
# if sub is absent, try matching on email
user = self.get_existing_user(sub, email)
if user:
if not user.is_active:
raise SuspiciousOperation(_("User account is disabled"))
self.update_user_if_needed(user, claims)
elif self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
try:
user = self.UserModel.objects.get(sub=sub)
except self.UserModel.DoesNotExist:
if self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(user_info)
else:
email = user_info.get("email")
name = user_info.get("name")
if email and email != user.email or name and name != user.name:
self.UserModel.objects.filter(sub=sub).update(email=email, name=name)
return user
@@ -108,32 +105,3 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
email=claims.get("email"),
name=claims.get("name"),
)
def compute_full_name(self, user_info):
"""Compute user's full name based on OIDC fields in settings."""
name_fields = settings.USER_OIDC_FIELDS_TO_NAME
full_name = " ".join(
user_info[field] for field in name_fields if user_info.get(field)
)
return full_name or None
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return User.objects.get(email=email)
except User.DoesNotExist:
pass
return None
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
has_changed = any(
value and value != getattr(user, key) for key, value in claims.items()
)
if has_changed:
updated_claims = {key: value for key, value in claims.items() if value}
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
+2 -2
View File
@@ -146,11 +146,11 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(condition=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
constraint=models.CheckConstraint(check=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(condition=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
constraint=models.CheckConstraint(check=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
),
migrations.AlterUniqueTogether(
name='contact',
+2 -2
View File
@@ -124,12 +124,12 @@ class Contact(BaseModel):
unique_together = ("owner", "base")
constraints = [
models.CheckConstraint(
condition=~models.Q(base__isnull=False, owner__isnull=True),
check=~models.Q(base__isnull=False, owner__isnull=True),
name="base_owner_constraint",
violation_error_message="A contact overriding a base contact must be owned.",
),
models.CheckConstraint(
condition=~models.Q(base=models.F("id")),
check=~models.Q(base=models.F("id")),
name="base_not_self",
violation_error_message="A contact cannot be based on itself.",
),
@@ -1,54 +0,0 @@
"""Resource Server Authentication"""
import base64
import binascii
import logging
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from mozilla_django_oidc.contrib.drf import OIDCAuthentication
from .backend import ResourceServerBackend, ResourceServerImproperlyConfiguredBackend
from .clients import AuthorizationServerClient
logger = logging.getLogger(__name__)
class ResourceServerAuthentication(OIDCAuthentication):
"""Authenticate clients using the token received from the authorization server."""
def __init__(self):
super().__init__()
try:
authorization_server_client = AuthorizationServerClient(
url=settings.OIDC_OP_URL,
verify_ssl=settings.OIDC_VERIFY_SSL,
timeout=settings.OIDC_TIMEOUT,
proxy=settings.OIDC_PROXY,
url_jwks=settings.OIDC_OP_JWKS_ENDPOINT,
url_introspection=settings.OIDC_OP_INTROSPECTION_ENDPOINT,
)
self.backend = ResourceServerBackend(authorization_server_client)
except ImproperlyConfigured as err:
message = "Resource Server authentication is disabled"
logger.debug("%s. Exception: %s", message, err)
self.backend = ResourceServerImproperlyConfiguredBackend()
def get_access_token(self, request):
"""Retrieve and decode the access token from the request.
This method overcharges the 'get_access_token' method from the parent class,
to support service providers that would base64 encode the bearer token.
"""
access_token = super().get_access_token(request)
try:
access_token = base64.b64decode(access_token).decode("utf-8")
except (binascii.Error, TypeError):
pass
return access_token
-223
View File
@@ -1,223 +0,0 @@
"""Resource Server Backend"""
import logging
from django.conf import settings
from django.contrib import auth
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from joserfc import jwe as jose_jwe
from joserfc import jwt as jose_jwt
from joserfc.errors import InvalidClaimError, InvalidTokenError
from requests.exceptions import HTTPError
from rest_framework.exceptions import AuthenticationFailed
from . import utils
logger = logging.getLogger(__name__)
class ResourceServerBackend:
"""Backend of an OAuth 2.0 resource server.
This backend is designed to authenticate resource owners to our API using the access token
they received from the authorization server.
In the context of OAuth 2.0, a resource server is a server that hosts protected resources and
is capable of accepting and responding to protected resource requests using access tokens.
The resource server verifies the validity of the access tokens issued by the authorization
server to ensure secure access to the resources.
For more information, visit: https://www.oauth.com/oauth2-servers/the-resource-server/
"""
# pylint: disable=too-many-instance-attributes
def __init__(self, authorization_server_client):
# pylint: disable=invalid-name
self.UserModel = auth.get_user_model()
self._client_id = settings.OIDC_RS_CLIENT_ID
self._client_secret = settings.OIDC_RS_CLIENT_SECRET
self._encryption_encoding = settings.OIDC_RS_ENCRYPTION_ENCODING
self._encryption_algorithm = settings.OIDC_RS_ENCRYPTION_ALGO
self._signing_algorithm = settings.OIDC_RS_SIGNING_ALGO
self._scopes = settings.OIDC_RS_SCOPES
if (
not self._client_id
or not self._client_secret
or not authorization_server_client
):
raise ImproperlyConfigured(
"Could not instantiate ResourceServerBackend, some parameters are missing."
)
self._authorization_server_client = authorization_server_client
self._claims_registry = jose_jwt.JWTClaimsRegistry(
iss={"essential": True, "value": self._authorization_server_client.url},
aud={"essential": True, "value": self._client_id},
token_introspection={"essential": True},
)
# pylint: disable=unused-argument
def get_or_create_user(self, access_token, id_token, payload):
"""Maintain API compatibility with OIDCAuthentication class from mozilla-django-oidc
Params 'id_token', 'payload' won't be used, and our implementation will only
support 'get_user', not 'get_or_create_user'.
"""
return self.get_user(access_token)
def get_user(self, access_token):
"""Get user from an access token emitted by the authorization server.
This method will submit the access token to the authorization server for
introspection, to ensure its validity and obtain the associated metadata.
It follows the specifications outlined in RFC7662 https://www.rfc-editor.org/info/rfc7662,
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12.
In our eGovernment applications, the standard RFC 7662 doesn't provide sufficient security.
Its introspection response is a plain JSON object. Therefore, we use the draft RFC
that extends RFC 7662 by returning a signed and encrypted JWT for stronger assurance that
the authorization server issued the token introspection response.
"""
jwt = self._introspect(access_token)
claims = self._verify_claims(jwt)
user_info = self._verify_user_info(claims["token_introspection"])
sub = user_info.get("sub")
if sub is None:
message = "User info contained no recognizable user identification"
logger.debug(message)
raise SuspiciousOperation(message)
try:
user = self.UserModel.objects.get(sub=sub)
except self.UserModel.DoesNotExist:
logger.debug("Login failed: No user with %s found", sub)
return None
return user
def _verify_user_info(self, introspection_response):
"""Verify the 'introspection_response' to get valid and relevant user info.
The 'introspection_response' should be still active, and while authenticating
the resource owner should have requested relevant scope to access her data in
our resource server.
Scope should be configured to match between the AS and the RS. The AS will filter
all the scopes the resource owner requested to expose only the relevant ones to
our resource server.
"""
active = introspection_response.get("active", None)
if not active:
message = "Introspection response is not active."
logger.debug(message)
raise SuspiciousOperation(message)
requested_scopes = introspection_response.get("scope", None).split(" ")
if set(self._scopes).isdisjoint(set(requested_scopes)):
message = "Introspection response contains any required scopes."
logger.debug(message)
raise SuspiciousOperation(message)
return introspection_response
def _introspect(self, token):
"""Introspect an access token to the authorization server."""
try:
jwe = self._authorization_server_client.get_introspection(
self._client_id,
self._client_secret,
token,
)
except HTTPError as err:
message = "Could not fetch introspection"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
private_key = utils.import_private_key_from_settings()
jws = self._decrypt(jwe, private_key=private_key)
try:
public_key_set = self._authorization_server_client.import_public_keys()
except (TypeError, ValueError, AttributeError, HTTPError) as err:
message = "Could get authorization server JWKS"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
jwt = self._decode(jws, public_key_set)
return jwt
def _decrypt(self, encrypted_token, private_key):
"""Decrypt the token encrypted by the Authorization Server (AS).
Resource Server (RS)'s public key is used for encryption, and its private
key is used for decryption. The RS's public key is exposed to the AS via a JWKS endpoint.
Encryption Algorithm and Encoding should be configured to match between the AS
and the RS.
"""
try:
decrypted_token = jose_jwe.decrypt_compact(
encrypted_token,
private_key,
algorithms=[self._encryption_algorithm, self._encryption_encoding],
)
except Exception as err:
message = "Token decryption failed"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
return decrypted_token
def _decode(self, encoded_token, public_key_set):
"""Decode the token signed by the Authorization Server (AS).
AS's private key is used for signing, and its public key is used for decoding.
The AS public key is exposed via a JWK endpoint.
Signing Algorithm should be configured to match between the AS and the RS.
"""
try:
token = jose_jwt.decode(
encoded_token.plaintext,
public_key_set,
algorithms=[self._signing_algorithm],
)
except ValueError as err:
message = "Token decoding failed"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
return token
def _verify_claims(self, token):
"""Verify the claims of the token to ensure authentication security.
By verifying these claims, we ensure that the token was issued by a
trusted authorization server and is intended for this specific
resource server. This prevents various types of attacks, such as
token substitution or misuse of tokens issued for different clients.
"""
try:
self._claims_registry.validate(token.claims)
except (InvalidClaimError, InvalidTokenError) as err:
message = "Failed to validate token's claims"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
return token.claims
class ResourceServerImproperlyConfiguredBackend:
"""Fallback backend for improperly configured Resource Servers."""
def get_or_create_user(self, access_token, id_token, payload):
"""Indicate that the Resource Server is improperly configured."""
raise AuthenticationFailed("Resource Server is improperly configured")
@@ -1,95 +0,0 @@
"""Resource Server Clients classes"""
from django.core.exceptions import ImproperlyConfigured
import requests
from joserfc.jwk import KeySet
class AuthorizationServerClient:
"""Client for interacting with an OAuth 2.0 authorization server.
An authorization server issues access tokens to client applications after authenticating
and obtaining authorization from the resource owner. It also provides endpoints for token
introspection and JSON Web Key Sets (JWKS) to validate and decode tokens.
This client facilitates communication with the authorization server, including:
- Fetching token introspection responses.
- Fetching JSON Web Key Sets (JWKS) for token validation.
- Setting appropriate headers for secure communication as recommended by RFC drafts.
"""
# ruff: noqa: PLR0913 PLR017
# pylint: disable=too-many-positional-arguments
# pylint: disable=too-many-arguments
def __init__(
self,
url,
url_jwks,
url_introspection,
verify_ssl,
timeout,
proxy,
):
if not url or not url_jwks or not url_introspection:
raise ImproperlyConfigured(
"Could not instantiate AuthorizationServerClient, some parameters are missing."
)
self.url = url
self._url_introspection = url_introspection
self._url_jwks = url_jwks
self._verify_ssl = verify_ssl
self._timeout = timeout
self._proxy = proxy
@property
def _introspection_headers(self):
"""Get HTTP header for the introspection request.
Notify the authorization server that we expect a signed and encrypted response
by setting the appropriate 'Accept' header.
This follows the recommendation from the draft RFC:
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12.
"""
return {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/token-introspection+jwt",
}
def get_introspection(self, client_id, client_secret, token):
"""Retrieve introspection response about a token."""
response = requests.post(
self._url_introspection,
data={
"client_id": client_id,
"client_secret": client_secret,
"token": token,
},
headers=self._introspection_headers,
verify=self._verify_ssl,
timeout=self._timeout,
proxies=self._proxy,
)
response.raise_for_status()
return response.text
def get_jwks(self):
"""Retrieve Authorization Server JWKS."""
response = requests.get(
self._url_jwks,
verify=self._verify_ssl,
timeout=self._timeout,
proxies=self._proxy,
)
response.raise_for_status()
return response.json()
def import_public_keys(self):
"""Retrieve and import Authorization Server JWKS."""
jwks = self.get_jwks()
public_keys = KeySet.import_key_set(jwks)
return public_keys
-9
View File
@@ -1,9 +0,0 @@
"""Resource Server URL Configuration"""
from django.urls import path
from .views import JWKSView
urlpatterns = [
path("jwks", JWKSView.as_view(), name="resource_server_jwks"),
]
-48
View File
@@ -1,48 +0,0 @@
"""Resource Server utils functions"""
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from joserfc.jwk import JWKRegistry
def import_private_key_from_settings():
"""Import the private key used by the resource server when interacting with the OIDC provider.
This private key is crucial; its public components are exposed in the JWK endpoints,
while its private component is used for decrypting the introspection token retrieved
from the OIDC provider.
By default, we recommend using RSAKey for asymmetric encryption,
known for its strong security features.
Note:
- The function requires the 'OIDC_RS_PRIVATE_KEY_STR' setting to be configured.
- The 'OIDC_RS_ENCRYPTION_KEY_TYPE' and 'OIDC_RS_ENCRYPTION_ALGO' settings can be customized
based on the chosen key type.
Raises:
ImproperlyConfigured: If the private key setting is missing, empty, or incorrect.
Returns:
joserfc.jwk.JWK: The imported private key as a JWK object.
"""
private_key_str = getattr(settings, "OIDC_RS_PRIVATE_KEY_STR", None)
if not private_key_str:
raise ImproperlyConfigured(
"OIDC_RS_PRIVATE_KEY_STR setting is missing or empty."
)
private_key_pem = private_key_str.encode()
try:
private_key = JWKRegistry.import_key(
private_key_pem,
key_type=settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
parameters={"alg": settings.OIDC_RS_ENCRYPTION_ALGO, "use": "enc"},
)
except ValueError as err:
raise ImproperlyConfigured("OIDC_RS_PRIVATE_KEY_STR setting is wrong.") from err
return private_key
-40
View File
@@ -1,40 +0,0 @@
"""Resource Server views"""
from django.core.exceptions import ImproperlyConfigured
from joserfc.jwk import KeySet
from rest_framework.response import Response
from rest_framework.views import APIView
from . import utils
class JWKSView(APIView):
"""
API endpoint for retrieving a JSON Web Keys Set (JWKS).
Returns:
Response: JSON response containing the JWKS data.
"""
authentication_classes = [] # disable authentication
permission_classes = [] # disable permission
def get(self, request):
"""Handle GET requests to retrieve JSON Web Keys Set (JWKS).
Returns:
Response: JSON response containing the JWKS data.
"""
try:
private_key = utils.import_private_key_from_settings()
except (ImproperlyConfigured, ValueError) as err:
return Response({"error": str(err)}, status=500)
try:
jwk = KeySet([private_key]).as_dict(private=False)
except (TypeError, ValueError, AttributeError):
return Response({"error": "Could not load key"}, status=500)
return Response(jwk)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,6 +1,5 @@
"""Unit tests for the Authentication Backends."""
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
import pytest
@@ -9,7 +8,6 @@ from core import factories, models
from core.authentication.backends import OIDCAuthenticationBackend
pytestmark = pytest.mark.django_db
User = get_user_model()
def test_authentication_getter_existing_user_no_email(
@@ -103,59 +101,6 @@ def test_authentication_getter_existing_user_change_fields(
assert user.name == f"{first_name:s} {last_name:s}"
def test_authentication_getter_existing_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user doesn't match the sub but matches the email,
the user should be returned.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory()
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(2):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_existing_user_no_fallback_to_email(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should not match users by email, even if the email matches.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory()
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub doesn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
@@ -224,63 +169,3 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
def test_authentication_getter_existing_disabled_user_via_sub(
django_assert_num_queries, monkeypatch
):
"""
If an existing user matches the sub but is disabled,
an error should be raised and a user should not be created.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory(name="John Doe", is_active=False)
def get_userinfo_mocked(*args):
return {
"sub": db_user.sub,
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(1),
pytest.raises(SuspiciousOperation, match="User account is disabled"),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
def test_authentication_getter_existing_disabled_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user does not matches the sub but match the email and is disabled,
an error should be raised and a user should not be created.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory(name="John Doe", is_active=False)
def get_userinfo_mocked(*args):
return {
"sub": "random",
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(2),
pytest.raises(SuspiciousOperation, match="User account is disabled"),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
@@ -1,447 +0,0 @@
"""
Test for the Resource Server (RS) Backend.
"""
# pylint: disable=W0212
from logging import Logger
from unittest.mock import Mock, patch
from django.contrib import auth
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
import pytest
from joserfc.errors import InvalidClaimError, InvalidTokenError
from joserfc.jwt import JWTClaimsRegistry
from requests.exceptions import HTTPError
from core.resource_server.backend import ResourceServerBackend
@pytest.fixture(name="mock_authorization_server")
def fixture_mock_authorization_server():
"""Mock an Authorization Server client."""
mock_server = Mock()
mock_server.url = "https://auth.server.com"
return mock_server
@pytest.fixture(name="mock_token")
def fixture_mock_token():
"""Mock a token"""
mock_token = Mock()
mock_token.claims = {"sub": "user123", "iss": "https://auth.server.com"}
return mock_token
@pytest.fixture(name="resource_server_backend")
def fixture_resource_server_backend(settings, mock_authorization_server):
"""Generate a Resource Server backend."""
settings.OIDC_RS_CLIENT_ID = "client_id"
settings.OIDC_RS_CLIENT_SECRET = "client_secret"
settings.OIDC_RS_ENCRYPTION_ENCODING = "A256GCM"
settings.OIDC_RS_ENCRYPTION_ALGO = "RSA-OAEP"
settings.OIDC_RS_SIGNING_ALGO = "ES256"
settings.OIDC_RS_SCOPES = ["groups"]
return ResourceServerBackend(mock_authorization_server)
@override_settings(OIDC_RS_CLIENT_ID="client_id")
@override_settings(OIDC_RS_CLIENT_SECRET="client_secret")
@override_settings(OIDC_RS_ENCRYPTION_ENCODING="A256GCM")
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RSA-OAEP")
@override_settings(OIDC_RS_SIGNING_ALGO="RS256")
@override_settings(OIDC_RS_SCOPES=["scopes"])
@patch.object(auth, "get_user_model", return_value="foo")
def test_backend_initialization(mock_get_user_model, mock_authorization_server):
"""Test the ResourceServerBackend initialization."""
backend = ResourceServerBackend(mock_authorization_server)
mock_get_user_model.assert_called_once()
assert backend.UserModel == "foo"
assert backend._client_id == "client_id"
assert backend._client_secret == "client_secret"
assert backend._encryption_encoding == "A256GCM"
assert backend._encryption_algorithm == "RSA-OAEP"
assert backend._signing_algorithm == "RS256"
assert backend._scopes == ["scopes"]
assert backend._authorization_server_client == mock_authorization_server
assert isinstance(backend._claims_registry, JWTClaimsRegistry)
assert backend._claims_registry.options == {
"iss": {"essential": True, "value": "https://auth.server.com"},
"aud": {"essential": True, "value": "client_id"},
"token_introspection": {"essential": True},
}
@patch.object(ResourceServerBackend, "get_user", return_value="user")
def test_get_or_create_user(mock_get_user, resource_server_backend):
"""Test 'get_or_create_user' method."""
access_token = "access_token"
res = resource_server_backend.get_or_create_user(access_token, None, None)
mock_get_user.assert_called_once_with(access_token)
assert res == "user"
def test_verify_claims_success(resource_server_backend, mock_token):
"""Test '_verify_claims' method with a successful response."""
with patch.object(
resource_server_backend._claims_registry, "validate"
) as mock_validate:
resource_server_backend._verify_claims(mock_token)
mock_validate.assert_called_once_with(mock_token.claims)
def test_verify_claims_invalid_claim_error(resource_server_backend, mock_token):
"""Test '_verify_claims' method with an invalid claim error."""
with patch.object(
resource_server_backend._claims_registry, "validate"
) as mock_validate:
mock_validate.side_effect = InvalidClaimError("claim_name")
expected_message = "Failed to validate token's claims"
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._verify_claims(mock_token)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
def test_verify_claims_invalid_token_error(resource_server_backend, mock_token):
"""Test '_verify_claims' method with an invalid token error."""
with patch.object(
resource_server_backend._claims_registry, "validate"
) as mock_validate:
mock_validate.side_effect = InvalidTokenError
expected_message = "Failed to validate token's claims"
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._verify_claims(mock_token)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
def test_decode_success(resource_server_backend):
"""Test '_decode' method with a successful response."""
encoded_token = Mock()
encoded_token.plaintext = "valid_encoded_token"
public_key_set = Mock()
expected_decoded_token = {"sub": "user123"}
with patch(
"joserfc.jwt.decode", return_value=expected_decoded_token
) as mock_decode:
decoded_token = resource_server_backend._decode(encoded_token, public_key_set)
mock_decode.assert_called_once_with(
"valid_encoded_token", public_key_set, algorithms=["ES256"]
)
assert decoded_token == expected_decoded_token
def test_decode_failure(resource_server_backend):
"""Test '_decode' method with a ValueError"""
encoded_token = Mock()
encoded_token.plaintext = "invalid_encoded_token"
public_key_set = Mock()
with patch("joserfc.jwt.decode", side_effect=ValueError):
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match="Token decoding failed"):
resource_server_backend._decode(encoded_token, public_key_set)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", "Token decoding failed", exc_info=True
)
def test_decrypt_success(resource_server_backend):
"""Test '_decrypt' method with a successful response."""
encrypted_token = "valid_encrypted_token"
private_key = "private_key"
expected_decrypted_token = {"sub": "user123"}
with patch(
"joserfc.jwe.decrypt_compact", return_value=expected_decrypted_token
) as mock_decrypt:
decrypted_token = resource_server_backend._decrypt(encrypted_token, private_key)
mock_decrypt.assert_called_once_with(
encrypted_token, private_key, algorithms=["RSA-OAEP", "A256GCM"]
)
assert decrypted_token == expected_decrypted_token
def test_decrypt_failure(resource_server_backend):
"""Test '_decrypt' method with an Exception."""
encrypted_token = "invalid_encrypted_token"
private_key = "private_key"
with patch(
"joserfc.jwe.decrypt_compact", side_effect=Exception("Decryption error")
):
expected_message = "Token decryption failed"
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._decrypt(encrypted_token, private_key)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
@patch(
"core.resource_server.utils.import_private_key_from_settings",
return_value="private_key",
)
# pylint: disable=unused-argument
def test_introspect_success(
mock_import_private_key_from_settings, resource_server_backend
):
"""Test '_introspect' method with a successful response."""
token = "valid_token"
jwe = "valid_jwe"
jws = "valid_jws"
jwt = {"sub": "user123"}
resource_server_backend._authorization_server_client.get_introspection = Mock(
return_value=jwe
)
resource_server_backend._decrypt = Mock(return_value=jws)
resource_server_backend._authorization_server_client.import_public_keys = Mock(
return_value="public_key_set"
)
resource_server_backend._decode = Mock(return_value=jwt)
result = resource_server_backend._introspect(token)
assert result == jwt
resource_server_backend._authorization_server_client.get_introspection.assert_called_once_with(
"client_id", "client_secret", token
)
resource_server_backend._decrypt.assert_called_once_with(
jwe, private_key="private_key"
)
resource_server_backend._authorization_server_client.import_public_keys.assert_called_once()
resource_server_backend._decode.assert_called_once_with(jws, "public_key_set")
def test_introspect_introspection_failure(resource_server_backend):
"""Test '_introspect' method when introspection to the AS fails."""
token = "invalid_token"
resource_server_backend._authorization_server_client.get_introspection.side_effect = HTTPError(
"Introspection error"
)
with patch.object(Logger, "debug") as mock_logger_debug:
expected_message = "Could not fetch introspection"
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._introspect(token)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
@patch(
"core.resource_server.utils.import_private_key_from_settings",
return_value="private_key",
)
# pylint: disable=unused-argument
def test_introspect_public_key_import_failure(
mock_import_private_key_from_settings, resource_server_backend
):
"""Test '_introspect' method when fetching AS's jwks fails."""
token = "valid_token"
jwe = "valid_jwe"
jws = "valid_jws"
resource_server_backend._authorization_server_client.get_introspection = Mock(
return_value=jwe
)
resource_server_backend._decrypt = Mock(return_value=jws)
resource_server_backend._authorization_server_client.import_public_keys.side_effect = HTTPError(
"Public key error"
)
with patch.object(Logger, "debug") as mock_logger_debug:
expected_message = "Could get authorization server JWKS"
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._introspect(token)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
def test_verify_user_info_success(resource_server_backend):
"""Test '_verify_user_info' with a successful response."""
introspection_response = {"active": True, "scope": "groups"}
result = resource_server_backend._verify_user_info(introspection_response)
assert result == introspection_response
def test_verify_user_info_inactive(resource_server_backend):
"""Test '_verify_user_info' with an inactive introspection response."""
introspection_response = {"active": False, "scope": "groups"}
expected_message = "Introspection response is not active."
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._verify_user_info(introspection_response)
mock_logger_debug.assert_called_once_with(expected_message)
def test_verify_user_info_wrong_scopes(resource_server_backend):
"""Test '_verify_user_info' with wrong requested scopes."""
introspection_response = {"active": True, "scope": "wrong-scopes"}
expected_message = "Introspection response contains any required scopes."
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._verify_user_info(introspection_response)
mock_logger_debug.assert_called_once_with(expected_message)
def test_get_user_success(resource_server_backend):
"""Test '_get_user' with a successful response."""
access_token = "valid_access_token"
mock_jwt = Mock()
mock_claims = {"token_introspection": {"sub": "user123"}}
mock_user = Mock()
resource_server_backend._introspect = Mock(return_value=mock_jwt)
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
resource_server_backend._verify_user_info = Mock(
return_value=mock_claims["token_introspection"]
)
resource_server_backend.UserModel.objects.get = Mock(return_value=mock_user)
user = resource_server_backend.get_user(access_token)
assert user == mock_user
resource_server_backend._introspect.assert_called_once_with(access_token)
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
resource_server_backend._verify_user_info.assert_called_once_with(
mock_claims["token_introspection"]
)
resource_server_backend.UserModel.objects.get.assert_called_once_with(sub="user123")
def test_get_user_could_not_introspect(resource_server_backend):
"""Test '_get_user' with introspection failing."""
access_token = "valid_access_token"
resource_server_backend._introspect = Mock(
side_effect=SuspiciousOperation("Invalid jwt")
)
resource_server_backend._verify_claims = Mock()
resource_server_backend._verify_user_info = Mock()
with pytest.raises(SuspiciousOperation, match="Invalid jwt"):
resource_server_backend.get_user(access_token)
resource_server_backend._introspect.assert_called_once_with(access_token)
resource_server_backend._verify_claims.assert_not_called()
resource_server_backend._verify_user_info.assert_not_called()
def test_get_user_invalid_introspection_response(resource_server_backend):
"""Test '_get_user' with an invalid introspection response."""
access_token = "valid_access_token"
mock_jwt = Mock()
resource_server_backend._introspect = Mock(return_value=mock_jwt)
resource_server_backend._verify_claims = Mock(
side_effect=SuspiciousOperation("Invalid claims")
)
resource_server_backend._verify_user_info = Mock()
with pytest.raises(SuspiciousOperation, match="Invalid claims"):
resource_server_backend.get_user(access_token)
resource_server_backend._introspect.assert_called_once_with(access_token)
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
resource_server_backend._verify_user_info.assert_not_called()
def test_get_user_user_not_found(resource_server_backend):
"""Test '_get_user' if the user is not found."""
access_token = "valid_access_token"
mock_jwt = Mock()
mock_claims = {"token_introspection": {"sub": "user123"}}
resource_server_backend._introspect = Mock(return_value=mock_jwt)
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
resource_server_backend._verify_user_info = Mock(
return_value=mock_claims["token_introspection"]
)
resource_server_backend.UserModel.objects.get = Mock(
side_effect=resource_server_backend.UserModel.DoesNotExist
)
with patch.object(Logger, "debug") as mock_logger_debug:
user = resource_server_backend.get_user(access_token)
assert user is None
resource_server_backend._introspect.assert_called_once_with(access_token)
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
resource_server_backend._verify_user_info.assert_called_once_with(
mock_claims["token_introspection"]
)
resource_server_backend.UserModel.objects.get.assert_called_once_with(
sub="user123"
)
mock_logger_debug.assert_called_once_with(
"Login failed: No user with %s found", "user123"
)
def test_get_user_no_user_identification(resource_server_backend):
"""Test '_get_user' if the response miss a user identification."""
access_token = "valid_access_token"
mock_jwt = Mock()
mock_claims = {"token_introspection": {}}
resource_server_backend._introspect = Mock(return_value=mock_jwt)
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
resource_server_backend._verify_user_info = Mock(
return_value=mock_claims["token_introspection"]
)
expected_message = "User info contained no recognizable user identification"
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend.get_user(access_token)
mock_logger_debug.assert_called_once_with(expected_message)
@@ -1,187 +0,0 @@
"""
Test for the Resource Server (RS) clients classes.
"""
# pylint: disable=W0212
from unittest.mock import MagicMock, patch
import pytest
from joserfc.jwk import KeySet, RSAKey
from requests.exceptions import HTTPError
from core.resource_server.clients import AuthorizationServerClient
@pytest.fixture(name="client")
def fixture_client():
"""Generate an Authorization Server client."""
return AuthorizationServerClient(
url="https://auth.example.com/api/v2",
url_jwks="https://auth.example.com/api/v2/jwks",
url_introspection="https://auth.example.com/api/v2/introspect",
verify_ssl=True,
timeout=5,
proxy=None,
)
def test_authorization_server_client_initialization():
"""Test the AuthorizationServerClient initialization."""
new_client = AuthorizationServerClient(
url="https://auth.example.com/api/v2",
url_jwks="https://auth.example.com/api/v2/jwks",
url_introspection="https://auth.example.com/api/v2/checktoken/foo",
verify_ssl=True,
timeout=5,
proxy=None,
)
assert new_client.url == "https://auth.example.com/api/v2"
assert (
new_client._url_introspection
== "https://auth.example.com/api/v2/checktoken/foo"
)
assert new_client._url_jwks == "https://auth.example.com/api/v2/jwks"
assert new_client._verify_ssl is True
assert new_client._timeout == 5
assert new_client._proxy is None
def test_introspection_headers(client):
"""Test the introspection headers to ensure they match the expected values."""
assert client._introspection_headers == {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/token-introspection+jwt",
}
@patch("requests.post")
def test_get_introspection_success(mock_post, client):
"""Test 'get_introspection' method with a successful response."""
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.text = "introspection response"
mock_post.return_value = mock_response
result = client.get_introspection("client_id", "client_secret", "token")
assert result == "introspection response"
mock_post.assert_called_once_with(
"https://auth.example.com/api/v2/introspect",
data={
"client_id": "client_id",
"client_secret": "client_secret",
"token": "token",
},
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/token-introspection+jwt",
},
verify=True,
timeout=5,
proxies=None,
)
@patch("requests.post", side_effect=HTTPError())
# pylint: disable=(unused-argument
def test_get_introspection_error(mock_post, client):
"""Test 'get_introspection' method with an HTTPError."""
with pytest.raises(HTTPError):
client.get_introspection("client_id", "client_secret", "token")
@patch("requests.get")
def test_get_jwks_success(mock_get, client):
"""Test 'get_jwks' method with a successful response."""
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"jwks": "foo"}
mock_get.return_value = mock_response
result = client.get_jwks()
assert result == {"jwks": "foo"}
mock_get.assert_called_once_with(
"https://auth.example.com/api/v2/jwks",
verify=client._verify_ssl,
timeout=client._timeout,
proxies=client._proxy,
)
@patch("requests.get")
def test_get_jwks_error(mock_get, client):
"""Test 'get_jwks' method with an HTTPError."""
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = HTTPError(
response=MagicMock(status=500)
)
mock_get.return_value = mock_response
with pytest.raises(HTTPError):
client.get_jwks()
@patch("requests.get")
def test_import_public_keys_valid(mock_get, client):
"""Test 'import_public_keys' method with a successful response."""
mocked_key = RSAKey.generate_key(2048)
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"keys": [mocked_key.as_dict()]}
mock_get.return_value = mock_response
response = client.import_public_keys()
assert isinstance(response, KeySet)
assert response.as_dict() == KeySet([mocked_key]).as_dict()
@patch("requests.get")
def test_import_public_keys_http_error(mock_get, client):
"""Test 'import_public_keys' method with an HTTPError."""
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = HTTPError(
response=MagicMock(status=500)
)
mock_get.return_value = mock_response
with pytest.raises(HTTPError):
client.import_public_keys()
@patch("requests.get")
def test_import_public_keys_empty_jwks(mock_get, client):
"""Test 'import_public_keys' method with empty keys response."""
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"keys": []}
mock_get.return_value = mock_response
response = client.import_public_keys()
assert isinstance(response, KeySet)
assert response.as_dict() == {"keys": []}
@patch("requests.get")
def test_import_public_keys_invalid_jwks(mock_get, client):
"""Test 'import_public_keys' method with invalid keys response."""
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"keys": [{"foo": "foo"}]}
mock_get.return_value = mock_response
with pytest.raises(ValueError):
client.import_public_keys()
@@ -1,88 +0,0 @@
"""
Test for the Resource Server (RS) utils functions.
"""
from django.core.exceptions import ImproperlyConfigured
from django.test.utils import override_settings
import pytest
from joserfc.jwk import ECKey, RSAKey
from core.resource_server.utils import import_private_key_from_settings
PRIVATE_KEY_STR_MOCKED = """-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3boG1kwEGUYL+
U58RPrVToIsF9jHB64S6WJIIInPmAclBciXFb6BWG11mbRIgo8ha3WVnC/tGHbXb
ndiKdrH2vKHOsDhV9AmgHgNgWaUK9L0uuKEb/xMLePYWsYlgzcQJx8RZY7RQyWqE
20WfzFxeuCE7QMb6VXSOgwQMnJsKocguIh3VCI9RIBq3B1kdgW35AD63YKOygmGx
qjcWwbjhKLvkF7LpBdlyAEzOKqg4T5uCcHMfksMW2+foTJx70RrZM/KHU+Zysuw7
uhhVsgPBG+CsqBSjHQhs7jzymqxtQAfe1FkrCRxOq5Pv2Efr7kgtVSkJJiX3KutM
vnWuEypxAgMBAAECggEAGqKS9pbrN+vnmb7yMsqYgVVnQn0aggZNHlLkl4ZLLnuV
aemlhur7zO0JzajqUC+AFQOfaQxiFu8S/FoJ+qccFdATrcPEVmTKbgPVqSyzLKlX
fByGll5eOVT95NMwN8yBGgt2HSW/ZditXS/KxxahVgamGqjAC9MTSutGz/8Ae1U+
DNDBJCc6RAqu3T02tV9A2pSpVC1rSktDMpLUTscnsfxpaEQATd9DJUcHEvIwoX8q
GJpycPEhNhdPXqpln5SoMHcf/zS5ssF/Mce0lJJXYyE0LnEk9X12jMWyBqmLqXUY
cKLyynaFbis0DpQppwKx2y8GpL76k+Ci4dOHIvFknQKBgQDj/2WRMcWOvfBrggzj
FHpcme2gSo5A5c0CVyI+Xkf1Zab6UR6T7GiImEoj9tq0+o2WEix9rwoypgMBq8rz
/rrJAPSZjgv6z71k4EnO2FIB5R03vQmoBRCN8VlgvLM0xv52zyjV4Wx66Q4MDjyH
EgkpHyB0FzRZh0UzhnE/pYSetQKBgQDN9eLB1nA4CBSr1vMGNfQyfBQl3vpO9EP4
VSS3KnUqCIjJeLu682Ylu7SFxcJAfzUpy5S43hEvcuJsagsVKfmCAGcYZs9/xq3I
vzYyhaEOS5ezNxLSh4+yCNBPlmrmDyoazag0t8H8YQFBN6BVcxbATHqdWGUhIhYN
eEpEMOh2TQKBgGBr7kRNTENlyHtu8IxIaMcowfn8DdUcWmsW9oBx1vTNHKTYEZp1
bG/4F8LF7xCCtcY1wWMV17Y7xyG5yYcOv2eqY8dc72wO1wYGZLB5g5URlB2ycJcC
LVIaM7ZZl2BGl+8fBSIOx5XjYfFvQ+HLmtwtMchm19jVAEseHF7SXRfRAoGAK15j
aT2mU6Yf9C9G7T/fM+I8u9zACHAW/+ut14PxN/CkHQh3P16RW9CyqpiB1uLyZuKf
Zm4cYElotDuAKey0xVMgYlsDxnwni+X3m5vX1hLE1s/5/qrc7zg75QZfbCI1U3+K
s88d4e7rPLhh4pxhZgy0pP1ADkIHMr7ppIJH8OECgYEApNfbgsJVPAMzucUhJoJZ
OmZHbyCtJvs4b+zxnmhmSbopifNCgS4zjXH9qC7tsUph1WE6L2KXvtApHGD5H4GQ
IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
MbyqKyC6DAzv4jEEhHaN7oY=
-----END PRIVATE KEY-----
"""
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
@pytest.mark.parametrize("mocked_private_key", [None, ""])
def test_import_private_key_from_settings_missing_or_empty_key(
settings, mocked_private_key
):
"""Should raise an exception if the settings 'OIDC_RS_PRIVATE_KEY_STR' is missing or empty."""
settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key
with pytest.raises(
ImproperlyConfigured,
match="OIDC_RS_PRIVATE_KEY_STR setting is missing or empty.",
):
import_private_key_from_settings()
@pytest.mark.parametrize("mocked_private_key", ["123", "foo", "invalid_key"])
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="RSA")
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RS256")
def test_import_private_key_from_settings_incorrect_key(settings, mocked_private_key):
"""Should raise an exception if the setting 'OIDC_RS_PRIVATE_KEY_STR' has an incorrect value."""
settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key
with pytest.raises(
ImproperlyConfigured, match="OIDC_RS_PRIVATE_KEY_STR setting is wrong."
):
import_private_key_from_settings()
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="RSA")
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RS256")
def test_import_private_key_from_settings_success_rsa_key():
"""Should import private key string as an RSA key."""
private_key = import_private_key_from_settings()
assert isinstance(private_key, RSAKey)
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="EC")
@override_settings(OIDC_RS_ENCRYPTION_ALGO="ES256")
def test_import_private_key_from_settings_success_ec_key():
"""Should import private key string as an EC key."""
private_key = import_private_key_from_settings()
assert isinstance(private_key, ECKey)
@@ -1,70 +0,0 @@
"""
Tests for the Resource Server (RS) Views.
"""
from unittest import mock
from django.core.exceptions import ImproperlyConfigured
from django.urls import reverse
import pytest
from joserfc.jwk import RSAKey
from rest_framework.test import APIClient
pytestmark = pytest.mark.django_db
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
def test_view_jwks_valid_public_key(mock_import_private_key_from_settings):
"""JWKs endpoint should return a set of valid Json Web Key"""
mocked_key = RSAKey.generate_key(2048)
mock_import_private_key_from_settings.return_value = mocked_key
url = reverse("resource_server_jwks")
response = APIClient().get(url)
mock_import_private_key_from_settings.assert_called_once()
assert response.status_code == 200
assert response["Content-Type"] == "application/json"
jwks = response.json()
assert jwks == {"keys": [mocked_key.as_dict(private=False)]}
# Security checks to make sure no details from the private key are exposed
private_details = ["d", "p", "q", "dp", "dq", "qi", "oth", "r", "t"]
assert all(
private_detail not in jwks["keys"][0].keys()
for private_detail in private_details
)
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
def test_view_jwks_invalid_private_key(mock_import_private_key_from_settings):
"""JWKS endpoint should return a proper exception when loading keys fails."""
mock_import_private_key_from_settings.return_value = "wrong_key"
url = reverse("resource_server_jwks")
response = APIClient().get(url)
mock_import_private_key_from_settings.assert_called_once()
assert response.status_code == 500
assert response.json() == {"error": "Could not load key"}
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
def test_view_jwks_missing_private_key(mock_import_private_key_from_settings):
"""JWKS endpoint should return a proper exception when private key is missing."""
mock_import_private_key_from_settings.side_effect = ImproperlyConfigured("foo.")
url = reverse("resource_server_jwks")
response = APIClient().get(url)
mock_import_private_key_from_settings.assert_called_once()
assert response.status_code == 500
assert response.json() == {"error": "foo."}
@@ -223,7 +223,7 @@ def test_api_team_accesses__list_find_members_by_email():
Authenticated users should be able to search users access with a case-insensitive and
partial query on the email.
"""
user = factories.UserFactory(name=None, email="alicia@example.com")
user = factories.UserFactory(name=None)
# set all names to None to match only on emails
colleague1 = factories.UserFactory(name=None, email="prudence_crandall@edu.us")
-41
View File
@@ -1,41 +0,0 @@
"""
Test users API endpoints in the People core app.
"""
import pytest
from rest_framework.status import (
HTTP_200_OK,
)
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_config_anonymous():
"""Anonymous users should be allowed to get the configuration."""
client = APIClient()
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
"FEATURES": {"TEAMS": True},
"RELEASE": "NA",
}
def test_api_config_authenticated():
"""Authenticated users should be allowed to get the configuration."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
"FEATURES": {"TEAMS": True},
"RELEASE": "NA",
}
+2 -2
View File
@@ -76,7 +76,7 @@ def test_api_contacts_list_authenticated_no_query():
# Let's have 5 contacts in database:
assert user.profile_contact is not None # Excluded because profile contact
base_contact = factories.BaseContactFactory() # Excluded because overridden
base_contact = factories.BaseContactFactory() # Excluded because overriden
factories.ContactFactory(
base=base_contact
) # Excluded because belongs to other user
@@ -395,7 +395,7 @@ def test_api_contacts_create_authenticated_successful():
@override_settings(ALLOW_API_USER_CREATE=True)
def test_api_contacts_create_authenticated_existing_override():
"""
Trying to create a contact for base contact that is already overridden by the user
Trying to create a contact for base contact that is already overriden by the user
should receive a 400 error.
"""
user = factories.UserFactory(profile_contact=None)
-6
View File
@@ -4,7 +4,6 @@ from django.urls import path
from .views import (
DebugViewHtml,
DebugViewNewMailboxHtml,
DebugViewTxt,
)
@@ -19,9 +18,4 @@ urlpatterns = [
DebugViewTxt.as_view(),
name="debug.mail.invitation_txt",
),
path(
"__debug__/mail/new_mailbox_html",
DebugViewNewMailboxHtml.as_view(),
name="debug.mail.new_mailbox_html",
),
]
-14
View File
@@ -25,17 +25,3 @@ class DebugViewTxt(DebugBaseView):
"""Debug View for Text Email Layout"""
template_name = "mail/text/invitation.txt"
class DebugViewNewMailboxHtml(DebugBaseView):
"""Debug view for new mailbox email layout"""
template_name = "mail/html/new_mailbox.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["mailbox_data"] = {
"email": "john.doe@example.com",
"password": "6HGVAsjoog_v",
}
return context
-1
View File
@@ -4,5 +4,4 @@ NB_OBJECTS = {
"users": 1000,
"teams": 100,
"max_users_per_team": 100,
"domains": 20,
}
@@ -10,15 +10,12 @@ from uuid import uuid4
from django import db
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.utils.text import slugify
from faker import Faker
from core import models
from demo import defaults
from mailbox_manager import models as mailbox_models
from mailbox_manager.enums import MailDomainStatusChoices
fake = Faker()
@@ -155,35 +152,6 @@ def create_demo(stdout):
)
queue.flush()
with Timeit(stdout, "Creating domains"):
for _i in range(defaults.NB_OBJECTS["domains"]):
name = fake.domain_name()
slug = slugify(name)
queue.push(
mailbox_models.MailDomain(
name=name,
# slug should be automatic but bulk_create doesn't use save
slug=slug,
status=random.choice(MailDomainStatusChoices.choices)[0],
)
)
queue.flush()
with Timeit(stdout, "Creating accesses to domains"):
domains_ids = list(
mailbox_models.MailDomain.objects.values_list("id", flat=True)
)
for domain_id in domains_ids:
queue.push(
mailbox_models.MailDomainAccess(
domain_id=domain_id,
user_id=random.choice(users_ids),
role=models.RoleChoices.OWNER,
)
)
queue.flush()
class Command(BaseCommand):
"""A management command to create a demo database."""
@@ -10,13 +10,12 @@ import pytest
from core import models
from demo import defaults
from mailbox_manager import models as mailbox_models
TEST_NB_OBJECTS = {
"users": 5,
"teams": 3,
"max_identities_per_user": 3,
"max_users_per_team": 5,
"domains": 2,
}
pytestmark = pytest.mark.django_db
@@ -28,11 +27,9 @@ def test_commands_create_demo():
"""The create_demo management command should create objects as expected."""
call_command("create_demo")
assert models.User.objects.count() == TEST_NB_OBJECTS["users"]
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"]
assert mailbox_models.MailDomainAccess.objects.count() == TEST_NB_OBJECTS["domains"]
assert models.User.objects.count() == 5
assert models.Team.objects.count() == 3
assert models.TeamAccess.objects.count() >= 3
def test_commands_createsuperuser():
Binary file not shown.
+89 -235
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-10-15 10:52+0000\n"
"POT-Creation-Date: 2024-03-21 19:26+0000\n"
"PO-Revision-Date: 2024-01-03 23:15\n"
"Last-Translator: \n"
"Language-Team: French\n"
@@ -17,251 +17,212 @@ msgstr ""
"X-Crowdin-File: backend.pot\n"
"X-Crowdin-File-ID: 2\n"
#: core/admin.py:45
#: core/admin.py:70
msgid "Personal info"
msgstr ""
#: core/admin.py:47
#: core/admin.py:72
msgid "Permissions"
msgstr ""
#: core/admin.py:59
#: core/admin.py:84
msgid "Important dates"
msgstr ""
#: core/admin.py:97
msgid "User"
msgstr ""
#: core/authentication/backends.py:82
#: core/authentication.py:81
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:90
msgid "User account is disabled"
msgstr ""
#: core/authentication/backends.py:102
#: core/authentication.py:114
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/enums.py:23
msgid "Failure"
msgstr ""
#: core/enums.py:24 mailbox_manager/enums.py:20
msgid "Pending"
msgstr ""
#: core/enums.py:25
msgid "Success"
msgstr ""
#: core/models.py:42
#: core/models.py:38
msgid "Member"
msgstr ""
#: core/models.py:43 mailbox_manager/enums.py:13
#: core/models.py:39
msgid "Administrator"
msgstr ""
#: core/models.py:44 mailbox_manager/enums.py:14
#: core/models.py:40
msgid "Owner"
msgstr ""
#: core/models.py:56
#: core/models.py:52
msgid "id"
msgstr ""
#: core/models.py:57
#: core/models.py:53
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:63
#: core/models.py:59
msgid "created at"
msgstr ""
#: core/models.py:64
#: core/models.py:60
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:69
#: core/models.py:65
msgid "updated at"
msgstr ""
#: core/models.py:70
#: core/models.py:66
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:101
#: core/models.py:97
msgid "full name"
msgstr ""
#: core/models.py:102
#: core/models.py:98
msgid "short name"
msgstr ""
#: core/models.py:107
#: core/models.py:103
msgid "contact information"
msgstr ""
#: core/models.py:108
#: core/models.py:104
msgid "A JSON object containing the contact information"
msgstr ""
#: core/models.py:122
#: core/models.py:118
msgid "contact"
msgstr ""
#: core/models.py:123
#: core/models.py:119
msgid "contacts"
msgstr ""
#: core/models.py:167
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
#: core/models.py:173
msgid "sub"
msgstr ""
#: core/models.py:175
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
#: core/models.py:181 core/models.py:489
#: core/models.py:160 core/models.py:263 core/models.py:483
msgid "email address"
msgstr ""
#: core/models.py:182 mailbox_manager/models.py:20
msgid "name"
msgstr ""
#: core/models.py:194
#: core/models.py:172
msgid "language"
msgstr ""
#: core/models.py:195
#: core/models.py:173
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:201
#: core/models.py:179
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:204
#: core/models.py:182
msgid "device"
msgstr ""
#: core/models.py:206
#: core/models.py:184
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:209
#: core/models.py:187
msgid "staff status"
msgstr ""
#: core/models.py:211
#: core/models.py:189
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:214
#: core/models.py:192
msgid "active"
msgstr ""
#: core/models.py:217
#: core/models.py:195
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:229
#: core/models.py:207
msgid "user"
msgstr ""
#: core/models.py:230
#: core/models.py:208
msgid "users"
msgstr ""
#: core/models.py:306
#: core/models.py:248
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
#: core/models.py:255
msgid "sub"
msgstr ""
#: core/models.py:257
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
#: core/models.py:264
msgid "name"
msgstr ""
#: core/models.py:266
msgid "main"
msgstr ""
#: core/models.py:268
msgid "Designates whether the email is the main one."
msgstr ""
#: core/models.py:274
msgid "identity"
msgstr ""
#: core/models.py:275
msgid "identities"
msgstr ""
#: core/models.py:282
msgid "This email address is already declared for this user."
msgstr ""
#: core/models.py:356
msgid "Team"
msgstr ""
#: core/models.py:307
#: core/models.py:357
msgid "Teams"
msgstr ""
#: core/models.py:367
#: core/models.py:417
msgid "Team/user relation"
msgstr ""
#: core/models.py:368
#: core/models.py:418
msgid "Team/user relations"
msgstr ""
#: core/models.py:373
#: core/models.py:423
msgid "This user is already in this team."
msgstr ""
#: core/models.py:462
msgid "url"
msgstr ""
#: core/models.py:463
msgid "secret"
msgstr ""
#: core/models.py:472
msgid "Team webhook"
msgstr ""
#: core/models.py:473
msgid "Team webhooks"
msgstr ""
#: core/models.py:506
#: core/models.py:500
msgid "Team invitation"
msgstr ""
#: core/models.py:507
#: core/models.py:501
msgid "Team invitations"
msgstr ""
#: core/models.py:532
#: core/models.py:526
msgid "This email is already associated to a registered user."
msgstr ""
#: core/models.py:574 core/models.py:580
#: core/models.py:568 core/models.py:573
msgid "Invitation to join Desk!"
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"
@@ -285,7 +246,7 @@ 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 Régie, your new "
"We are delighted to welcome you to our community on Equipes, your new "
"companion to simplify the management of your groups efficiently, "
"intuitively, and securely."
msgstr ""
@@ -299,7 +260,7 @@ msgstr ""
#: core/templates/mail/html/invitation.html:236
#: core/templates/mail/text/invitation.txt:16
msgid "With Régie, you will be able to:"
msgid "With Equipes, you will be able to:"
msgstr ""
#: core/templates/mail/html/invitation.html:237
@@ -333,13 +294,13 @@ msgstr ""
#: core/templates/mail/html/invitation.html:252
#: core/templates/mail/text/invitation.txt:23
msgid "Visit Régie"
msgid "Visit Equipes"
msgstr ""
#: core/templates/mail/html/invitation.html:261
#: core/templates/mail/text/invitation.txt:25
msgid ""
"We are confident that Régie will help you increase efficiency and "
"We are confident that Equipes will help you increase efficiency and "
"productivity while strengthening the bond among members."
msgstr ""
@@ -359,126 +320,19 @@ msgid ""
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 "Cordialement,"
msgstr ""
#: core/templates/mail/html/invitation.html:279
#: core/templates/mail/text/invitation.txt:31
msgid "The La Suite Numérique Team"
msgstr "L'équipe de La Suite Numérique"
#: core/templates/mail/html/new_mailbox.html:159
#: core/templates/mail/text/new_mailbox.txt:3
msgid "La Messagerie"
msgstr "La Messagerie"
#: core/templates/mail/html/new_mailbox.html:188
#: core/templates/mail/text/new_mailbox.txt:5
msgid "Welcome to La Messagerie"
msgstr "Bienvenue dans La Messagerie"
#: 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 "La Messagerie est la solution de mail de La Suite."
#: core/templates/mail/html/new_mailbox.html:199
#: core/templates/mail/text/new_mailbox.txt:7
msgid "Your mailbox has been created."
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 :"
#: core/templates/mail/html/new_mailbox.html:228
#: core/templates/mail/text/new_mailbox.txt:10
msgid "Email address: "
msgstr "Adresse email : "
#: 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 "Mot de passe temporaire (à modifier à la première connexion) : "
#: core/templates/mail/html/new_mailbox.html:261
#: core/templates/mail/text/new_mailbox.txt:13
msgid "Go to La Messagerie"
msgstr "Accéder à La Messagerie"
#: core/templates/mail/html/new_mailbox.html:273
#: core/templates/mail/text/new_mailbox.txt:17
msgid "La Suite Team"
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 ""
#: mailbox_manager/enums.py:12
msgid "Viewer"
msgstr ""
#: mailbox_manager/enums.py:21
msgid "Enabled"
msgstr ""
#: mailbox_manager/enums.py:22
msgid "Failed"
msgstr ""
#: mailbox_manager/enums.py:23
msgid "Disabled"
msgstr ""
#: mailbox_manager/models.py:31
msgid "Mail domain"
msgstr ""
#: mailbox_manager/models.py:32
msgid "Mail domains"
msgstr ""
#: mailbox_manager/models.py:98
msgid "User/mail domain relation"
msgstr ""
#: mailbox_manager/models.py:99
msgid "User/mail domain relations"
msgstr ""
#: mailbox_manager/models.py:171
msgid "local_part"
msgstr ""
#: mailbox_manager/models.py:185
msgid "secondary email address"
msgstr ""
#: mailbox_manager/models.py:190
msgid "Mailbox"
msgstr ""
#: mailbox_manager/models.py:191
msgid "Mailboxes"
msgstr ""
#: mailbox_manager/utils/dimail.py:137
msgid "Your new mailbox information"
msgstr "Informations concernant votre nouvelle boîte mail"
#: people/settings.py:134
#: people/settings.py:133
msgid "English"
msgstr ""
#: people/settings.py:135
#: people/settings.py:134
msgid "French"
msgstr ""
#~ msgid "Regards,"
#~ msgstr "Cordialement,"
-18
View File
@@ -6,14 +6,6 @@ from django.utils.translation import gettext_lazy as _
from mailbox_manager import models
class UserMailDomainAccessInline(admin.TabularInline):
"""Inline admin class for mail domain accesses."""
extra = 0
model = models.MailDomainAccess
readonly_fields = ("created_at", "updated_at", "domain", "user")
@admin.register(models.MailDomain)
class MailDomainAdmin(admin.ModelAdmin):
"""Mail domain admin interface declaration."""
@@ -27,7 +19,6 @@ class MailDomainAdmin(admin.ModelAdmin):
)
search_fields = ("name",)
readonly_fields = ["created_at", "slug"]
inlines = (UserMailDomainAccessInline,)
@admin.register(models.MailDomainAccess)
@@ -43,15 +34,6 @@ class MailDomainAccessAdmin(admin.ModelAdmin):
)
class MailDomainAccessInline(admin.TabularInline):
"""Inline admin class for mail domain accesses."""
extra = 0
autocomplete_fields = ["user", "domain"]
model = models.MailDomainAccess
readonly_fields = ("created_at", "updated_at")
@admin.register(models.Mailbox)
class MailboxAdmin(admin.ModelAdmin):
"""Admin for mailbox model."""
@@ -21,12 +21,3 @@ class MailBoxPermission(core_permissions.IsAuthenticated):
domain = models.MailDomain.objects.get(slug=view.kwargs.get("domain_slug", ""))
abilities = domain.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
class MailDomainAccessRolePermission(core_permissions.IsAuthenticated):
"""Permission class to manage mailboxes for a mail domain"""
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
+6 -125
View File
@@ -1,14 +1,8 @@
"""Client serializers for People's mailbox manager app."""
import json
from rest_framework import serializers
from rest_framework import exceptions, serializers
from core.api.serializers import UserSerializer
from core.models import User
from mailbox_manager import enums, models
from mailbox_manager.utils.dimail import DimailAPIClient
from mailbox_manager import models
class MailboxSerializer(serializers.ModelSerializer):
@@ -17,31 +11,6 @@ class MailboxSerializer(serializers.ModelSerializer):
class Meta:
model = models.Mailbox
fields = ["id", "first_name", "last_name", "local_part", "secondary_email"]
# everything is actually read-only as we do not allow update for now
read_only_fields = ["id"]
def create(self, validated_data):
"""
Override create function to fire a request on mailbox creation.
"""
# send new mailbox request to dimail
client = DimailAPIClient()
response = client.send_mailbox_request(
validated_data, self.context["request"].user.sub
)
# fix format to have actual json, and remove uuid
mailbox_data = json.loads(response.content.decode("utf-8").replace("'", '"'))
del mailbox_data["uuid"]
# actually save mailbox on our database
instance = models.Mailbox.objects.create(**validated_data)
# send confirmation email
client.send_new_mailbox_notification(
recipient=validated_data["secondary_email"], mailbox_data=mailbox_data
)
return instance
class MailDomainSerializer(serializers.ModelSerializer):
@@ -56,7 +25,6 @@ class MailDomainSerializer(serializers.ModelSerializer):
"id",
"name",
"slug",
"status",
"abilities",
"created_at",
"updated_at",
@@ -64,7 +32,6 @@ class MailDomainSerializer(serializers.ModelSerializer):
read_only_fields = [
"id",
"slug",
"status",
"abilities",
"created_at",
"updated_at",
@@ -79,89 +46,7 @@ class MailDomainSerializer(serializers.ModelSerializer):
class MailDomainAccessSerializer(serializers.ModelSerializer):
"""Serialize mail domain access."""
user = UserSerializer(read_only=True, fields=["id", "name", "email"])
can_set_role_to = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.MailDomainAccess
fields = ["id", "user", "role", "can_set_role_to"]
read_only_fields = ["id", "can_set_role_to"]
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
def get_can_set_role_to(self, access):
"""Return roles available to set for the authenticated user"""
return access.get_can_set_role_to(self.context.get("request").user)
def validate(self, attrs):
"""
Check access rights specific to writing (update/create)
"""
request = self.context.get("request")
authenticated_user = getattr(request, "user", None)
role = attrs.get("role")
# Update
if self.instance:
can_set_role_to = self.instance.get_can_set_role_to(authenticated_user)
if role and role not in can_set_role_to:
message = (
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
if can_set_role_to
else "You are not allowed to modify role for this user."
)
raise exceptions.PermissionDenied(message)
# Create
else:
# A domain slug has to be set to create a new access
try:
domain_slug = self.context["domain_slug"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a domain slug in kwargs to create a new domain access."
) from exc
try:
access = authenticated_user.mail_domain_accesses.get(
domain__slug=domain_slug
)
except models.MailDomainAccess.DoesNotExist as exc:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this domain."
) from exc
# Authenticated user must be owner or admin of current domain to set new roles
if access.role not in [
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
]:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this domain."
)
# only an owner can set an owner role to another user
if (
role == enums.MailDomainRoleChoices.OWNER
and access.role != enums.MailDomainRoleChoices.OWNER
):
raise exceptions.PermissionDenied(
"Only owners of a domain can assign other users as owners."
)
attrs["user"] = User.objects.get(pk=self.initial_data["user"])
attrs["domain"] = models.MailDomain.objects.get(
slug=self.context["domain_slug"]
)
return attrs
class MailDomainAccessReadOnlySerializer(MailDomainAccessSerializer):
"""Serialize mail domain access for list and retrieve actions."""
"""Serialize mail domain accesses."""
class Meta:
model = models.MailDomainAccess
@@ -169,11 +54,7 @@ class MailDomainAccessReadOnlySerializer(MailDomainAccessSerializer):
"id",
"user",
"role",
"can_set_role_to",
]
read_only_fields = [
"id",
"user",
"role",
"can_set_role_to",
"created_at",
"updated_at",
]
read_only_fields = ["id"]
+9 -127
View File
@@ -1,12 +1,11 @@
"""API endpoints"""
from django.db.models import Subquery
from rest_framework import exceptions, filters, mixins, viewsets
from rest_framework import filters, mixins, viewsets
from rest_framework import permissions as drf_permissions
from core import models as core_models
from mailbox_manager import enums, models
from mailbox_manager import models
from mailbox_manager.api import permissions, serializers
@@ -55,125 +54,19 @@ class MailDomainViewSet(
# pylint: disable=too-many-ancestors
class MailDomainAccessViewSet(
viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with mail domain accesses.
GET /api/v1.0/mail-domains/<domain_slug>/accesses/:<domain_access_id>
Return list of all domain accesses related to the logged-in user and one
domain access if an id is provided.
POST /api/v1.0/mail-domains/<domain_slug>/accesses/ with expected data:
- user: str
- role: str [owner|admin|viewer]
Return newly created mail domain access
PUT /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/ with expected data:
- role: str [owner|admin|viewer]
Return updated domain access
PATCH /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/ with expected data:
- role: str [owner|admin|viewer]
Return partially updated domain access
DELETE /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/
Delete targeted domain access
MailDomainAccess viewset.
"""
permission_classes = [permissions.MailDomainAccessRolePermission]
permission_classes = [drf_permissions.IsAuthenticated]
serializer_class = serializers.MailDomainAccessSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["role", "user__email", "user__name"]
ordering_fields = ["created_at", "user", "domain", "role"]
ordering = ["-created_at"]
queryset = (
models.MailDomainAccess.objects.all()
.select_related("user")
.order_by("-created_at")
)
list_serializer_class = serializers.MailDomainAccessReadOnlySerializer
detail_serializer_class = serializers.MailDomainAccessSerializer
def get_serializer_class(self):
if self.action in {"list", "retrieve"}:
return self.list_serializer_class
return self.detail_serializer_class
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["domain_slug"] = self.kwargs["domain_slug"]
context["authenticated_user"] = self.request.user
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(domain__slug=self.kwargs["domain_slug"])
if self.action in {"list", "retrieve"}:
# Determine which role the logged-in user has in the domain
user_role_query = models.MailDomainAccess.objects.filter(
user=self.request.user, domain__slug=self.kwargs["domain_slug"]
).values("role")[:1]
queryset = (
# The logged-in user should be part of a domain to see its accesses
queryset.filter(
domain__accesses__user=self.request.user,
)
# Abilities are computed based on logged-in user's role and
# the user role on each domain access
.annotate(
user_role=Subquery(user_role_query),
)
.select_related("user")
.distinct()
)
return queryset
def perform_update(self, serializer):
"""Check that we don't change the role if it leads to losing the last owner."""
instance = serializer.instance
# Check if the role is being updated and the new role is not "owner"
if (
"role" in self.request.data
and self.request.data["role"] != enums.MailDomainRoleChoices.OWNER
):
domain = instance.domain
# Check if the access being updated is the last owner access for the domain
if (
instance.role == enums.MailDomainRoleChoices.OWNER
and domain.accesses.filter(
role=enums.MailDomainRoleChoices.OWNER
).count()
== 1
):
message = "Cannot change the role to a non-owner role for the last owner access."
raise exceptions.PermissionDenied({"role": message})
serializer.save()
def destroy(self, request, *args, **kwargs):
"""Forbid deleting the last owner access"""
instance = self.get_object()
domain = instance.domain
# Check if the access being deleted is the last owner access for the domain
if (
instance.role == enums.MailDomainRoleChoices.OWNER
and domain.accesses.filter(role=enums.MailDomainRoleChoices.OWNER).count()
== 1
):
message = "Cannot delete the last owner access for the domain."
raise exceptions.PermissionDenied({"detail": message})
return super().destroy(request, *args, **kwargs)
queryset = models.MailDomainAccess.objects.all()
class MailBoxViewSet(
@@ -181,18 +74,7 @@ class MailBoxViewSet(
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""MailBox ViewSet
GET /api/<version>/mail-domains/<domain-slug>/mailboxes/
Return a list of mailboxes on the domain
POST /api/<version>/mail-domains/<domain-slug>/mailboxes/ with expected data:
- first_name: str
- last_name: str
- local_part: str
- secondary_email: str
Sends request to email provisioning API and returns newly created mailbox
"""
"""MailBox ViewSet"""
permission_classes = [permissions.MailBoxPermission]
serializer_class = serializers.MailboxSerializer
@@ -1,148 +0,0 @@
"""Management command creating a dimail-api container, for test purposes."""
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
import requests
from rest_framework import status
User = get_user_model()
DIMAIL_URL = "http://host.docker.internal:8001"
admin = {"username": "admin", "password": "admin"}
regie = {"username": "la_regie", "password": "password"}
class Command(BaseCommand):
"""
Management command populate local dimail database, to ease dev
"""
help = "Populate local dimail database, for dev purposes."
def handle(self, *args, **options):
"""Handling of the management command."""
if not settings.DEBUG:
raise CommandError(
("This command is meant to run in local dev environment.")
)
# Create a first superuser for dimail-api container. User creation is usually
# protected behind admin rights but dimail allows to create a first user
# when database is empty
self.create_user(
auth=(None, None),
name=admin["username"],
password=admin["password"],
perms=[],
)
# Create Regie user, auth for all remaining requests
# and your own dev
self.create_user(
auth=(admin["username"], admin["password"]),
name=regie["username"],
password=regie["password"],
perms=["new_domain", "create_users", "manage_users"],
)
# we create a dimail user for keycloak+regie user John Doe
# This way, la Régie will be able to make request in the name of
# this user
people_base_user = User.objects.get(name="John Doe")
self.create_user(name=people_base_user.sub, password="whatever") # noqa S106
# we create a domain and add John Doe to it
domain_name = "test.domain.com"
self.create_domain(domain_name)
self.create_allows(people_base_user.sub, domain_name)
self.stdout.write("DONE", ending="\n")
def create_user(
self,
name,
password,
perms=None,
auth=(regie["username"], regie["password"]),
):
"""
Send a request to create a new user.
"""
response = requests.post(
url=f"{DIMAIL_URL}/users/",
json={
"name": name,
"password": password,
"is_admin": name == admin["username"],
"perms": perms or [],
},
auth=auth,
timeout=10,
)
if response.status_code == status.HTTP_201_CREATED:
self.stdout.write(self.style.SUCCESS(f"Creating user {name}......... OK"))
else:
self.stdout.write(
self.style.ERROR(
f"Creating user {name} ......... failed: {response.json()['detail']}"
)
)
def create_domain(self, name, auth=(regie["username"], regie["password"])):
"""
Send a request to create a new domain.
"""
response = requests.post(
url=f"{DIMAIL_URL}/domains/",
json={
"name": name,
"context_name": "context",
"features": ["webmail", "mailbox", "alias"],
},
auth=auth,
timeout=10,
)
if response.status_code == status.HTTP_201_CREATED:
self.stdout.write(
self.style.SUCCESS(f"Creating domain '{name}' ........ OK")
)
else:
self.stdout.write(
self.style.ERROR(
f"Creating domain '{name}' ........ failed: {response.json()['detail']}"
)
)
def create_allows(self, user, domain, auth=(regie["username"], regie["password"])):
"""
Send a request to create a new allows between user and domain.
"""
response = requests.post(
url=f"{DIMAIL_URL}/allows/",
json={
"domain": domain,
"user": user,
},
auth=auth,
timeout=10,
)
if response.status_code == status.HTTP_201_CREATED:
self.stdout.write(
self.style.SUCCESS(
f"Creating permissions for {user} on {domain} ........ OK"
)
)
else:
self.stdout.write(
self.style.ERROR(
f"Creating permissions for {user} on {domain}\
........ failed: {response.json()['detail']}"
)
)
@@ -1,18 +0,0 @@
# Generated by Django 5.0.6 on 2024-07-01 16:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0010_alter_mailbox_first_name_alter_mailbox_last_name'),
]
operations = [
migrations.AddField(
model_name='maildomain',
name='secret',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='secret'),
),
]
@@ -1,19 +0,0 @@
# Generated by Django 5.1 on 2024-08-30 10:09
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0011_maildomain_secret'),
]
operations = [
migrations.AlterField(
model_name='mailbox',
name='local_part',
field=models.CharField(max_length=150, validators=[django.core.validators.RegexValidator(regex='^[a-zA-Z0-9_.-]+$')], verbose_name='local_part'),
),
]
@@ -1,17 +0,0 @@
# Generated by Django 5.1 on 2024-08-30 12:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0012_alter_mailbox_local_part'),
]
operations = [
migrations.RemoveField(
model_name='maildomain',
name='secret',
),
]
+7 -86
View File
@@ -3,7 +3,8 @@ Declare and configure the models for the People additional application : mailbox
"""
from django.conf import settings
from django.core import exceptions, validators
from django.core import validators
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
@@ -47,6 +48,7 @@ class MailDomain(BaseModel):
"""
Compute and return abilities for a given user on the domain.
"""
is_owner_or_admin = False
role = None
if user.is_authenticated:
@@ -102,65 +104,6 @@ class MailDomainAccess(BaseModel):
def __str__(self):
return f"Access of user {self.user} on domain {self.domain}."
def get_can_set_role_to(self, user):
"""Return roles available to set"""
if not user.is_authenticated:
return []
roles = list(MailDomainRoleChoices)
authenticated_user_role = None
# get role of authenticated user
if hasattr(self, "user_role"):
authenticated_user_role = self.user_role
else:
try:
authenticated_user_role = user.mail_domain_accesses.get(
domain=self.domain
).role
except (MailDomainAccess.DoesNotExist, IndexError):
return []
# only an owner can set an owner role
if authenticated_user_role != MailDomainRoleChoices.OWNER:
roles.remove(MailDomainRoleChoices.OWNER)
# if the user authenticated is a viewer, they can't modify role
# and only an owner can change role of an owner
if authenticated_user_role == MailDomainRoleChoices.VIEWER or (
authenticated_user_role != MailDomainRoleChoices.OWNER
and self.role == MailDomainRoleChoices.OWNER
):
return []
# we only want to return other roles available to change,
# so we remove the current role of current access.
roles.remove(self.role)
return sorted(roles)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the domain access.
"""
role = None
if user.is_authenticated:
try:
role = user.mail_domain_accesses.filter(domain=self.domain).get().role
except (MailDomainAccess.DoesNotExist, IndexError):
role = None
is_owner_or_admin = role in [
MailDomainRoleChoices.OWNER,
MailDomainRoleChoices.ADMIN,
]
return {
"get": bool(role),
"patch": is_owner_or_admin,
"put": is_owner_or_admin,
"post": is_owner_or_admin,
"delete": is_owner_or_admin,
}
class Mailbox(BaseModel):
"""Mailboxes for users from mail domain."""
@@ -172,7 +115,7 @@ class Mailbox(BaseModel):
max_length=150,
null=False,
blank=False,
validators=[validators.RegexValidator(regex="^[a-zA-Z0-9_.-]+$")],
validators=[validators.RegexValidator(regex="^[a-zA-Z0-9_.+-]+$")],
)
domain = models.ForeignKey(
MailDomain,
@@ -194,30 +137,8 @@ class Mailbox(BaseModel):
def __str__(self):
return f"{self.local_part!s}@{self.domain.name:s}"
def clean(self):
"""
Mailboxes can only be created on enabled domains.
Also, mail-provisioning API credentials must be set for dimail to allow auth.
"""
if self.domain.status != MailDomainStatusChoices.ENABLED:
raise exceptions.ValidationError(
"You can create mailbox only for a domain enabled"
)
# Won't be able to query user token if MAIL_PROVISIONING_API_CREDENTIALS are not set
if not settings.MAIL_PROVISIONING_API_CREDENTIALS:
raise exceptions.ValidationError(
"Please configure MAIL_PROVISIONING_API_CREDENTIALS before creating any mailbox."
)
def save(self, *args, **kwargs):
"""
Modification is forbidden for now.
"""
self.full_clean()
if self._state.adding:
return super().save(*args, **kwargs)
# Update is not implemented for now
raise NotImplementedError()
if self.domain.status != MailDomainStatusChoices.ENABLED:
raise ValidationError("You can create mailbox only for a domain enabled")
super().save(*args, **kwargs)
@@ -1,2 +0,0 @@
<h1>403 Forbidden</h1>
{{ exception }}
@@ -52,10 +52,12 @@ def test_api_mail_domains__create_authenticated():
Authenticated users should be able to create mail domains
and should automatically be added as owner of the newly created domain.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/mail-domains/",
{
@@ -63,21 +65,10 @@ def test_api_mail_domains__create_authenticated():
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
# a new domain pending is created and the authenticated user is the owner
domain = models.MailDomain.objects.get()
# response is as expected
assert response.json() == {
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"status": enums.MailDomainStatusChoices.PENDING,
"created_at": domain.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
"abilities": domain.get_abilities(user),
}
# a new domain with status "pending" is created and authenticated user is the owner
assert domain.status == enums.MailDomainStatusChoices.PENDING
assert domain.name == "mydomain.com"
assert domain.accesses.filter(role="owner", user=user).exists()
@@ -81,7 +81,6 @@ def test_api_mail_domains__retrieve_authenticated_related():
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"status": domain.status,
"created_at": domain.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
"abilities": domain.get_abilities(user),
@@ -1,173 +0,0 @@
"""
Test for mail domain accesses API endpoints in People's core app : create
"""
import random
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_create_anonymous():
"""Anonymous users should not be allowed to create mail domain accesses."""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
for role in [role[0] for role in enums.MailDomainRoleChoices.choices]:
response = APIClient().post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.MailDomainAccess.objects.exists() is False
def test_api_mail_domain__accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create domain accesses for a domain to
which they are not related.
"""
user = core_factories.UserFactory()
other_user = core_factories.UserFactory()
domain = factories.MailDomainFactory()
client = APIClient()
client.force_login(user)
for role in [role[0] for role in enums.MailDomainRoleChoices.choices]:
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You are not allowed to manage accesses for this domain."
}
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
def test_api_mail_domain__accesses_create_authenticated_viewer():
"""Viewer of a mail domain should not be allowed to create mail domain accesses."""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
)
other_user = core_factories.UserFactory()
client = APIClient()
client.force_login(authenticated_user)
for role in [role[0] for role in enums.MailDomainRoleChoices.choices]:
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You are not allowed to manage accesses for this domain."
}
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
def test_api_mail_domain__accesses_create_authenticated_administrator():
"""
Administrators of a domain should be able to create mail domain accesses
except for the "owner" role.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
other_user = core_factories.UserFactory()
client = APIClient()
client.force_login(authenticated_user)
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": enums.MailDomainRoleChoices.OWNER,
},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "Only owners of a domain can assign other users as owners."
}
# It should be allowed to create a lower access
for role in [enums.MailDomainRoleChoices.ADMIN, enums.MailDomainRoleChoices.VIEWER]:
other_user = core_factories.UserFactory()
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
new_mail_domain_access = models.MailDomainAccess.objects.filter(
user=other_user
).last()
assert response.json()["id"] == str(new_mail_domain_access.id)
assert response.json()["role"] == role
assert models.MailDomainAccess.objects.filter(domain=mail_domain).count() == 3
def test_api_mail_domain__accesses_create_authenticated_owner():
"""
Owners of a mail domain should be able to create mail domain accesses whatever the role.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.OWNER)]
)
other_user = core_factories.UserFactory()
role = random.choice([role[0] for role in enums.MailDomainRoleChoices.choices])
client = APIClient()
client.force_login(authenticated_user)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert models.MailDomainAccess.objects.filter(user=other_user).count() == 1
new_mail_domain_access = models.MailDomainAccess.objects.filter(
user=other_user
).get()
assert response.json()["id"] == str(new_mail_domain_access.id)
assert response.json()["role"] == role
@@ -1,139 +0,0 @@
"""
Test for mail_domain accesses API endpoints in People's core app : delete
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a mail domain access."""
access = factories.MailDomainAccessFactory()
response = APIClient().delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a mail domain access for a
mail domain to which they are not related.
"""
authenticated_user = core_factories.UserFactory()
access = factories.MailDomainAccessFactory()
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_viewer():
"""
Authenticated users should not be allowed to delete a mail domain access for a
mail domain in which they are a simple viewer.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
)
access = factories.MailDomainAccessFactory(domain=mail_domain)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.MailDomainAccess.objects.count() == 2
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
def test_api_mail_domain__accesses_delete_administrators():
"""
Administrators of a mail domain should be allowed to delete accesses excepted owner accesses.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
for role in [enums.MailDomainRoleChoices.VIEWER, enums.MailDomainRoleChoices.ADMIN]:
access = factories.MailDomainAccessFactory(domain=mail_domain, role=role)
assert models.MailDomainAccess.objects.count() == 2
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_owners():
"""
An owner should be able to delete the mail domain access of another user including
a owner access.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.OWNER)]
)
for role in [role[0] for role in enums.MailDomainRoleChoices.choices]:
access = factories.MailDomainAccessFactory(domain=mail_domain, role=role)
assert models.MailDomainAccess.objects.count() == 2
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_owners_last_owner():
"""
It should not be possible to delete the last owner access from a mail domain
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
access = factories.MailDomainAccessFactory(
domain=mail_domain,
user=authenticated_user,
role=enums.MailDomainRoleChoices.OWNER,
)
factories.MailDomainAccessFactory.create_batch(9)
assert models.MailDomainAccess.objects.count() == 10
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.MailDomainAccess.objects.count() == 10
@@ -1,260 +0,0 @@
"""
Test for mail_domain accesses API endpoints in People's core app : list
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_list_anonymous():
"""Anonymous users should not be allowed to list mail_domain accesses."""
mail_domain = factories.MailDomainFactory()
factories.MailDomainAccessFactory.create_batch(2, domain=mail_domain)
response = APIClient().get(f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_mail_domain__accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list mail_domain accesses for a mail_domain
to which they are not related.
"""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
factories.MailDomainAccessFactory.create_batch(3, domain=mail_domain)
# Accesses for other mail_domains to which the user is related should not be listed either
other_access = factories.MailDomainAccessFactory(user=user)
factories.MailDomainAccessFactory(domain=other_access.domain)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_mail_domain__accesses_list_for_authenticated_user_related_to_domain():
"""
Authenticated users should be able to list mail_domain accesses for a mail_domain
to which they are related, with a given role.
"""
viewer, administrator, owner = core_factories.UserFactory.create_batch(3)
mail_domain = factories.MailDomainFactory()
owner_access = factories.MailDomainAccessFactory.create(
domain=mail_domain, user=owner, role=enums.MailDomainRoleChoices.OWNER
)
admin_access = factories.MailDomainAccessFactory.create(
domain=mail_domain, user=administrator, role=enums.MailDomainRoleChoices.ADMIN
)
viewer_access = models.MailDomainAccess.objects.create(
domain=mail_domain, user=viewer, role=enums.MailDomainRoleChoices.VIEWER
)
admin_expected_data = {
"id": str(admin_access.id),
"user": {
"id": str(administrator.id),
"email": str(administrator.email),
"name": str(administrator.name),
},
"role": str(admin_access.role),
}
viewer_expected_data = {
"id": str(viewer_access.id),
"user": {
"id": str(viewer.id),
"email": str(viewer.email),
"name": str(viewer.name),
},
"role": str(viewer_access.role),
}
owner_expected_data = {
"id": str(owner_access.id),
"user": {
"id": str(owner.id),
"email": str(owner.email),
"name": str(owner.name),
},
"role": str(owner_access.role),
}
# Grant other mail_domain accesses to the user, they should not be listed either
other_access = factories.MailDomainAccessFactory(user=viewer)
factories.MailDomainAccessFactory(domain=other_access.domain)
client = APIClient()
client.force_login(viewer)
# viewer can see accesses but no action is available
admin_expected_data["can_set_role_to"] = []
viewer_expected_data["can_set_role_to"] = []
owner_expected_data["can_set_role_to"] = []
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 3
expected = sorted(
[admin_expected_data, viewer_expected_data, owner_expected_data],
key=lambda x: x["role"],
)
assert sorted(response.json()["results"], key=lambda x: x["role"]) == expected
client.force_login(administrator)
# administrator can see and give new role but not an OWNER role
admin_expected_data["can_set_role_to"] = [enums.MailDomainRoleChoices.VIEWER]
viewer_expected_data["can_set_role_to"] = [enums.MailDomainRoleChoices.ADMIN]
owner_expected_data["can_set_role_to"] = []
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 3
expected = sorted(
[admin_expected_data, viewer_expected_data, owner_expected_data],
key=lambda x: x["role"],
)
assert sorted(response.json()["results"], key=lambda x: x["role"]) == expected
client.force_login(owner)
# owner can do everything
admin_expected_data["can_set_role_to"] = [
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.VIEWER,
]
viewer_expected_data["can_set_role_to"] = [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.OWNER,
]
owner_expected_data["can_set_role_to"] = [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
]
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 3
expected = sorted(
[admin_expected_data, viewer_expected_data, owner_expected_data],
key=lambda x: x["role"],
)
assert sorted(response.json()["results"], key=lambda x: x["role"]) == expected
def test_api_mail_domain__accesses_list_authenticated_constant_numqueries(
django_assert_num_queries,
):
"""
The number of queries should not depend on the amount of fetched accesses.
"""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
models.MailDomainAccess.objects.create(domain=mail_domain, user=user) # random role
client = APIClient()
client.force_login(user)
# Only 3 queries are needed to efficiently fetch mail_domain accesses,
# related users :
# - query retrieving logged-in user for user_role annotation
# - count from pagination
# - distinct from viewset
with django_assert_num_queries(3):
client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
# create 20 new mail_domain accesses
for _ in range(20):
factories.MailDomainAccessFactory(domain=mail_domain)
# num queries should still be the same
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 21
def test_api_mail_domain__accesses_list_authenticated_ordering():
"""MailDomain accesses can be ordered by "role"."""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
models.MailDomainAccess.objects.create(domain=mail_domain, user=user)
# create 20 new mail_domain accesses
for _ in range(20):
factories.MailDomainAccessFactory(domain=mail_domain)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/?ordering=role",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 21
results = [access["role"] for access in response.json()["results"]]
assert sorted(results) == results
# check results when we change ordering
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/?ordering=-role",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 21
results = [access["role"] for access in response.json()["results"]]
assert sorted(results, reverse=True) == results
@pytest.mark.parametrize("ordering_field", ["email", "name"])
def test_api_mail_domain__accesses_list_authenticated_ordering_user(ordering_field):
"""Mail domain accesses can be ordered by user's fields."""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
models.MailDomainAccess.objects.create(domain=mail_domain, user=user)
for _ in range(20):
factories.MailDomainAccessFactory(domain=mail_domain)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/?ordering=user__{ordering_field}",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 21
def normalize(x):
"""Mimic Django order_by, which is case-insensitive and space-insensitive"""
return x.casefold().replace(" ", "")
results = [access["user"][ordering_field] for access in response.json()["results"]]
assert sorted(results, key=normalize) == results
@@ -1,118 +0,0 @@
"""
Test for mail_domain accesses API endpoints in People's core app : retrieve
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a mail_domain access.
"""
access = factories.MailDomainAccessFactory()
response = APIClient().get(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_mail_domain__accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a mail_domain access for
a mail_domain to which they are not related.
"""
user = core_factories.UserFactory()
access = factories.MailDomainAccessFactory(domain=factories.MailDomainFactory())
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomainAccess matches the given query."}
# Accesses related to another mail_domain should be excluded even if the user is related to it
for other_access in [
factories.MailDomainAccessFactory(),
factories.MailDomainAccessFactory(user=user),
]:
response = client.get(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{other_access.id!s}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"detail": "No MailDomainAccess matches the given query."
}
def test_api_mail_domain__accesses_retrieve_authenticated_related():
"""
A user who is related to a mail_domain should be allowed to retrieve the
associated mail_domain user accesses.
"""
owner = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
access = factories.MailDomainAccessFactory(
domain=mail_domain, user=owner, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(owner)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
results = {
"id": str(access.id),
"user": {
"id": str(access.user.id),
"email": str(owner.email),
"name": str(owner.name),
},
"role": str(access.role),
"can_set_role_to": [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
],
}
assert response.status_code == status.HTTP_200_OK
assert response.json() == results
admin = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.ADMIN
).user
client.force_login(admin)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
# admin can't change role of an owner
results["can_set_role_to"] = []
assert response.status_code == status.HTTP_200_OK
assert response.json() == results
viewer = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.VIEWER
).user
client.force_login(viewer)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
# viewer can't change anyone's role
results["can_set_role_to"] = []
assert response.status_code == status.HTTP_200_OK
assert response.json() == results
@@ -1,295 +0,0 @@
"""
Test for mail_domain accesses API endpoints in People's core app : update
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_update_anonymous():
"""An anonymous users should not be allowed to update a mail domain access."""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.VIEWER)
response = APIClient().put(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
data={"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.VIEWER
def test_api_mail_domain__accesses_update_authenticated_unrelated():
"""
An authenticated user should not be allowed to update a mail domain access
for a mail_domain to which they are not related.
"""
authenticated_user = core_factories.UserFactory()
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.VIEWER)
client = APIClient()
client.force_login(authenticated_user)
response = client.put(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
{"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.VIEWER
def test_api_mail_domain__accesses_update_authenticated_viewer():
"""A viewer of a mail domain should not be allowed to update accesses."""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
)
access = factories.MailDomainAccessFactory(
domain=mail_domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(authenticated_user)
response = client.put(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
{"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.VIEWER
def test_api_mail_domain__accesses_update_authenticated_viewer_themself():
"""A viewer of a mail domain should not be allowed to update its accesses."""
authenticated_user = core_factories.UserFactory()
access = factories.MailDomainAccessFactory(
user=authenticated_user,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(authenticated_user)
response = client.put(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
{"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.VIEWER
def test_api_mail_domain__accesses_update_administrator_except_owner():
"""
An administrator of a mail domain should be allowed to update a user
access for this mail domain, as long as they don't try to set the role to owner.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
admin_access = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.ADMIN
)
viewer_access = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.VIEWER
)
client = APIClient()
client.force_login(authenticated_user)
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{admin_access.id!s}/",
data={"role": enums.MailDomainRoleChoices.OWNER},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
admin_access.refresh_from_db()
assert admin_access.role == enums.MailDomainRoleChoices.ADMIN
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{admin_access.id!s}/",
data={"role": enums.MailDomainRoleChoices.VIEWER},
format="json",
)
assert response.status_code == status.HTTP_200_OK
admin_access.refresh_from_db()
assert admin_access.role == enums.MailDomainRoleChoices.VIEWER
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{viewer_access.id!s}/",
data={"role": enums.MailDomainRoleChoices.OWNER},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
viewer_access.refresh_from_db()
assert viewer_access.role == enums.MailDomainRoleChoices.VIEWER
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{viewer_access.id!s}/",
data={"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_200_OK
viewer_access.refresh_from_db()
assert viewer_access.role == enums.MailDomainRoleChoices.ADMIN
def test_api_mail_domain__accesses_update_administrator_from_owner():
"""
An administrator for a mail domain, should not be allowed to update
the user access of an "owner" for this mail domain.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
owner = core_factories.UserFactory()
owner_access = factories.MailDomainAccessFactory(
domain=mail_domain, user=owner, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(authenticated_user)
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{owner_access.id!s}/",
data={"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
owner_access.refresh_from_db()
assert owner_access.role == enums.MailDomainRoleChoices.OWNER
def test_api_mail_domain__accesses_update_owner():
"""
An owner of a mail domain should be allowed to update
a user access for this domain.
"""
owner_authenticated = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(owner_authenticated, enums.MailDomainRoleChoices.OWNER)]
)
user_access1 = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.ADMIN
)
user_access2 = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.VIEWER
)
client = APIClient()
client.force_login(owner_authenticated)
# turn admin in viewer
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{user_access1.id!s}/",
data={"role": enums.MailDomainRoleChoices.VIEWER},
format="json",
)
assert response.status_code == status.HTTP_200_OK
user_access1.refresh_from_db()
assert user_access1.role == enums.MailDomainRoleChoices.VIEWER
# turn viewer in owner
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{user_access1.id!s}/",
data={"role": enums.MailDomainRoleChoices.OWNER},
format="json",
)
assert response.status_code == status.HTTP_200_OK
user_access1.refresh_from_db()
assert user_access1.role == enums.MailDomainRoleChoices.OWNER
# turn viewer in admin
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{user_access2.id!s}/",
data={"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_200_OK
user_access2.refresh_from_db()
assert user_access2.role == enums.MailDomainRoleChoices.ADMIN
def test_api_mail_domain__accesses_update_owner_for_owners():
"""
An owner of a mail domain should be allowed to update
an existing owner access for this mail domain.
"""
owner_authenticated = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(owner_authenticated, enums.MailDomainRoleChoices.OWNER)]
)
other_owner_access = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(owner_authenticated)
for new_role in [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
]:
response = client.patch(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{other_owner_access.id!s}/",
data={"role": new_role},
format="json",
)
assert response.status_code == status.HTTP_200_OK
other_owner_access.refresh_from_db()
assert other_owner_access.role == new_role
def test_api_mail_domain__accesses_update_owner_self():
"""
An owner of a mail domain should be allowed to update
their own user access provided there are other owners in the mail domain.
"""
owner_authenticated = core_factories.UserFactory()
access = factories.MailDomainAccessFactory(
user=owner_authenticated, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(owner_authenticated)
for new_role in [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
]:
response = client.patch(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
data={"role": new_role},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert (
response.json()["role"]
== "Cannot change the role to a non-owner role for the last owner access."
)
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.OWNER
# Add another owner and it should now work
factories.MailDomainAccessFactory(
domain=access.domain, role=enums.MailDomainRoleChoices.OWNER
)
for new_role in [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
]:
response = client.patch(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
data={"role": new_role},
format="json",
)
assert response.status_code == status.HTTP_200_OK
access.refresh_from_db()
assert access.role == new_role
@@ -2,16 +2,7 @@
Unit tests for the mailbox API
"""
import json
import re
from logging import Logger
from unittest import mock
from django.test.utils import override_settings
from django.utils.translation import gettext_lazy as _
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
@@ -84,7 +75,10 @@ def test_api_mailboxes__create_viewer_failure():
@pytest.mark.parametrize(
"role",
[enums.MailDomainRoleChoices.OWNER, enums.MailDomainRoleChoices.ADMIN],
[
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
],
)
def test_api_mailboxes__create_roles_success(role):
"""Users with owner or admin role should be able to create mailbox on the mail domain."""
@@ -97,33 +91,11 @@ def test_api_mailboxes__create_roles_success(role):
mailbox_values = serializers.MailboxSerializer(
factories.MailboxFactory.build()
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{mail_domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_values['local_part']}@{mail_domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
mailbox = models.Mailbox.objects.get()
@@ -141,7 +113,10 @@ def test_api_mailboxes__create_roles_success(role):
@pytest.mark.parametrize(
"role",
[enums.MailDomainRoleChoices.OWNER, enums.MailDomainRoleChoices.ADMIN],
[
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
],
)
def test_api_mailboxes__create_with_accent_success(role):
"""Users with proper abilities should be able to create mailbox on the mail domain with a
@@ -155,33 +130,12 @@ def test_api_mailboxes__create_with_accent_success(role):
mailbox_values = serializers.MailboxSerializer(
factories.MailboxFactory.build(first_name="Aimé")
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{mail_domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_values['local_part']}@{mail_domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
mailbox = models.Mailbox.objects.get()
@@ -233,435 +187,3 @@ def test_api_mailboxes__create_administrator_missing_fields():
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not models.Mailbox.objects.exists()
assert response.json() == {"secondary_email": ["This field is required."]}
### REACTING TO DIMAIL-API
### We mock dimail's responses to avoid testing dimail's container too
def test_api_mailboxes__unrelated_user_provisioning_api_not_called():
"""
Provisioning API should not be called if an user tries
to create a mailbox on a domain they have no access to.
"""
domain = factories.MailDomainEnabledFactory()
client = APIClient()
client.force_login(core_factories.UserFactory()) # user with no access
body_values = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=domain)
).data
with responses.RequestsMock():
# We add no simulated response in RequestsMock
# because we expected no "outside" calls to be made
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/mailboxes/",
body_values,
format="json",
)
# No exception raised by RequestsMock means no call was sent
# our API blocked the request before sending it
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_api_mailboxes__domain_viewer_provisioning_api_not_called():
"""
Provisioning API should not be called if a domain viewer tries
to create a mailbox on a domain they are not owner/admin of.
"""
access = factories.MailDomainAccessFactory(
domain=factories.MailDomainEnabledFactory(),
user=core_factories.UserFactory(),
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(access.user)
body_values = serializers.MailboxSerializer(factories.MailboxFactory.build()).data
with responses.RequestsMock():
# We add no simulated response in RequestsMock
# because we expected no "outside" calls to be made
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
body_values,
format="json",
)
# No exception raised by RequestsMock means no call was sent
# our API blocked the request before sending it
assert response.status_code == status.HTTP_403_FORBIDDEN
@mock.patch.object(Logger, "error")
def test_api_mailboxes__async_dimail_unauthorized(mock_error):
"""
Dimail should raise an error if token has been successfully granted
but mailbox creation request returns a 403.
i.e. user exists on dimail-api but has no permission on that domain
"""
# creating all needed objects
# this access somehow exists solely in our database but not in dimail
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=access.domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK, # user is in dimail-api
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(
rf".*/domains/{access.domain.name}/mailboxes/{mailbox_data['local_part']}"
),
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert mock_error.call_count == 1
assert mock_error.call_args_list[0][0] == (
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
access.domain.name,
)
@pytest.mark.parametrize(
"role",
[enums.MailDomainRoleChoices.ADMIN, enums.MailDomainRoleChoices.OWNER],
)
def test_api_mailboxes__domain_owner_or_admin_successful_creation_and_provisioning(
role,
):
"""
Domain owner/admin should be able to create mailboxes.
Provisioning API should be called when owner/admin makes a call.
Succesfull 201 response from dimail should trigger mailbox creation on our side.
"""
# creating all needed objects
access = factories.MailDomainAccessFactory(role=role)
client = APIClient()
client.force_login(access.user)
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=access.domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsp = rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
# Checks payload sent to email-provisioning API
payload = json.loads(rsps.calls[1].request.body)
assert payload == {
"displayName": f"{mailbox_data['first_name']} {mailbox_data['last_name']}",
"givenName": mailbox_data["first_name"],
"surName": mailbox_data["last_name"],
}
# Checks response
assert response.status_code == status.HTTP_201_CREATED
assert rsp.call_count == 1
mailbox = models.Mailbox.objects.get()
assert response.json() == {
"id": str(mailbox.id),
"first_name": str(mailbox_data["first_name"]),
"last_name": str(mailbox_data["last_name"]),
"local_part": str(mailbox_data["local_part"]),
"secondary_email": str(mailbox_data["secondary_email"]),
}
assert mailbox.first_name == mailbox_data["first_name"]
assert mailbox.last_name == mailbox_data["last_name"]
assert mailbox.local_part == mailbox_data["local_part"]
assert mailbox.secondary_email == mailbox_data["secondary_email"]
@override_settings(MAIL_PROVISIONING_API_CREDENTIALS="wrongCredentials")
def test_api_mailboxes__dimail_token_permission_denied():
"""
API should raise a clear "permission denied" error
when receiving a permission denied from dimail upon requesting token.
"""
# creating all needed objects
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=access.domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"details": "Permission denied"}',
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "Token denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
}
assert not models.Mailbox.objects.exists()
def test_api_mailboxes__user_unrelated_to_domain():
"""
API should raise a clear "permission denied" when dimail returns a permission denied
on mailbox creation. This means token was granted for this user
but user is not allowed to modify this domain (i.e. not owner)
"""
# creating all needed objects
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=access.domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body='{"details": "Permission denied"}',
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
}
assert not models.Mailbox.objects.exists()
@mock.patch.object(Logger, "error")
def test_api_mailboxes__handling_dimail_unexpected_error(mock_error):
"""
API should raise a clear error when dimail returns an unexpected response.
"""
# creating all needed objects
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=access.domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body='{"details": "Internal server error"}',
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
content_type="application/json",
)
with pytest.raises(SystemError):
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.json() == {
"detail": "Unexpected response from dimail: {'details': 'Internal server error'}"
}
assert not models.Mailbox.objects.exists()
# Check error logger was called
assert mock_error.called
assert mock_error.call_args_list[1][0] == (
'Unexpected response from dimail: 500 {"details": "Internal server error"}',
)
@mock.patch.object(Logger, "error")
@mock.patch.object(Logger, "info")
def test_api_mailboxes__send_correct_logger_infos(mock_info, mock_error):
"""
Upon requesting mailbox creation, la régie should impersonate
querying user in dimail and log things correctly.
"""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=access.domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
# user sub is sent to payload as a parameter
assert rsps.calls[0].request.params == {"username": access.user.sub}
# Logger
assert not mock_error.called
assert mock_info.call_count == 4
# a new empty error has been added. To be investigated
assert mock_info.call_args_list[0][0] == (
"Token succesfully granted by mail-provisioning API.",
)
assert mock_info.call_args_list[1][0] == (
"Mailbox successfully created on domain %s by user %s",
str(access.domain),
access.user.sub,
)
@mock.patch.object(Logger, "info")
def test_api_mailboxes__sends_new_mailbox_notification(mock_info):
"""
Creating a new mailbox should send confirmation email
to secondary email.
"""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=access.domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
with mock.patch("django.core.mail.send_mail") as mock_send:
client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
assert mock_send.call_count == 1
assert mock_send.mock_calls[0][1][0] == "Your new mailbox information"
assert mock_send.mock_calls[0][1][3][0] == mailbox_data["secondary_email"]
assert mock_info.call_count == 4
assert mock_info.call_args_list[2][0] == (
"Information for mailbox %s sent to %s.",
f"{mailbox_data['local_part']}@{access.domain.name}",
mailbox_data["secondary_email"],
)
@@ -78,17 +78,3 @@ def test_api_mailboxes__list_roles(role):
"secondary_email": str(mailbox1.secondary_email),
},
]
def test_api_mailboxes__list_non_existing():
"""
User gets a 404 when trying to list mailboxes of a domain which does not exist.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.MailboxFactory.create_batch(5)
response = client.get("/api/v1.0/mail-domains/nonexistent.domain/mailboxes/")
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -2,27 +2,27 @@
Unit tests for the mailbox model
"""
from django.core import exceptions
from django.test.utils import override_settings
from django.core.exceptions import ValidationError
import pytest
from mailbox_manager import enums, factories, models
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
# LOCAL PART FIELD
def test_models_mailboxes__local_part_cannot_be_empty():
"""The "local_part" field should not be empty."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
with pytest.raises(ValidationError, match="This field cannot be blank"):
factories.MailboxFactory(local_part="")
def test_models_mailboxes__local_part_cannot_be_null():
"""The "local_part" field should not be null."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be null"):
with pytest.raises(ValidationError, match="This field cannot be null"):
factories.MailboxFactory(local_part=None)
@@ -31,14 +31,13 @@ def test_models_mailboxes__local_part_matches_expected_format():
The local part should contain alpha-numeric caracters
and a limited set of special caracters ("+", "-", ".", "_").
"""
# "-", ".", "_" are allowed
factories.MailboxFactory(local_part="Marie-Jose.Perec_2024")
factories.MailboxFactory(local_part="Marie-Jose.Perec+JO_2024")
# other special characters should raise a validation error
# "+" included, as this test is about mail creation
for character in ["+", "@", "!", "$", "%", " "]:
with pytest.raises(exceptions.ValidationError, match="Enter a valid value"):
factories.MailboxFactory(local_part=f"marie{character}jo")
with pytest.raises(ValidationError, match="Enter a valid value"):
factories.MailboxFactory(local_part="mariejo@unnecessarydomain.com")
with pytest.raises(ValidationError, match="Enter a valid value"):
factories.MailboxFactory(local_part="!")
def test_models_mailboxes__local_part_unique_per_domain():
@@ -51,8 +50,7 @@ def test_models_mailboxes__local_part_unique_per_domain():
# same local part on the same domain should not be possible
with pytest.raises(
exceptions.ValidationError,
match="Mailbox with this Local_part and Domain already exists.",
ValidationError, match="Mailbox with this Local_part and Domain already exists."
):
factories.MailboxFactory(
local_part=existing_mailbox.local_part, domain=existing_mailbox.domain
@@ -74,7 +72,7 @@ def test_models_mailboxes__domain_must_be_a_maildomain_instance():
def test_models_mailboxes__domain_cannot_be_null():
"""The "domain" field should not be null."""
with pytest.raises(models.MailDomain.DoesNotExist, match="Mailbox has no domain."):
with pytest.raises(ValidationError, match="This field cannot be null"):
factories.MailboxFactory(domain=None)
@@ -83,13 +81,13 @@ def test_models_mailboxes__domain_cannot_be_null():
def test_models_mailboxes__secondary_email_cannot_be_empty():
"""The "secondary_email" field should not be empty."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
with pytest.raises(ValidationError, match="This field cannot be blank"):
factories.MailboxFactory(secondary_email="")
def test_models_mailboxes__secondary_email_cannot_be_null():
"""The "secondary_email" field should not be null."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be null"):
with pytest.raises(ValidationError, match="This field cannot be null"):
factories.MailboxFactory(secondary_email=None)
@@ -97,8 +95,7 @@ def test_models_mailboxes__cannot_be_created_for_disabled_maildomain():
"""Mailbox creation is allowed only for a domain enabled.
A disabled status for the mail domain raises an error."""
with pytest.raises(
exceptions.ValidationError,
match="You can create mailbox only for a domain enabled",
ValidationError, match="You can create mailbox only for a domain enabled"
):
factories.MailboxFactory(
domain=factories.MailDomainFactory(
@@ -111,8 +108,7 @@ def test_models_mailboxes__cannot_be_created_for_failed_maildomain():
"""Mailbox creation is allowed only for a domain enabled.
A failed status for the mail domain raises an error."""
with pytest.raises(
exceptions.ValidationError,
match="You can create mailbox only for a domain enabled",
ValidationError, match="You can create mailbox only for a domain enabled"
):
factories.MailboxFactory(
domain=factories.MailDomainFactory(
@@ -125,28 +121,8 @@ def test_models_mailboxes__cannot_be_created_for_pending_maildomain():
"""Mailbox creation is allowed only for a domain enabled.
A pending status for the mail domain raises an error."""
with pytest.raises(
exceptions.ValidationError,
match="You can create mailbox only for a domain enabled",
ValidationError, match="You can create mailbox only for a domain enabled"
):
# MailDomainFactory initializes a mail domain with default values,
# so mail domain status is pending!
factories.MailboxFactory(domain=factories.MailDomainFactory())
### REACTING TO DIMAIL-API
### We mock dimail's responses to avoid testing dimail's container too
@override_settings(MAIL_PROVISIONING_API_CREDENTIALS=None)
def test_models_mailboxes__dimail_no_credentials():
"""
If MAIL_PROVISIONING_API_CREDENTIALS setting is not configured,
trying to create a mailbox should raise an error.
"""
domain = factories.MailDomainEnabledFactory()
with pytest.raises(
exceptions.ValidationError,
match="Please configure MAIL_PROVISIONING_API_CREDENTIALS before creating any mailbox.",
):
factories.MailboxFactory(domain=domain)
-165
View File
@@ -1,165 +0,0 @@
"""A minimalist client to synchronize with mailbox provisioning API."""
import smtplib
from logging import getLogger
from django.conf import settings
from django.contrib.sites.models import Site
from django.core import exceptions, mail
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
import requests
from rest_framework import status
from urllib3.util import Retry
logger = getLogger(__name__)
adapter = requests.adapters.HTTPAdapter(
max_retries=Retry(
total=4,
backoff_factor=0.1,
status_forcelist=[500, 502],
allowed_methods=["PATCH"],
)
)
session = requests.Session()
session.mount("http://", adapter)
session.mount("https://", adapter)
class DimailAPIClient:
"""A dimail-API client."""
API_URL = settings.MAIL_PROVISIONING_API_URL
API_CREDENTIALS = settings.MAIL_PROVISIONING_API_CREDENTIALS
def get_headers(self, user_sub=None):
"""
Build headers dictionary. Requires MAIL_PROVISIONING_API_CREDENTIALS setting,
to get a token from dimail /token/ endpoint.
If provided, request user' sub is used for la regie to log in as this user,
thus allowing for more precise logs.
"""
headers = {"Content-Type": "application/json"}
params = None
if user_sub:
params = {"username": str(user_sub)}
response = requests.get(
f"{self.API_URL}/token/",
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
params=params,
timeout=20,
)
if response.status_code == status.HTTP_200_OK:
headers["Authorization"] = f"Bearer {response.json()['access_token']}"
logger.info("Token succesfully granted by mail-provisioning API.")
return headers
if response.status_code == status.HTTP_403_FORBIDDEN:
logger.error(
"[DIMAIL] 403 Forbidden: Could not retrieve a token,\
please check 'MAIL_PROVISIONING_API_CREDENTIALS' setting.",
)
raise exceptions.PermissionDenied(
"Token denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
return self.pass_dimail_unexpected_response(response)
def send_mailbox_request(self, mailbox, user_sub=None):
"""Send a CREATE mailbox request to mail provisioning API."""
payload = {
"givenName": mailbox["first_name"],
"surName": mailbox["last_name"],
"displayName": f"{mailbox['first_name']} {mailbox['last_name']}",
}
headers = self.get_headers(user_sub)
try:
response = session.post(
f"{self.API_URL}/domains/{mailbox['domain']}/mailboxes/{mailbox['local_part']}",
json=payload,
headers=headers,
verify=True,
timeout=10,
)
except requests.exceptions.ConnectionError as error:
logger.error(
"Connection error while trying to reach %s.",
self.API_URL,
exc_info=error,
)
raise error
if response.status_code == status.HTTP_201_CREATED:
logger.info(
"Mailbox successfully created on domain %s by user %s",
str(mailbox["domain"]),
user_sub,
)
return response
if response.status_code == status.HTTP_403_FORBIDDEN:
logger.error(
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
str(mailbox["domain"]),
)
raise exceptions.PermissionDenied(
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
return self.pass_dimail_unexpected_response(response)
def pass_dimail_unexpected_response(self, response):
"""Raise error when encountering an unexpected error in dimail."""
error_content = response.content.decode("utf-8")
logger.error(
"[DIMAIL] unexpected error : %s %s", response.status_code, error_content
)
raise SystemError(
f"Unexpected response from dimail: {response.status_code} {error_content}"
)
def send_new_mailbox_notification(self, recipient, mailbox_data):
"""
Send email to confirm mailbox creation
and send new mailbox information.
"""
template_vars = {
"title": _("Your new mailbox information"),
"site": Site.objects.get_current(),
"webmail_url": settings.WEBMAIL_URL,
"mailbox_data": mailbox_data,
}
msg_html = render_to_string("mail/html/new_mailbox.html", template_vars)
msg_plain = render_to_string("mail/text/new_mailbox.txt", template_vars)
try:
mail.send_mail(
template_vars["title"],
msg_plain,
settings.EMAIL_FROM,
[recipient],
html_message=msg_html,
fail_silently=False,
)
logger.info(
"Information for mailbox %s sent to %s.",
mailbox_data["email"],
recipient,
)
except smtplib.SMTPException as exception:
logger.error(
"Mailbox confirmation email to %s was not sent: %s",
recipient,
exception,
)
-3
View File
@@ -7,7 +7,6 @@ from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.authentication.urls import urlpatterns as oidc_urls
from core.resource_server.urls import urlpatterns as resource_server_urls
# - Main endpoints
router = DefaultRouter()
@@ -37,7 +36,6 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
*resource_server_urls,
re_path(
r"^teams/(?P<team_id>[0-9a-z-]*)/",
include(team_related_router.urls),
@@ -46,5 +44,4 @@ urlpatterns = [
),
),
path(f"api/{settings.API_VERSION}/", include("mailbox_manager.urls")),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
]
+1 -114
View File
@@ -218,7 +218,6 @@ class Base(Configuration):
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"core.resource_server.authentication.ResourceServerAuthentication",
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
"rest_framework.authentication.SessionAuthentication",
),
@@ -271,6 +270,7 @@ class Base(Configuration):
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("from@example.com")
AUTH_USER_MODEL = "core.User"
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
@@ -286,7 +286,6 @@ class Base(Configuration):
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
THUMBNAIL_ALIASES = {}
# Celery
@@ -316,9 +315,6 @@ class Base(Configuration):
OIDC_OP_JWKS_ENDPOINT = values.Value(
environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None
)
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
)
OIDC_OP_AUTHORIZATION_ENDPOINT = values.Value(
environ_name="OIDC_OP_AUTHORIZATION_ENDPOINT", environ_prefix=None
)
@@ -361,26 +357,6 @@ class Base(Configuration):
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
OIDC_RS_PRIVATE_KEY_STR = values.Value(
default=None,
environ_name="OIDC_RS_PRIVATE_KEY_STR",
environ_prefix=None,
)
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
default="RSA",
environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE",
environ_prefix=None,
)
OIDC_RS_ENCRYPTION_ALGO = values.Value(
default="RSA-OAEP",
environ_name="OIDC_RS_ENCRYPTION_ALGO",
environ_prefix=None,
)
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
default="A256GCM",
environ_name="OIDC_RS_ENCRYPTION_ENCODING",
environ_prefix=None,
)
USER_OIDC_FIELDS_TO_NAME = values.ListValue(
default=["first_name", "last_name"],
@@ -388,60 +364,6 @@ class Base(Configuration):
environ_prefix=None,
)
OIDC_OP_TOKEN_INTROSPECTION_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_TOKEN_INTROSPECTION_ENDPOINT", environ_prefix=None
)
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
OIDC_RS_CLIENT_ID = values.Value(
None, environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
)
OIDC_RS_CLIENT_SECRET = values.Value(
None,
environ_name="OIDC_RS_CLIENT_SECRET",
environ_prefix=None,
)
OIDC_RS_SIGNING_ALGO = values.Value(
default="ES256", environ_name="OIDC_RS_SIGNING_ALG0", environ_prefix=None
)
OIDC_RS_SCOPES = values.ListValue(
["groups"], environ_name="OIDC_RS_SCOPES", environ_prefix=None
)
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
OIDC_VERIFY_SSL = values.BooleanValue(
True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
)
OIDC_TIMEOUT = values.Value(None, environ_name="OIDC_TIMEOUT", environ_prefix=None)
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
default=True,
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
environ_prefix=None,
)
# MAILBOX-PROVISIONING API
WEBMAIL_URL = values.Value(
default=None,
environ_name="WEBMAIL_URL",
environ_prefix=None,
)
MAIL_PROVISIONING_API_URL = values.Value(
default="http://host.docker.internal:8001",
environ_name="MAIL_PROVISIONING_API_URL",
environ_prefix=None,
)
MAIL_PROVISIONING_API_CREDENTIALS = values.Value(
default=None,
environ_name="MAIL_PROVISIONING_API_CREDENTIALS",
environ_prefix=None,
)
FEATURES = {
"TEAMS": values.BooleanValue(
default=True, environ_name="FEATURE_TEAMS", environ_prefix=None
),
}
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -487,8 +409,6 @@ class Base(Configuration):
environment=cls.__name__.lower(),
release=get_release(),
integrations=[DjangoIntegration()],
traces_sample_rate=0.1,
default_integrations=False,
)
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
@@ -531,24 +451,6 @@ class Development(Base):
USE_SWAGGER = True
LOGGING = values.DictValue(
{
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"core": {
"handlers": ["console"],
"level": "DEBUG",
},
},
}
)
def __init__(self):
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["django_extensions", "drf_spectacular_sidecar"]
@@ -590,9 +492,6 @@ class Test(Base):
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
# this is a dev credentials for mail provisioning API
MAIL_PROVISIONING_API_CREDENTIALS = "bGFfcmVnaWU6cGFzc3dvcmQ="
class ContinuousIntegration(Test):
"""
@@ -627,14 +526,6 @@ class Production(Base):
#
# In other cases, you should comment the following line to avoid security issues.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = 60
SECURE_HSTS_PRELOAD = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [
"^__lbheartbeat__",
"^__heartbeat__",
]
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True
@@ -681,10 +572,6 @@ class Production(Base):
},
},
}
SENTRY_DSN = values.Value(
"https://b72746c73d669421e7a8ccd3fab0fad2@sentry.incubateur.net/171",
environ_name="SENTRY_DSN",
)
class Feature(Production):
+18 -19
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "people"
version = "1.4.1"
version = "0.1.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,31 +25,30 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.44",
"boto3==1.34.153",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.5.0",
"django-cors-headers==4.4.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.1.1",
"redis==5.0.8",
"django-redis==5.4.0",
"django-storages==1.14.4",
"django-timezone-field>=5.1",
"django==5.1.2",
"django==5.0.8",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"gunicorn==23.0.0",
"easy_thumbnails==2.9",
"factory_boy==3.3.0",
"gunicorn==22.0.0",
"jsonschema==4.23.0",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.3",
"psycopg[binary]==3.2.1",
"PyJWT==2.9.0",
"joserfc==1.0.0",
"requests==2.32.3",
"sentry-sdk[django]==2.17.0",
"sentry-sdk==2.12.0",
"url-normalize==1.4.3",
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
@@ -66,18 +65,18 @@ dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"ipdb==0.13.13",
"ipython==8.28.0",
"pyfakefs==5.7.1",
"pylint-django==2.6.1",
"pylint==3.3.1",
"ipython==8.26.0",
"pyfakefs==5.6.0",
"pylint-django==2.5.5",
"pylint==3.2.6",
"pytest-cov==5.0.0",
"pytest-django==4.9.0",
"pytest==8.3.3",
"pytest-django==4.8.0",
"pytest==8.3.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.7.0",
"types-requests==2.32.0.20241016",
"ruff==0.5.6",
"types-requests==2.32.0.20240712",
"freezegun==1.5.1",
]
-5
View File
@@ -1,5 +1,3 @@
const path = require('path');
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
@@ -7,9 +5,6 @@ const nextConfig = {
images: {
unoptimized: true,
},
sassOptions: {
includePaths: [path.join(__dirname, 'src')],
},
compiler: {
// Enables the styled-components SWC transform
styledComponents: true,
+19 -20
View File
@@ -1,6 +1,6 @@
{
"name": "app-desk",
"version": "1.4.1",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -17,36 +17,35 @@
"dependencies": {
"@gouvfr-lasuite/integration": "1.0.2",
"@hookform/resolvers": "3.9.0",
"@openfun/cunningham-react": "2.9.4",
"@tanstack/react-query": "5.56.2",
"i18next": "23.15.1",
"@openfun/cunningham-react": "2.9.3",
"@tanstack/react-query": "5.51.16",
"i18next": "23.12.2",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "14.2.13",
"luxon": "3.4.4",
"next": "14.2.5",
"react": "*",
"react-aria-components": "1.3.3",
"react-aria-components": "1.3.1",
"react-dom": "*",
"react-hook-form": "7.53.0",
"react-i18next": "15.0.2",
"react-select": "5.8.1",
"sass": "1.80.3",
"styled-components": "6.1.13",
"react-hook-form": "7.52.1",
"react-i18next": "15.0.0",
"react-select": "5.8.0",
"styled-components": "6.1.12",
"zod": "3.23.8",
"zustand": "4.5.5"
"zustand": "4.5.4"
},
"devDependencies": {
"@hookform/devtools": "4.3.1",
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.58.0",
"@tanstack/react-query-devtools": "5.51.16",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/react": "16.0.1",
"@testing-library/jest-dom": "6.4.8",
"@testing-library/react": "16.0.0",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.13",
"@types/lodash": "4.17.9",
"@types/jest": "29.5.12",
"@types/lodash": "4.17.7",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.10",
"@types/react": "18.3.3",
"@types/react-dom": "*",
"dotenv": "16.4.5",
"eslint-config-people": "*",
@@ -55,7 +54,7 @@
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.3.3",
"stylelint": "16.9.0",
"stylelint": "16.8.1",
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*"
@@ -1,41 +1,18 @@
import '@testing-library/jest-dom';
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { useConfigStore } from '@/core';
import { AppWrapper } from '@/tests/utils';
import Page from '../pages';
const mockedPush = jest.fn();
const mockedUseRouter = jest.fn().mockReturnValue({
push: mockedPush,
});
jest.mock('next/navigation', () => ({
...jest.requireActual('next/navigation'),
useRouter: () => mockedUseRouter(),
}));
describe('Page', () => {
afterEach(() => jest.clearAllMocks());
it('checks Page rendering with team feature', () => {
useConfigStore.setState({
config: { RELEASE: '1.0.0', FEATURES: { TEAMS: true }, LANGUAGES: [] },
});
it('checks Page rendering', () => {
render(<Page />, { wrapper: AppWrapper });
expect(mockedPush).toHaveBeenCalledWith('/teams/');
});
it('checks Page rendering without team feature', () => {
useConfigStore.setState({
config: { RELEASE: '1.0.0', FEATURES: { TEAMS: false }, LANGUAGES: [] },
});
render(<Page />, { wrapper: AppWrapper });
expect(mockedPush).toHaveBeenCalledWith('/mail-domains/');
expect(
screen.getByRole('button', {
name: /Create a new team/i,
}),
).toBeInTheDocument();
});
});
@@ -1,157 +0,0 @@
import { APIError } from '@/api';
import {
parseAPIError,
parseAPIErrorCause,
parseServerAPIError,
} from '../parseAPIError';
describe('parseAPIError', () => {
const handleErrorMock = jest.fn();
const handleServerErrorMock = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should handle specific API error and return no unhandled causes', () => {
const error = new APIError('client error', {
cause: ['Mail domain with this name already exists.'],
status: 400,
});
const result = parseAPIError({
error,
errorParams: [
[
['Mail domain with this name already exists.'],
'This domain already exists.',
handleErrorMock,
],
],
serverErrorParams: ['Server error', handleServerErrorMock],
});
expect(handleErrorMock).toHaveBeenCalled();
expect(result).toEqual(['This domain already exists.']);
});
it('should return unhandled causes when no match is found', () => {
const error = new APIError('client error', {
cause: ['Unhandled error'],
status: 400,
});
const result = parseAPIError({
error,
errorParams: [
[
['Mail domain with this name already exists.'],
'This domain already exists.',
handleErrorMock,
],
],
serverErrorParams: ['Server error', handleServerErrorMock],
});
expect(handleErrorMock).not.toHaveBeenCalled();
expect(result).toEqual(['Unhandled error']);
});
it('should handle server errors correctly and prepend server error message', () => {
const error = new APIError('server error', { status: 500 });
const result = parseAPIError({
error,
errorParams: undefined,
serverErrorParams: ['Server error occurred', handleServerErrorMock],
});
expect(handleServerErrorMock).toHaveBeenCalled();
expect(result).toEqual(['Server error occurred']);
});
it('should handle absence of errors gracefully', () => {
const result = parseAPIError({
error: null,
errorParams: [
[
['Mail domain with this name already exists.'],
'This domain already exists.',
handleErrorMock,
],
],
serverErrorParams: ['Server error', handleServerErrorMock],
});
expect(result).toBeUndefined();
});
});
describe('parseAPIErrorCause', () => {
it('should handle specific errors and call handleError', () => {
const handleErrorMock = jest.fn();
const causes = ['Mail domain with this name already exists.'];
const errorParams: [string[], string, () => void][] = [
[
['Mail domain with this name already exists.'],
'This domain already exists.',
handleErrorMock,
],
];
const result = parseAPIErrorCause(
new APIError('client error', { cause: causes, status: 400 }),
errorParams,
);
expect(handleErrorMock).toHaveBeenCalled();
expect(result).toEqual(['This domain already exists.']);
});
it('should handle multiple causes and return unhandled causes', () => {
const handleErrorMock = jest.fn();
const causes = [
'Mail domain with this name already exists.',
'Unhandled error',
];
const errorParams: [string[], string, () => void][] = [
[
['Mail domain with this name already exists.'],
'This domain already exists.',
handleErrorMock,
],
];
const result = parseAPIErrorCause(
new APIError('client error', { cause: causes, status: 400 }),
errorParams,
);
expect(handleErrorMock).toHaveBeenCalled();
expect(result).toEqual(['This domain already exists.', 'Unhandled error']);
});
});
describe('parseServerAPIError', () => {
const handleServerErrorMock = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should return the server error message and handle callback', () => {
const result = parseServerAPIError(['Server error', handleServerErrorMock]);
expect(result).toEqual('Server error');
expect(handleServerErrorMock).toHaveBeenCalled();
});
it('should return only the server error message when no callback is provided', () => {
const result = parseServerAPIError(['Server error', undefined]);
expect(result).toEqual('Server error');
});
});
@@ -1,56 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { useDebounce } from '../useDebounce';
jest.useFakeTimers();
describe('useDebounce', () => {
it('should return the initial value immediately', () => {
const { result } = renderHook(() => useDebounce('initial value'));
expect(result.current).toBe('initial value');
});
it('should return debounced value after default 500ms delay', () => {
const { result, rerender } = renderHook(({ value }) => useDebounce(value), {
initialProps: { value: 'initial' },
});
expect(result.current).toBe('initial');
rerender({ value: 'updated' });
expect(result.current).toBe('initial');
act(() => {
jest.advanceTimersByTime(500);
});
expect(result.current).toBe('updated');
});
it('should reset the debounce timer if value changes before delay', () => {
const { result, rerender } = renderHook(({ value }) => useDebounce(value), {
initialProps: { value: 'initial' },
});
expect(result.current).toBe('initial');
rerender({ value: 'updated' });
rerender({ value: 'updated again' });
// Fast forward 400ms (less than debounce delay), value should still be 'initial'
act(() => {
jest.advanceTimersByTime(400);
});
expect(result.current).toBe('initial');
// Fast forward 500ms (total: 900ms), value should be 'updated again'
act(() => {
jest.advanceTimersByTime(500);
});
expect(result.current).toBe('updated again');
});
});
@@ -1 +0,0 @@
export { useDebounce } from './useDebounce';
@@ -1,18 +0,0 @@
import { useEffect, useState } from 'react';
const MS_DEBOUNCE = 500;
export const useDebounce = (value: string, delay: number = MS_DEBOUNCE) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};
-1
View File
@@ -3,4 +3,3 @@ export * from './conf';
export * from './fetchApi';
export * from './types';
export * from './utils';
export * from './hooks';
@@ -1,106 +0,0 @@
import { APIError } from '@/api/index';
type ErrorCallback = () => void;
// Type for the error tuple format [causes, message, handleError]
type ErrorTuple = [string[], string, ErrorCallback | undefined];
// Server error tuple [defaultMessage, handleError]
type ServerErrorTuple = [string, ErrorCallback | undefined];
/**
* @function parseAPIError
* @description function to centralize APIError handling to treat discovered errors
* and error type 500 with default behavior using a simplified tuple structure.
* @param error - APIError object
* @param errorParams - Array of tuples: each contains an array of causes, a message, and an optional callback function.
* @param serverErrorParams - A tuple for server error handling: [defaultMessage, handleError]
* @returns Array of error messages or undefined
*/
export const parseAPIError = ({
error,
errorParams,
serverErrorParams,
}: {
error: APIError | null;
errorParams?: ErrorTuple[];
serverErrorParams?: ServerErrorTuple;
}): string[] | undefined => {
if (!error) {
return;
}
// Parse known error causes using the tuple structure
const errorCauses =
error.cause?.length && errorParams
? parseAPIErrorCause(error, errorParams)
: undefined;
// Check if it's a server error (500) and handle that case
const serverErrorCause =
(error?.status === 500 || !error?.status) && serverErrorParams
? parseServerAPIError(serverErrorParams)
: undefined;
// Combine the causes and return
const causes: string[] = errorCauses ? [...errorCauses] : [];
if (serverErrorCause) {
causes.unshift(serverErrorCause);
}
return causes.length ? causes : undefined;
};
/**
* @function parseAPIErrorCause
* @description Processes known API error causes using the tuple structure.
* @param error - APIError object
* @param errorParams - Array of tuples: each contains an array of causes, a message, and an optional callback function.
* @returns Array of error messages
*/
export const parseAPIErrorCause = (
error: APIError,
errorParams: ErrorTuple[],
): string[] | undefined => {
if (!error.cause) {
return;
}
return error.cause.reduce((causes: string[], cause: string) => {
// Find the matching error tuple
const matchedError = errorParams.find(([errorCauses]) =>
errorCauses.some((knownCause) => new RegExp(knownCause, 'i').test(cause)),
);
if (matchedError) {
const [, message, handleError] = matchedError;
causes.push(message);
if (handleError) {
handleError();
}
} else {
// If no match is found, add the original cause
causes.push(cause);
}
return causes;
}, []);
};
/**
* @function parseServerAPIError
* @description Handles server errors (500) and adds the default message.
* @param serverErrorParams - Tuple [defaultMessage, handleError]
* @returns Server error message
*/
export const parseServerAPIError = ([
defaultMessage,
handleError,
]: ServerErrorTuple): string => {
if (handleError) {
handleError();
}
return defaultMessage;
};
@@ -1,42 +0,0 @@
import {
Modal as CunninghamModal,
ModalProps,
} from '@openfun/cunningham-react';
import React, { useEffect } from 'react';
// Define a wrapper component that extends ModalProps to accept the same props as the Modal
export const Modal: React.FC<ModalProps> = ({ children, ...props }) => {
// Apply the hook here once for all modals
usePreventFocusVisible(['.c__modal__content']);
return <CunninghamModal {...props}>{children}</CunninghamModal>;
};
/**
* @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/numerique-gouv/people/pull/379
*/
export const usePreventFocusVisible = (elements: string[]) => {
useEffect(() => {
const observer = new MutationObserver((mutationsList) => {
mutationsList.forEach(() => {
elements.forEach((selector) =>
document.querySelector(selector)?.setAttribute('tabindex', '-1'),
);
observer.disconnect();
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, [elements]);
return null;
};
@@ -15,6 +15,7 @@ export interface TextProps extends BoxProps {
>;
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
// eslint-disable-next-line @typescript-eslint/ban-types
$size?: TextSizes | (string & {});
$theme?:
| 'primary'
@@ -1,80 +0,0 @@
import { ModalSize } from '@openfun/cunningham-react';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { AppWrapper } from '@/tests/utils';
import { Modal, usePreventFocusVisible } from '../Modal';
describe('usePreventFocusVisible hook', () => {
const TestComponent = () => {
usePreventFocusVisible(['.test-element']);
return (
<div>
<div className="test-element">Test Element</div>
</div>
);
};
const originalMutationObserver = global.MutationObserver;
const mockDisconnect = jest.fn();
const mutationObserverMock = jest.fn(function MutationObserver(
callback: MutationCallback,
) {
this.observe = () => {
callback([{ type: 'childList' }] as MutationRecord[], this);
};
this.disconnect = mockDisconnect;
});
afterEach(() => jest.clearAllMocks());
beforeAll(
() =>
(global.MutationObserver =
mutationObserverMock as unknown as typeof MutationObserver),
);
afterAll(() => (global.MutationObserver = originalMutationObserver));
test('sets tabindex to -1 on the target elements', () => {
const { unmount } = render(<TestComponent />);
const targetElement = screen.getByText('Test Element');
expect(targetElement).toHaveAttribute('tabindex', '-1');
unmount();
expect(mockDisconnect).toHaveBeenCalled();
});
});
describe('Modal', () => {
test('applies usePreventFocusVisible and sets tabindex', async () => {
render(
<Modal
isOpen={true}
onClose={() => {}}
size={ModalSize.MEDIUM}
title={<h3>Test Modal Title</h3>}
leftActions={<button>Cancel</button>}
rightActions={<button>Submit</button>}
>
<p>Modal content</p>
</Modal>,
{ wrapper: AppWrapper },
);
/* eslint-disable testing-library/no-node-access */
const modalContent = document.querySelector('.c__modal__content');
/* eslint-enable testing-library/no-node-access */
await waitFor(() => {
expect(modalContent).toHaveAttribute('tabindex', '-1');
});
});
});
@@ -1,54 +0,0 @@
import * as React from 'react';
import { ReactNode } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
import { Box } from '@/components';
import style from './custom-tabs.module.scss';
type TabsOption = {
ariaLabel?: string;
label: string;
iconName?: string;
id?: string;
content: ReactNode;
};
type Props = {
tabs: TabsOption[];
};
export const CustomTabs = ({ tabs }: Props) => {
return (
<div className={style.customTabsContainer}>
<Tabs>
<TabList>
{tabs.map((tab) => {
const id = tab.id ?? tab.label;
return (
<Tab key={id} aria-label={tab.ariaLabel} id={id}>
<Box $direction="row" $align="center" $gap="5px">
{tab.iconName && (
<span className="material-icons" aria-hidden="true">
{tab.iconName}
</span>
)}
{tab.label}
</Box>
</Tab>
);
})}
</TabList>
{tabs.map((tab) => {
const id = tab.id ?? tab.label;
return (
<TabPanel key={id} id={id}>
{tab.content}
</TabPanel>
);
})}
</Tabs>
</div>
);
};
@@ -1,63 +0,0 @@
.customTabsContainer {
:global {
.react-aria-TabList {
display: flex;
&[data-orientation='horizontal'] {
.react-aria-Tab {
border-bottom: 2px solid var(--c--theme--colors--greyscale-500);
}
}
}
.react-aria-Tab {
padding: 10px;
cursor: pointer;
outline: none;
position: relative;
color: var(--c--theme--colors--greyscale-700);
transition: color 200ms;
--border-color: transparent;
forced-color-adjust: none;
&[data-hovered],
&[data-focused] {
color: var(--c--theme--colors--greyscale-900);
}
&[data-selected] {
border-bottom: 2px solid var(--c--theme--colors--primary-600) !important;
color: var(--c--theme--colors--primary-600);
}
&[data-disabled] {
color: var(--c--theme--colors--greyscale-500);
&[data-selected] {
--border-color: var(--c--theme--colors--greyscale-200);
}
}
&[data-focus-visible]::after {
content: '';
position: absolute;
inset: 4px;
border-radius: 4px;
border: 1px solid var(--c--theme--colors--primary-600);
}
}
.react-aria-TabPanel {
margin-top: 4px;
padding: 10px;
border-radius: 4px;
outline: none;
&[data-focus-visible] {
outline: 2px solid var(--c--theme--colors--primary-600);
}
}
}
}
@@ -6,7 +6,6 @@ import { useCunninghamTheme } from '@/cunningham';
import '@/i18n/initI18n';
import { Auth } from './auth/Auth';
import { ConfigProvider } from './config';
/**
* QueryClient:
@@ -30,9 +29,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools />
<CunninghamProvider theme={theme}>
<ConfigProvider>
<Auth>{children}</Auth>
</ConfigProvider>
<Auth>{children}</Auth>
</CunninghamProvider>
</QueryClientProvider>
);
@@ -5,17 +5,13 @@ import { Footer } from '@/features/footer/Footer';
import { HEADER_HEIGHT, Header } from '@/features/header';
import { Menu } from '@/features/menu';
import { useConfigStore } from './config';
export function MainLayout({ children }: PropsWithChildren) {
const { config } = useConfigStore();
return (
<Box>
<Box $height="100vh">
<Header />
<Box $css="flex: 1;" $direction="row">
{config?.FEATURES.TEAMS && <Menu />}
<Menu />
<Box
as="main"
$height={`calc(100vh - ${HEADER_HEIGHT})`}
@@ -1,57 +0,0 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { AppWrapper } from '@/tests/utils';
import { MainLayout } from '../MainLayout';
import { useConfigStore } from '../config';
jest.mock('next/navigation', () => ({
...jest.requireActual('next/navigation'),
usePathname: () => '/',
useRouter: () => ({
push: () => {},
}),
}));
describe('MainLayout', () => {
it('checks menu rendering with team feature', () => {
useConfigStore.setState({
config: { RELEASE: '1.0.0', FEATURES: { TEAMS: true }, LANGUAGES: [] },
});
render(<MainLayout />, { wrapper: AppWrapper });
expect(
screen.getByRole('button', {
name: /Teams button/i,
}),
).toBeInTheDocument();
expect(
screen.getByRole('button', {
name: /Mail Domains button/i,
}),
).toBeInTheDocument();
});
it('checks menu rendering without team feature', () => {
useConfigStore.setState({
config: { RELEASE: '1.0.0', FEATURES: { TEAMS: false }, LANGUAGES: [] },
});
render(<MainLayout />, { wrapper: AppWrapper });
expect(
screen.queryByRole('button', {
name: /Teams button/i,
}),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', {
name: /Mail Domains button/i,
}),
).not.toBeInTheDocument();
});
});
@@ -1,24 +0,0 @@
import { Loader } from '@openfun/cunningham-react';
import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
import { useConfigStore } from './useConfigStore';
export const ConfigProvider = ({ children }: PropsWithChildren) => {
const { config, initConfig } = useConfigStore();
useEffect(() => {
initConfig();
}, [initConfig]);
if (!config) {
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
<Loader />
</Box>
);
}
return children;
};
@@ -1,11 +0,0 @@
import { fetchAPI } from '@/api';
import { Config } from '../types';
export const getConfig = async (): Promise<Config> => {
const response = await fetchAPI(`config/`);
if (!response.ok) {
throw new Error(`Couldn't fetch conf data: ${response.statusText}`);
}
return response.json() as Promise<Config>;
};
@@ -1 +0,0 @@
export * from './getConfig';
@@ -1,2 +0,0 @@
export * from './ConfigProvider';
export * from './useConfigStore';
@@ -1,7 +0,0 @@
export interface Config {
LANGUAGES: [string, string][];
RELEASE: string;
FEATURES: {
TEAMS: boolean;
};
}
@@ -1,26 +0,0 @@
import { create } from 'zustand';
import { getConfig } from './api';
import { Config } from './types';
interface ConfStore {
config?: Config;
initConfig: () => void;
}
const initialState = {
config: undefined,
};
export const useConfigStore = create<ConfStore>((set) => ({
config: initialState.config,
initConfig: () => {
void getConfig()
.then((config: Config) => {
set({ config });
})
.catch(() => {
console.error('Failed to fetch config data');
});
},
}));
-1
View File
@@ -1,4 +1,3 @@
export * from './AppProvider';
export * from './MainLayout';
export * from './PageLayout';
export * from './config';
@@ -484,6 +484,11 @@ input:-webkit-autofill:focus {
/**
* Modal
*/
.c__modal:focus-visible {
outline: none;
box-shadow: none;
}
.c__modal__backdrop {
z-index: 1000;
}
@@ -3,7 +3,6 @@ import { Trans, useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { Box, LogoGouv, StyledLink, Text } from '@/components';
import { useConfigStore } from '@/core';
import IconLink from './assets/external-link.svg';
@@ -17,7 +16,6 @@ const BlueStripe = styled.div`
export const Footer = () => {
const { t } = useTranslation();
const { config } = useConfigStore();
return (
<Box $position="relative" as="footer">
@@ -96,7 +94,7 @@ export const Footer = () => {
$padding={{ top: 'tiny' }}
$css="
flex-wrap: wrap;
border-top: 1px solid var(--c--theme--colors--greyscale-200);
border-top: 1px solid var(--c--theme--colors--greyscale-200);
column-gap: 1rem;
row-gap: .5rem;
"
@@ -140,7 +138,6 @@ export const Footer = () => {
</StyledLink>
))}
</Box>
<Text
as="p"
$size="m"
@@ -148,12 +145,6 @@ export const Footer = () => {
$variation="600"
$display="inline"
>
{config?.RELEASE && (
<>
{t(`Version: {{release}}`, { release: config?.RELEASE })} &nbsp;
</>
)}
<Trans>
Unless otherwise stated, all content on this site is under
<StyledLink
@@ -7,35 +7,32 @@ import { useAuthStore } from '@/core/auth';
export const AccountDropdown = () => {
const { t } = useTranslation();
const { userData, logout } = useAuthStore();
const { logout } = useAuthStore();
const userName = userData?.name || t('No Username');
return (
<DropButton
aria-label={t('My account')}
button={
<Box $flex $direction="row" $align="center">
<Text $theme="primary">{userName}</Text>
<Text $theme="primary">{t('My account')}</Text>
<Text className="material-icons" $theme="primary" aria-hidden="true">
arrow_drop_down
</Text>
</Box>
}
>
<Box $css="display: flex; direction: column; gap: 0.5rem">
<Button
onClick={logout}
key="logout"
color="primary-text"
icon={
<span className="material-icons" aria-hidden="true">
logout
</span>
}
aria-label={t('Logout')}
>
<Text $weight="normal">{t('Logout')}</Text>
</Button>
</Box>
<Button
onClick={logout}
color="primary-text"
icon={
<span className="material-icons" aria-hidden="true">
logout
</span>
}
aria-label={t('Logout')}
>
<Text $weight="normal">{t('Logout')}</Text>
</Button>
</DropButton>
);
};
@@ -1,67 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '@/core/auth';
import { AppWrapper } from '@/tests/utils';
import { AccountDropdown } from '../AccountDropdown';
describe('AccountDropdown', () => {
const mockLogout = jest.fn();
const renderAccountDropdown = () =>
render(<AccountDropdown />, { wrapper: AppWrapper });
beforeEach(() => {
jest.clearAllMocks();
useAuthStore.setState({
userData: {
id: '1',
email: 'test@example.com',
name: 'Test User',
},
logout: mockLogout,
});
});
it('renders the user name correctly', async () => {
renderAccountDropdown();
expect(await screen.findByText('Test User')).toBeInTheDocument();
});
it('renders "No Username" when userData name is missing', () => {
useAuthStore.setState({
userData: {
id: '1',
email: 'test@example.com',
},
logout: mockLogout,
});
renderAccountDropdown();
expect(screen.getByText('No Username')).toBeInTheDocument();
});
it('opens the dropdown and shows logout button when clicked', async () => {
renderAccountDropdown();
const dropButton = await screen.findByText('Test User');
await userEvent.click(dropButton);
expect(screen.getByText('Logout')).toBeInTheDocument();
expect(screen.getByLabelText('Logout')).toBeInTheDocument();
});
it('calls logout function when logout button is clicked', async () => {
renderAccountDropdown();
const dropButton = await screen.findByText('Test User');
await userEvent.click(dropButton);
const logoutButton = screen.getByLabelText('Logout');
await userEvent.click(logoutButton);
expect(mockLogout).toHaveBeenCalledTimes(1);
});
});
@@ -1,6 +1,6 @@
import { Select } from '@openfun/cunningham-react';
import Image from 'next/image';
import { useEffect, useMemo } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
@@ -60,19 +60,6 @@ export const LanguagePicker = () => {
}));
}, [languages]);
/**
* @description prevent select div to receive focus on keyboard navigation so the focus goes directly to inner button
* @see https://github.com/numerique-gouv/people/pull/379
*/
useEffect(() => {
if (!document) {
return;
}
document
.querySelector('.c__select-language-picker .c__select__wrapper')
?.setAttribute('tabindex', '-1');
}, []);
return (
<SelectStyled
label={t('Language')}
@@ -80,7 +67,7 @@ export const LanguagePicker = () => {
clearable={false}
hideLabel
defaultValue={i18n.language}
className="c_select__no_bg c__select-language-picker"
className="c_select__no_bg"
options={optionsPicker}
onChange={(e) => {
i18n.changeLanguage(e.target.value as string).catch((err) => {
@@ -1,143 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import { AccessesContent } from '@/features/mail-domains/access-management';
import { Role } from '@/features/mail-domains/domains';
import MailDomainAccessesPage from '@/pages/mail-domains/[slug]/accesses';
import { AppWrapper } from '@/tests/utils';
jest.mock('next/navigation', () => ({
useRouter: jest.fn(),
}));
jest.mock('next/router', () => ({
useRouter: jest.fn(),
}));
jest.mock(
'@/features/mail-domains/access-management/components/AccessesContent',
() => ({
AccessesContent: jest.fn(() => <div>AccessContent</div>),
}),
);
describe('MailDomainAccessesPage', () => {
const mockRouterReplace = jest.fn();
const mockNavigate = { replace: mockRouterReplace };
const mockRouter = {
query: { slug: 'example-slug' },
};
(useRouter as jest.Mock).mockReturnValue(mockRouter);
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
beforeEach(() => {
jest.clearAllMocks();
fetchMock.reset();
(useRouter as jest.Mock).mockReturnValue(mockRouter);
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
});
afterEach(() => {
fetchMock.restore();
});
const renderPage = () => {
render(<MailDomainAccessesPage />, { wrapper: AppWrapper });
};
it('renders loader while loading', () => {
// Simulate a never-resolving promise to mock loading
fetchMock.mock(
`end:/mail-domains/${mockRouter.query.slug}/`,
new Promise(() => {}),
);
renderPage();
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('renders error message when there is an error', async () => {
fetchMock.mock(`end:/mail-domains/${mockRouter.query.slug}/`, {
status: 500,
});
renderPage();
await waitFor(() => {
expect(
screen.getByText('Something bad happens, please retry.'),
).toBeInTheDocument();
});
});
it('redirects to 404 page if the domain is not found', async () => {
fetchMock.mock(
`end:/mail-domains/${mockRouter.query.slug}/`,
{
body: { detail: 'Not found' },
status: 404,
},
{ overwriteRoutes: true },
);
renderPage();
await waitFor(() => {
expect(mockRouterReplace).toHaveBeenCalledWith('/404');
});
});
it('renders the AccessesContent when data is available', async () => {
const mockMailDomain = {
id: '1-1-1-1-1',
name: 'example.com',
slug: 'example-com',
status: 'enabled',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
};
fetchMock.mock(`end:/mail-domains/${mockRouter.query.slug}/`, {
body: mockMailDomain,
status: 200,
});
renderPage();
await waitFor(() => {
expect(screen.getByText('AccessContent')).toBeInTheDocument();
});
await waitFor(() => {
expect(AccessesContent).toHaveBeenCalledWith(
{
mailDomain: mockMailDomain,
currentRole: Role.OWNER,
},
{}, // adding this empty object is necessary to load jest context
);
});
});
it('throws an error when slug is invalid', () => {
console.error = jest.fn(); // Suppress expected error in jest logs
(useRouter as jest.Mock).mockReturnValue({
query: { slug: ['invalid-array-slug-in-array'] },
});
expect(() => renderPage()).toThrow('Invalid mail domain slug');
});
});

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