Compare commits

...

65 Commits

Author SHA1 Message Date
Marie PUPO JEAMMET 7f31a2b820 🔖(minor) release version 1.1.0
Update all version files and changelog for minor release.
2024-09-10 17:20:05 +02:00
Jacques ROUSSEL aaca8819b3 🔧(helm) fix mail provisioning env
I forgot to use the new variable on the deployment.
2024-09-10 16:50:14 +02:00
Sabrina Demagny 77cc64a6c7 (release) add python script to prepare release
Change versions, update changelog and create branch and commit
to submit release informations.
Give following instructions to do after.
2024-09-10 15:30:30 +02:00
Marie PUPO JEAMMET aaad48480a (dimail) fix tests still waiting for domain.secret
For unknown reasons, these tests were forgotten and are still
refering to this 'secret' field, removed in last commit.
2024-09-10 15:29:19 +02:00
Marie PUPO JEAMMET 00dafd4b15 ♻️(dimail) separate headers request from mailbox request
I want to separate headers request form mailbox request,
as we were previously catching the same errors twice.
It should be clearer now.
2024-09-10 15:29:19 +02:00
Jacques ROUSSEL 864702d0ee 🔧(helm) add secret for email provisionning
Add email provisionning secret to be abble to provision email
2024-09-09 18:18:00 +02:00
Marie PUPO JEAMMET 29904ef7b6 (tests) update tests to look for dimail secret in settings
Update back-end tests to match 'secret' field being moved to settings.
2024-09-09 18:18:00 +02:00
Marie PUPO JEAMMET ba30b1d3ee 🗃️(models) remove 'secret' field from mailbox model
We remove 'secret' field, as it won't be of use in interactions
between la Régie and dimail. Régie credentials will be stored and used
as project variable.
2024-09-09 18:18:00 +02:00
renovate[bot] 9503b073b6 ⬆️(dependencies) update python dependencies 2024-09-09 15:01:53 +02:00
daproclaima e4aed82ff2 🥅(frontend) improve error catching in forms
- rename CreateMailboxForm into ModalCreateMailbox,
and useCreateMailDomain into useAddMailDomain
- use useAPIError hook in ModalCreateMailbox.tsx and ModalAddMailDomain
- update translations and tests (include removal of e2e test able
to be asserted by component tests)
2024-09-09 12:43:29 +02:00
daproclaima 25898bbb64 🥅(frontend) handle api errors
- add hook to handle api errors.
- add related component tests
2024-09-09 12:43:29 +02:00
daproclaima b5d8e92d1e 🐛(frontend) fix mail domain creation form
- allow to submit form by pressing "Enter" key
- force focus on form when form is submited
but is invalid
- add error 500 handling
- update related e2e tests
2024-09-09 12:43:29 +02:00
Marie PUPO JEAMMET 237d64b4c5 ♻️(dimail) refacto to better handle 500 errors from dimail
simple refacto to catch all 500 errors, including when
asking for new token.
2024-09-09 12:24:48 +02:00
daproclaima 0d157d3f2b ️(frontend) improve keyboard navigation on modals
- create a temporary Modal component to apply it
a function tracking document update and set
modal elements we want to ignore a tabindex=-1.
- add component tests
- temporary fix. Better to apply them on
cunningham directly
2024-09-09 11:58:37 +02:00
daproclaima d291e55a9e 🐛(frontend) fix mail domain addition form
- remove link wrapping cancel button
2024-09-09 11:58:37 +02:00
daproclaima e7aebfe59e 🐛(frontend) keyboard navigation language picker
- add temporary fix to language picker to
ignore select on keyboard navigation. Needs
to be fixed directly in Cunningham Select
- update related e2e test
2024-09-09 11:58:37 +02:00
daproclaima bd6cd59df6 💬(frontend) change add mail domain text
- update translations and tests
2024-09-09 11:41:34 +02:00
daproclaima 874ce18134 💬(frontend) fix group member removal text
- update FR translation
2024-09-09 11:41:34 +02:00
Marie PUPO JEAMMET 4fe74733a5 📈(monitoring) configure sentry dsn
configure sentry dsn to monitor errors in sentry
2024-09-05 16:16:05 +02:00
Marie PUPO JEAMMET 4b47f80cab 🚨(tests) fix obsolete code warnings
- in docker compose, remove obsolete 'version' field
- in django, replace obsolete CheckConstraints 'check' field by 'condition'
2024-09-05 14:57:32 +02:00
Marie PUPO JEAMMET ba631fafb9 🐛(dimail) improve handling of dimail errors on failed mailbox creation
dimail is called twice when creating a mailbox (once for the token,
and once for the post on mailbox endpoint). we want to clarify
the status_codes and messages of each error to inform user and ease debug
2024-09-05 14:57:32 +02:00
renovate[bot] ce15e8a3ed ⬆️(dependencies) update python dependencies 2024-09-05 12:00:53 +02:00
Marie PUPO JEAMMET 55dc342a8b 🔨(demo) add domains creation to demo
Add domains creation to "make demo" rule. Update related test.
2024-09-03 16:11:23 +02:00
renovate[bot] 05c8f636dd ⬆️(dependencies) update js dependencies 2024-09-02 09:41:24 +02:00
Sabrina Demagny 675ba4b557 🔖(patch) release version 1.0.2
Update all version files and changelog for release patch.
2024-08-30 18:03:26 +02:00
Marie PUPO JEAMMET f5c7abcd20 🗃️(mailboxes) migrate local part field modification
"+" character was removed from valid character for mailbox creation
this commit adds migration file for database to be up-to-date
2024-08-30 14:35:58 +02:00
lebaudantoine 8c54e701c1 🔧(helm) configure resource server in staging
Allow desk-staging app to interact with AgentConnect integration env
to introspect a received access token.

Other environment (pre-prod and production) will be configured when
at least one interconnection with a service provider has been
validated.
2024-08-30 13:12:25 +02:00
Anthony LC ff2cbe3aed 🔥(CI) remove crowdin from CI workflow
Pushing to crowdin from the workflow has some side
effects, if 2 branches are pushing to crowdin it
can cause conflicts and delete translations on
Crowdin side.
Better to push to crowdin manually to keep good
control over the translations.
2024-08-30 10:42:43 +02:00
Anthony LC 7f3f25c61d ⬆️(i18n) i18next-parser to 9.0.2
i18next-parser had a compatibility issue with
a dependency (cheerio). The last version
fixed this issue, plus fixed another issue
about a configuration problem.
We can now remove it from the renovate ignore list.
2024-08-30 10:42:43 +02:00
Anthony LC bd36983e3c (frontend) add @typescript-eslint/parser
In order to work correctly
@typescript-eslint/eslint-plugin requires
@typescript-eslint/parser to be installed as well.
We fixed the linting issues related to the
upgrade.
2024-08-30 10:42:43 +02:00
renovate[bot] e8cc6a6f23 ⬆️(dependencies) update js dependencies 2024-08-30 10:42:43 +02:00
lebaudantoine 9adb2d4cf4 📝(backend) draft a minimal documentation on resource server
I've committed few of my notes for my future myself, helping me to
run locally the stack. These notes are raw, I'll be more than happy
to refine them when I got sufficient time. Please feel free to ping
me if you have any question.
2024-08-29 11:39:08 +02:00
lebaudantoine bb5058c478 🔊(backend) enable debug logs in development
Enhance developer experience, unmuting some debug information,
that weren't taken into account.
2024-08-29 11:39:08 +02:00
lebaudantoine 78818ba541 🩹(backend) enable resource server authentication if properly configured
Tests are missing, let's ship it, I'll open an issue.

Without such protection, the whole app would crash if the resource server is
not configured. The fallback backend would return an appropriate error to
the client if the resource server is improperly configured.
2024-08-29 11:39:08 +02:00
lebaudantoine f1a2b7c603 (backend) authenticate requests using an access token issued by AC
Overload mozilla-django-oidc class to support an authentication method
with the resource server backend.

This enables any route of the API to be called with an access token
issued by Agent Connect.
2024-08-29 11:39:08 +02:00
lebaudantoine 5634a7f390 (backend) add resource server backend
Why:

Many services in La Suite rely on Agent Connect to authenticate their users.
Delegating  authentication to Agent Connect is highly beneficial. With a central
party (Agent Connect)  handling user authentication, our services can seamlessly
communicate with each other.  Our backend must be able to receive and verify
access tokens issued by Agent Connect.

Additionally, it should ensure that the resource owner has granted permission
for our  data to the service provider transmitting the access token.

How:

Our backend needs to verify access tokens by introspecting them. This involves
requesting the Authorization Server to validate the access token received in
the authentication header. The Authorization Server validates the token's
integrity, provides authentication and authorization information about
the user currently logged into the service provider requesting data from
the resource server.

The data returned by the Authorization Server to the resource server
is encrypted and signed. To encrypt the introspection token, the Authorization
Server retrieves the resource server's public key from
the new ‘/jwks’ endpoint.

Encryption parameters, such as algorithm and encoding, are configured on
the resource server. Ensure that these parameters match between
the Authorization Server and the resource server.

The resource server verifies the token signature using the Authorization
Server's public key, exposed through its `/jwks` endpoint. Make sure
the signature algorithms match between both servers. Finally, introspection
token claims are verified to adhere to good  practices for handling JWTs,
including checks on issuer, audience, and expiration time.

The introspection token contains a subject (`sub`). The resource server uses
this subject to retrieve the requested database user, compatible
with both pairwise and public subjects.

Important:

Agent Connect does not follow RFC 7662 but uses a draft RFC that adds security
(signing/encryption) to the initial specification. Refer to the "References"
section for more information.

References:

The initial RFC describing token introspection is RFC 7662 "OAuth 2.0 Token
Introspection". However, this RFC specifies that the introspection
response is a plain JSON object.

In eGovernment applications, our resource server requires stronger assurance
that the Authorization Server issued the token introspection response.

France Connect's team implemented a stronger version of the spec, returning
a signed and encrypted token  introspection response. This version is still
a draft, available under:

"draft-ietf-oauth-jwt-introspection-response".
2024-08-29 11:39:08 +02:00
lebaudantoine 9c05167d80 (backend) introduce an authorization server client
In OAuth 2.0, the Authorization Server is equivalent to the OIDC provider.

The Authorization Server exposes endpoints for token introspection and JWKS.
I’ve created a client to easily interact with the Authorization Server,
while doing the token introspection in our resource server.

Token introspection will be explained in upcoming commits.

The current OIDC library used in the project doesn’t offer token introspection,
leading to redundancy in the code handling some OIDC/OAuth2 flows.

This overlap makes the code bulky. My goal is to quickly deliver a working
PoC for the resource server, with plans to refactor in the longer run.

Please feel free to provide feedback on the class design.
2024-08-29 11:39:08 +02:00
lebaudantoine 21371dbd1b (backend) add a '/jwks' endpoint
Introduce a new endpoint, /jwks, which returns a JSON Web Key Set (JWKS).
This set of public crypto keys will be used by external parties to encrypt
data intended for our backend. In the context of the resource server, this key
will be used by the authorization server to encrypt the introspection response.

The current implementation exposes a single public key, with the private key
configurable in the app settings. The private key is represented as a string.
For enhanced security, we might prefer to store this data in a .pem file
excluded from version control.

A few parameters for this key, such as its type and encoding, are configurable
in the settings.

A critique of the current design is its lack of extensibility.
If we decide to offer more than one encryption method, this view will require
refactoring.

Additionally, the current implementation is tightly coupled with joserfc.

This lays the foundation for further improvements.

Please note, this endpoint only public components of the key, there is no
chance for any secret leaking.
2024-08-29 11:39:08 +02:00
lebaudantoine b40aefc505 ✏️(backend) fix minor typo
Found and fixed a minor typo. Nit-picking!
2024-08-29 11:39:08 +02:00
lebaudantoine 591b3eedff 🏗️(backend) create a new python package for the resource server
Encapsulate all Resource Server (RS) sources in a dedicated python package.

Resource server belongs to the Oauth2 ecosystem, please find informations
here https://www.oauth.com/oauth2-servers/the-resource-server/
2024-08-29 11:39:08 +02:00
lebaudantoine 09cb7ff6f1 (backend) add a JOSE dependency
We will add a JWKs endpoint to the application
and manipulate signed/encrypted Json Web Token (JWT).

Project lacks tooling for JSON Object Signing and Encryption (JOSE)
manipulations. After a quick benchmark, 'joserfc' has been chosen
as the dependency to add.

joserfc is a Python library that provides a comprehensive
implementation of several essential JOSE standards.

Please find the benchmark:

- Cryptography: Although using only cryptography is feasible, its
  interface/API is not as user-friendly.

- pyjwt: While pyjwt is popular, it lacks support for JWK and JWE objects,
  which are essential for the requirements.

- python-jose: The latest release of python-jose was in 2021, and the
  project seems less active compared to other alternatives.

- Authlib: Authlib is the second most popular library after pyjwt and seems
  modern with an active community. However, the parts relevant to the use case
  were extracted into a relatively new package named joserfc.

- joserfc: Although joserfc has fewer stars compared to Authlib, it was
  extracted from Authlib, which has more than 4k stars, indicating a solid
  foundation.

While the low star count of joserfc might raise concerns about its stability, it
is believed to be worth considering its addition. Adding Authlib and refactoring
later, once they finish migrating to joserfc, is also a possibility
2024-08-29 11:39:08 +02:00
Marie PUPO JEAMMET 87966fa062 🐛(backend) fix dependencies conflicts
Upgrading Django to 5.1 created a severe issue, breaking
mail template-associated features.

The issue originated from the third party 'easy_thumbnail'.
Please refer to the issue #641 on Django's repo. This is
the suggested workaround by @Miketsukami.
2024-08-28 12:21:02 +02:00
renovate[bot] e4dd3395bb ⬆️(dependencies) update python dependencies 2024-08-28 12:21:02 +02:00
Marie PUPO JEAMMET 93681b0030 (mailboxes) remove '+' from valid special caracters
We previously accepted '+' as a special caracter during mailbox creation.
We now remove it, as this caracter has a very special meaning and it wouldn't
make sense to create a mail using it.
2024-08-28 11:17:19 +02:00
Marie PUPO JEAMMET e04a994d37 👽️(mailboxes) fix mailbox creation after dimail api improvement
Latest dimail modification lead to a bug in our app, preventing mailbox creation
from working properly. I swapped old dimail url to new one, mirrored dimail
modification and fixed tests and tada!
2024-08-28 11:17:19 +02:00
Sabrina Demagny ba46d7de54 (users) improve user display on admin users list
If user email exists, display it instead of sub to identify
users in admin view list.
2024-08-28 11:00:34 +02:00
Sabrina Demagny b79b4b1853 (domains) manage domain roles on user admin view
Allow to manage mail domain roles on user admin interface
2024-08-28 11:00:34 +02:00
Laurent Bossavit e3f8633931 (test) fix flaky search test
By making this email address invariant, we remove failures due to FactoryBoy's
random address being considered as a match to our test query
(and hence returning unexpected number of matches).
2024-08-28 10:47:19 +02:00
Anthony LC b84e8b89f7 🔧(helm) deactivate teams feature on productions env
Teams feature is not ready for production yet,
so we need to deactivate it on productions environment.
preprod should be a copy of production,
so we need to deactivate it on preprod too.
2024-08-21 15:13:12 +02:00
Anthony LC 32889b9e8c ♻️(frontend) replace env NEXT_PUBLIC_FEATURE_TEAM
NEXT_PUBLIC_FEATURE_TEAM is a buid-time env
variable, it is not easy to overload it per
environment.
We will use the config endpoint to get the
feature flag at runtime.
To do so, we are using the ConfigStore.
2024-08-21 15:13:12 +02:00
Anthony LC 75647fe289 (frontend) add config provider
Add a ConfigProvider to the frontend to provide
configuration to the app.
The configuration is loaded from the config
endpoint and stored in a zustand store.
2024-08-21 15:13:12 +02:00
Anthony LC 03bfef6061 (backend) add public endpoint /api/v1.0/config/
Add public endpoint /api/v1.0/config/ to
share some public configuration values
with the frontend.
2024-08-21 15:13:12 +02:00
daproclaima 85c789bb1a 🔖(patch) patch release to 1.0.1
- upgrade semver for all package.json, env.d files,
and pyproject.toml
- update CHANGELOG.md
2024-08-20 10:03:11 +02:00
daproclaima 049d8695be ️(frontend) updates links
- remove buttons inside links in panels and
teams index
- update component and e2e tests
2024-08-19 16:04:57 +02:00
daproclaima 49c238155c (frontend) user can add mail domain
- user can add an externally created mail domain
from UI and see the mail domain status on mail
domain page and left panel links.
- user can not create mailboxes to domain if mail
domain status is not equal to `enabled`
- update related tests and translations
2024-08-19 16:04:57 +02:00
daproclaima b79725acbe 💬(frontend) change text in mail domains list
- use more meaningful message
- update translations and e2e tests
2024-08-19 16:04:57 +02:00
Marie PUPO JEAMMET 439ddb9d4a 🔖(major) major release to 1.0.0
🎉! For changelog, see changelog.md
2024-08-09 15:21:42 +02:00
Sabrina Demagny a7a923e790 (mailboxes) manage bad secret sent to dimail API
- manage 403 returned by dimail API when mail domain secret is not valid
- improve some tests
- improve MailboxFactory to mock success for dimail API POST call
- override 403.html to return a nice failing error in django admin
- an error message is displayed on mailbox creation form of frontend
2024-08-09 13:37:20 +02:00
Marie PUPO JEAMMET 5ed63fc091 (test) add test list mailboxes non existing domain
Test that API raises a 404 when trying to list mailboxes
of a domain that does not exist.
2024-08-09 13:37:20 +02:00
Marie PUPO JEAMMET f55cb3a813 (mailboxes) add mail provisioning api integration
We want people to create new mailboxes in La Régie.
This commit adds integration with intermediary dimail-api,
which will in turn send our email creation request to Open-Xchange.
2024-08-09 13:37:20 +02:00
Marie PUPO JEAMMET 2c82f38c59 🗃️(domains) add "secret" field to domains
add a "secret" field to domain model. This secret will be used as
password in mail provisioning API.
2024-08-09 13:37:20 +02:00
Sabrina Demagny 8963f0bb3d (mail) add status on domain create or retrieve API
to display status on frontend
2024-08-08 23:47:49 +02:00
Anthony LC 733a1d8861 🔥(frontend) remove temporarily team feature
We want to make a first realease, but the
team feature is not ready yet.
So we will hide it for now by hiding the menu.
We will still let the feature in dev environment.
2024-08-08 16:29:06 +02:00
daproclaima 7916f7d7d0 (frontend) show names in mailbox table
- show a concatenation of first and last names
for each row in mailbox table
- update related e2e tests
2024-08-08 13:53:07 +02:00
daproclaima 45dbdd6c4c (frontend) restrict mailbox creation
- update mailbox creation feature by introducing the use of
new mail domain ability field to hide or show
mailbox creation button
- update related e2e tests
2024-08-08 13:53:07 +02:00
130 changed files with 7186 additions and 2709 deletions
-76
View File
@@ -306,79 +306,3 @@ 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
+49
View File
@@ -7,3 +7,52 @@ and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [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.1.0...main
[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
+4
View File
@@ -343,3 +343,7 @@ 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
-2
View File
@@ -1,5 +1,3 @@
version: '3.8'
services:
postgresql:
image: postgres:16
+164
View File
@@ -0,0 +1,164 @@
## 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,3 +33,35 @@ 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-----
"
+1 -7
View File
@@ -13,13 +13,7 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"fetch-mock",
"node",
"node-fetch",
"i18next-parser",
"eslint"
]
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
}
]
}
+116
View File
@@ -0,0 +1,116 @@
import datetime
import os
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 = "):
last_version = line.split("=")[-1]
new_line = line.replace(last_version, f' "{version}"\n')
lines[index] = new_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 line.startswith(" tag: "):
lines[index] = f' tag: "v{version}"\n'
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 line.startswith(' "version": '):
lines[index] = f' "version": "{version}",\n'
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
@@ -0,0 +1,14 @@
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: d7cfe7bcdc...49b5911ecf
+14 -5
View File
@@ -4,6 +4,8 @@ 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
@@ -40,7 +42,7 @@ class UserAdmin(auth_admin.UserAdmin):
)
},
),
(_("Personal info"), {"fields": ("email", "language", "timezone")}),
(_("Personal info"), {"fields": ("name", "email", "language", "timezone")}),
(
_("Permissions"),
{
@@ -65,10 +67,9 @@ class UserAdmin(auth_admin.UserAdmin):
},
),
)
inlines = (TeamAccessInline,)
inlines = (TeamAccessInline, MailDomainAccessInline)
list_display = (
"sub",
"email",
"get_user",
"created_at",
"updated_at",
"is_active",
@@ -79,7 +80,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")
search_fields = ("id", "email", "sub", "name")
def get_readonly_fields(self, request, obj=None):
"""The sub should only be editable for a create, not for updates."""
@@ -87,6 +88,14 @@ 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,5 +1,6 @@
"""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
@@ -12,8 +13,10 @@ from rest_framework import (
pagination,
response,
throttling,
views,
viewsets,
)
from rest_framework.permissions import AllowAny
from core import models
@@ -491,3 +494,21 @@ 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"]
dict_settings = {}
for setting in array_settings:
dict_settings[setting] = getattr(settings, setting)
return response.Response(dict_settings)
+1 -1
View File
@@ -79,7 +79,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
)
try:
user = self.UserModel.objects.get(sub=sub)
user = self.UserModel.objects.get(sub=sub, is_active=True)
except self.UserModel.DoesNotExist:
if self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(user_info)
+2 -2
View File
@@ -146,11 +146,11 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='contact',
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.'),
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.'),
),
migrations.AddConstraint(
model_name='contact',
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.'),
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.'),
),
migrations.AlterUniqueTogether(
name='contact',
+2 -2
View File
@@ -124,12 +124,12 @@ class Contact(BaseModel):
unique_together = ("owner", "base")
constraints = [
models.CheckConstraint(
check=~models.Q(base__isnull=False, owner__isnull=True),
condition=~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(
check=~models.Q(base=models.F("id")),
condition=~models.Q(base=models.F("id")),
name="base_not_self",
violation_error_message="A contact cannot be based on itself.",
),
@@ -0,0 +1,54 @@
"""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
@@ -0,0 +1,223 @@
"""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")
@@ -0,0 +1,94 @@
"""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
# 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
@@ -0,0 +1,9 @@
"""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
@@ -0,0 +1,48 @@
"""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
@@ -0,0 +1,40 @@
"""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)
@@ -0,0 +1,447 @@
"""
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)
@@ -0,0 +1,187 @@
"""
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()
@@ -0,0 +1,88 @@
"""
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)
@@ -0,0 +1,70 @@
"""
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)
user = factories.UserFactory(name=None, email="alicia@example.com")
# set all names to None to match only on emails
colleague1 = factories.UserFactory(name=None, email="prudence_crandall@edu.us")
+39
View File
@@ -0,0 +1,39 @@
"""
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},
}
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},
}
+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 overriden
base_contact = factories.BaseContactFactory() # Excluded because overridden
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 overriden by the user
Trying to create a contact for base contact that is already overridden by the user
should receive a 400 error.
"""
user = factories.UserFactory(profile_contact=None)
+1
View File
@@ -4,4 +4,5 @@ NB_OBJECTS = {
"users": 1000,
"teams": 100,
"max_users_per_team": 100,
"domains": 20,
}
@@ -10,12 +10,15 @@ 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()
@@ -152,6 +155,35 @@ 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,12 +10,13 @@ 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
@@ -27,9 +28,11 @@ def test_commands_create_demo():
"""The create_demo management command should create objects as expected."""
call_command("create_demo")
assert models.User.objects.count() == 5
assert models.Team.objects.count() == 3
assert models.TeamAccess.objects.count() >= 3
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"]
def test_commands_createsuperuser():
+9
View File
@@ -34,6 +34,15 @@ 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."""
@@ -11,6 +11,8 @@ class MailboxSerializer(serializers.ModelSerializer):
class Meta:
model = models.Mailbox
fields = ["id", "first_name", "last_name", "local_part", "secondary_email"]
# everything is actually read-only as we do not allow update for now
read_only_fields = ["id"]
class MailDomainSerializer(serializers.ModelSerializer):
@@ -25,6 +27,7 @@ class MailDomainSerializer(serializers.ModelSerializer):
"id",
"name",
"slug",
"status",
"abilities",
"created_at",
"updated_at",
@@ -32,6 +35,7 @@ class MailDomainSerializer(serializers.ModelSerializer):
read_only_fields = [
"id",
"slug",
"status",
"abilities",
"created_at",
"updated_at",
+12 -1
View File
@@ -74,7 +74,18 @@ class MailBoxViewSet(
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""MailBox ViewSet"""
"""MailBox ViewSet
GET /api/<version>/mail-domains/<domain-slug>/mailboxes/
Return a list of mailboxes on the domain
POST /api/<version>/mail-domains/<domain-slug>/mailboxes/ with expected data:
- first_name: str
- last_name: str
- local_part: str
- secondary_email: str
Sends request to email provisioning API and returns newly created mailbox
"""
permission_classes = [permissions.MailBoxPermission]
serializer_class = serializers.MailboxSerializer
+38
View File
@@ -2,10 +2,14 @@
Mailbox manager application factories
"""
import re
from django.utils.text import slugify
import factory.fuzzy
import responses
from faker import Faker
from rest_framework import status
from core import factories as core_factories
from core import models as core_models
@@ -75,3 +79,37 @@ class MailboxFactory(factory.django.DjangoModelFactory):
)
domain = factory.SubFactory(MailDomainEnabledFactory)
secondary_email = factory.Faker("email")
@classmethod
def _create(cls, model_class, *args, use_mock=True, **kwargs):
domain = kwargs["domain"]
if use_mock and isinstance(domain, models.MailDomain):
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(
rf".*/domains/{domain.name}/mailboxes/{kwargs['local_part']}"
),
body=str(
{
"email": f"{kwargs['local_part']}@{domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
result = super()._create(model_class, *args, **kwargs)
else:
result = super()._create(model_class, *args, **kwargs)
return result
@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-07-01 16:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0010_alter_mailbox_first_name_alter_mailbox_last_name'),
]
operations = [
migrations.AddField(
model_name='maildomain',
name='secret',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='secret'),
),
]
@@ -0,0 +1,19 @@
# 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'),
),
]
@@ -0,0 +1,17 @@
# 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',
),
]
+34 -8
View File
@@ -3,15 +3,15 @@ Declare and configure the models for the People additional application : mailbox
"""
from django.conf import settings
from django.core import validators
from django.core.exceptions import ValidationError
from django.db import models
from django.core import exceptions, validators
from django.db import models, transaction
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from core.models import BaseModel
from mailbox_manager.enums import MailDomainRoleChoices, MailDomainStatusChoices
from mailbox_manager.utils.dimail import DimailAPIClient
class MailDomain(BaseModel):
@@ -115,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,
@@ -137,8 +137,34 @@ class Mailbox(BaseModel):
def __str__(self):
return f"{self.local_part!s}@{self.domain.name:s}"
def save(self, *args, **kwargs):
self.full_clean()
def clean(self):
"""
Mailboxes can 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 ValidationError("You can create mailbox only for a domain enabled")
super().save(*args, **kwargs)
raise exceptions.ValidationError(
"You can create mailbox only for a domain enabled"
)
# 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):
"""
Override save function to fire a request on mailbox creation.
Modification is forbidden for now.
"""
self.full_clean()
if self._state.adding:
with transaction.atomic():
client = DimailAPIClient()
client.send_mailbox_request(self)
return super().save(*args, **kwargs)
# Update is not implemented for now
raise NotImplementedError()
@@ -0,0 +1,2 @@
<h1>403 Forbidden</h1>
{{ exception }}
@@ -52,12 +52,10 @@ def test_api_mail_domains__create_authenticated():
Authenticated users should be able to create mail domains
and should automatically be added as owner of the newly created domain.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/mail-domains/",
{
@@ -65,10 +63,21 @@ def test_api_mail_domains__create_authenticated():
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
# a new domain pending is created and the authenticated user is the owner
domain = models.MailDomain.objects.get()
# response is as expected
assert response.json() == {
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"status": enums.MailDomainStatusChoices.PENDING,
"created_at": domain.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
"abilities": domain.get_abilities(user),
}
# a new domain with status "pending" is created and authenticated user is the owner
assert domain.status == enums.MailDomainStatusChoices.PENDING
assert domain.name == "mydomain.com"
assert domain.accesses.filter(role="owner", user=user).exists()
@@ -81,6 +81,7 @@ def test_api_mail_domains__retrieve_authenticated_related():
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"status": domain.status,
"created_at": domain.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
"abilities": domain.get_abilities(user),
@@ -2,7 +2,13 @@
Unit tests for the mailbox API
"""
import json
import re
from django.test.utils import override_settings
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
@@ -75,10 +81,7 @@ 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."""
@@ -91,11 +94,33 @@ def test_api_mailboxes__create_roles_success(role):
mailbox_values = serializers.MailboxSerializer(
factories.MailboxFactory.build()
).data
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{mail_domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_values['local_part']}@{mail_domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
mailbox = models.Mailbox.objects.get()
@@ -113,10 +138,7 @@ 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
@@ -130,12 +152,33 @@ def test_api_mailboxes__create_with_accent_success(role):
mailbox_values = serializers.MailboxSerializer(
factories.MailboxFactory.build(first_name="Aimé")
).data
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{mail_domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_values['local_part']}@{mail_domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
mailbox = models.Mailbox.objects.get()
@@ -187,3 +230,260 @@ def test_api_mailboxes__create_administrator_missing_fields():
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not models.Mailbox.objects.exists()
assert response.json() == {"secondary_email": ["This field is required."]}
### SYNC TO PROVISIONING API
def test_api_mailboxes__unrelated_user_provisioning_api_not_called():
"""
Provisioning API should not be called if an user tries
to create a mailbox on a domain they have no access to.
"""
domain = factories.MailDomainEnabledFactory()
client = APIClient()
client.force_login(core_factories.UserFactory()) # user with no access
body_values = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=domain)
).data
with responses.RequestsMock():
# We add no simulated response in RequestsMock
# because we expected no "outside" calls to be made
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/mailboxes/",
body_values,
format="json",
)
# No exception raised by RequestsMock means no call was sent
# our API blocked the request before sending it
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_api_mailboxes__domain_viewer_provisioning_api_not_called():
"""
Provisioning API should not be called if a domain viewer tries
to create a mailbox on a domain they are not owner/admin of.
"""
access = factories.MailDomainAccessFactory(
domain=factories.MailDomainEnabledFactory(),
user=core_factories.UserFactory(),
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(access.user)
body_values = serializers.MailboxSerializer(factories.MailboxFactory.build()).data
with responses.RequestsMock():
# We add no simulated response in RequestsMock
# because we expected no "outside" calls to be made
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
body_values,
format="json",
)
# No exception raised by RequestsMock means no call was sent
# our API blocked the request before sending it
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize(
"role",
[enums.MailDomainRoleChoices.ADMIN, enums.MailDomainRoleChoices.OWNER],
)
def test_api_mailboxes__domain_owner_or_admin_successful_creation_and_provisioning(
role,
):
"""
Domain owner/admin should be able to create mailboxes.
Provisioning API should be called when owner/admin makes a call.
Expected response contains new email and password.
"""
# creating all needed objects
access = factories.MailDomainAccessFactory(role=role)
client = APIClient()
client.force_login(access.user)
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=access.domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsp = rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
# Checks payload sent to email-provisioning API
payload = json.loads(rsps.calls[1].request.body)
assert payload == {
"displayName": f"{mailbox_data['first_name']} {mailbox_data['last_name']}",
"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 403_forbidden from dimail.
"""
# 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()
def test_api_mailboxes__handling_dimail_unexpected_error():
"""
API should raise a clear error when dimail gives 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()
@@ -78,3 +78,17 @@ def test_api_mailboxes__list_roles(role):
"secondary_email": str(mailbox1.secondary_email),
},
]
def test_api_mailboxes__list_non_existing():
"""
User gets a 404 when trying to list mailboxes of a domain which does not exist.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.MailboxFactory.create_batch(5)
response = client.get("/api/v1.0/mail-domains/nonexistent.domain/mailboxes/")
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -2,27 +2,35 @@
Unit tests for the mailbox model
"""
from django.core.exceptions import ValidationError
import json
import re
from logging import Logger
from unittest import mock
from django.core import exceptions
from django.test.utils import override_settings
import pytest
import responses
from rest_framework import status
from mailbox_manager import enums, factories
from mailbox_manager import enums, factories, models
from mailbox_manager.api import serializers
pytestmark = pytest.mark.django_db
# LOCAL PART FIELD
def test_models_mailboxes__local_part_cannot_be_empty():
"""The "local_part" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank"):
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
factories.MailboxFactory(local_part="")
def test_models_mailboxes__local_part_cannot_be_null():
"""The "local_part" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null"):
with pytest.raises(exceptions.ValidationError, match="This field cannot be null"):
factories.MailboxFactory(local_part=None)
@@ -31,13 +39,14 @@ def test_models_mailboxes__local_part_matches_expected_format():
The local part should contain alpha-numeric caracters
and a limited set of special caracters ("+", "-", ".", "_").
"""
factories.MailboxFactory(local_part="Marie-Jose.Perec+JO_2024")
# "-", ".", "_" are allowed
factories.MailboxFactory(local_part="Marie-Jose.Perec_2024")
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="!")
# 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")
def test_models_mailboxes__local_part_unique_per_domain():
@@ -50,7 +59,8 @@ def test_models_mailboxes__local_part_unique_per_domain():
# same local part on the same domain should not be possible
with pytest.raises(
ValidationError, match="Mailbox with this Local_part and Domain already exists."
exceptions.ValidationError,
match="Mailbox with this Local_part and Domain already exists.",
):
factories.MailboxFactory(
local_part=existing_mailbox.local_part, domain=existing_mailbox.domain
@@ -72,7 +82,7 @@ def test_models_mailboxes__domain_must_be_a_maildomain_instance():
def test_models_mailboxes__domain_cannot_be_null():
"""The "domain" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null"):
with pytest.raises(models.MailDomain.DoesNotExist, match="Mailbox has no domain."):
factories.MailboxFactory(domain=None)
@@ -81,13 +91,13 @@ def test_models_mailboxes__domain_cannot_be_null():
def test_models_mailboxes__secondary_email_cannot_be_empty():
"""The "secondary_email" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank"):
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
factories.MailboxFactory(secondary_email="")
def test_models_mailboxes__secondary_email_cannot_be_null():
"""The "secondary_email" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null"):
with pytest.raises(exceptions.ValidationError, match="This field cannot be null"):
factories.MailboxFactory(secondary_email=None)
@@ -95,7 +105,8 @@ def test_models_mailboxes__cannot_be_created_for_disabled_maildomain():
"""Mailbox creation is allowed only for a domain enabled.
A disabled status for the mail domain raises an error."""
with pytest.raises(
ValidationError, match="You can create mailbox only for a domain enabled"
exceptions.ValidationError,
match="You can create mailbox only for a domain enabled",
):
factories.MailboxFactory(
domain=factories.MailDomainFactory(
@@ -108,7 +119,8 @@ def test_models_mailboxes__cannot_be_created_for_failed_maildomain():
"""Mailbox creation is allowed only for a domain enabled.
A failed status for the mail domain raises an error."""
with pytest.raises(
ValidationError, match="You can create mailbox only for a domain enabled"
exceptions.ValidationError,
match="You can create mailbox only for a domain enabled",
):
factories.MailboxFactory(
domain=factories.MailDomainFactory(
@@ -121,8 +133,136 @@ def test_models_mailboxes__cannot_be_created_for_pending_maildomain():
"""Mailbox creation is allowed only for a domain enabled.
A pending status for the mail domain raises an error."""
with pytest.raises(
ValidationError, match="You can create mailbox only for a domain enabled"
exceptions.ValidationError,
match="You can create mailbox only for a domain enabled",
):
# MailDomainFactory initializes a mail domain with default values,
# so mail domain status is pending!
factories.MailboxFactory(domain=factories.MailDomainFactory())
### SYNC TO DIMAIL-API
@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)
@override_settings(MAIL_PROVISIONING_API_CREDENTIALS="wrongCredentials")
def test_models_mailboxes__dimail_token_permissions_denied():
"""
Our API should raise a clear "Permission denied" error
if dimail returns a permission denied on /token/ endpoint.
"""
domain = factories.MailDomainEnabledFactory()
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"detail": "Permission denied"}',
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
with pytest.raises(
exceptions.PermissionDenied,
match="Token denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS.",
):
mailbox = factories.MailboxFactory(use_mock=False, domain=domain)
# Payload sent to mailbox provider
payload = json.loads(rsps.calls[1].request.body)
assert payload == {
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
}
@mock.patch.object(Logger, "error")
@mock.patch.object(Logger, "info")
def test_models_mailboxes__create_mailbox_success(mock_info, mock_error):
"""Creating a mailbox sends the expected information and get expected response before saving."""
domain = factories.MailDomainEnabledFactory()
# generate mailbox data before mailbox, to mock responses
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
mailbox = factories.MailboxFactory(
use_mock=False, local_part=mailbox_data["local_part"], domain=domain
)
# Check headers
headers = rsps.calls[1].request.headers
# assert "Authorization" not in headers
assert headers["Content-Type"] == "application/json"
# Payload sent to mailbox provider
payload = json.loads(rsps.calls[1].request.body)
assert payload == {
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
}
# Logger
assert not mock_error.called
assert mock_info.call_count == 2
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",
domain.name,
)
assert mock_info.call_args_list[1][1] == (
{
"extra": {
"response": str(
{
"email": f"{mailbox.local_part}@{domain.name}",
"password": "newpass",
"uuid": "uuid",
}
)
}
}
)
+113
View File
@@ -0,0 +1,113 @@
"""A minimalist client to synchronize with mailbox provisioning API."""
from logging import getLogger
from django.conf import settings
from django.core import exceptions
import requests
from rest_framework import status
from urllib3.util import Retry
logger = getLogger(__name__)
adapter = requests.adapters.HTTPAdapter(
max_retries=Retry(
total=4,
backoff_factor=0.1,
status_forcelist=[500, 502],
allowed_methods=["PATCH"],
)
)
session = requests.Session()
session.mount("http://", adapter)
session.mount("https://", adapter)
class DimailAPIClient:
"""A dimail-API client."""
API_URL = settings.MAIL_PROVISIONING_API_URL
API_CREDENTIALS = settings.MAIL_PROVISIONING_API_CREDENTIALS
def get_headers(self):
"""
Build headers dictionary. Requires MAIL_PROVISIONING_API_CREDENTIALS setting,
to get a token from dimail /token/ endpoint.
"""
headers = {"Content-Type": "application/json"}
response = requests.get(
f"{self.API_URL}/token/",
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
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):
"""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()
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:
extra = {"response": response.content.decode("utf-8")}
# This a temporary broken solution. Password will soon be sent
# from OX servers but their prod is not ready.
# In the meantime, we log mailbox info (including password !)
logger.info(
"Mailbox successfully created on domain %s",
mailbox.domain.name,
extra=extra,
)
return response
if response.status_code == status.HTTP_403_FORBIDDEN:
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", error_content)
raise SystemError(f"Unexpected response from dimail: {error_content}")
+3
View File
@@ -7,6 +7,7 @@ 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()
@@ -36,6 +37,7 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
*resource_server_urls,
re_path(
r"^teams/(?P<team_id>[0-9a-z-]*)/",
include(team_related_router.urls),
@@ -44,4 +46,5 @@ urlpatterns = [
),
),
path(f"api/{settings.API_VERSION}/", include("mailbox_manager.urls")),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
]
+95
View File
@@ -218,6 +218,7 @@ class Base(Configuration):
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"core.resource_server.authentication.ResourceServerAuthentication",
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
"rest_framework.authentication.SessionAuthentication",
),
@@ -286,6 +287,7 @@ class Base(Configuration):
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
THUMBNAIL_ALIASES = {}
# Celery
@@ -315,6 +317,9 @@ 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
)
@@ -357,6 +362,26 @@ 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"],
@@ -364,6 +389,50 @@ 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)
# MAILBOX-PROVISIONING API
MAIL_PROVISIONING_API_URL = values.Value(
default="https://api.dev.ox.numerique.gouv.fr",
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):
@@ -409,6 +478,7 @@ class Base(Configuration):
environment=cls.__name__.lower(),
release=get_release(),
integrations=[DjangoIntegration()],
traces_sample_rate=1.0,
)
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
@@ -451,6 +521,24 @@ 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"]
@@ -492,6 +580,9 @@ 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):
"""
@@ -572,6 +663,10 @@ class Production(Base):
},
},
}
SENTRY_DSN = values.Value(
"https://b72746c73d669421e7a8ccd3fab0fad2@sentry.incubateur.net/171",
environ_name="SENTRY_DSN",
)
class Feature(Production):
+12 -11
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "people"
version = "0.1.0"
version = "1.1.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.34.153",
"boto3==1.35.14",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
@@ -36,19 +36,20 @@ dependencies = [
"django-redis==5.4.0",
"django-storages==1.14.4",
"django-timezone-field>=5.1",
"django==5.0.8",
"django==5.1.1",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.9",
"factory_boy==3.3.0",
"gunicorn==22.0.0",
"factory_boy==3.3.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.1",
"PyJWT==2.9.0",
"joserfc==1.0.0",
"requests==2.32.3",
"sentry-sdk==2.12.0",
"sentry-sdk[django]==2.13.0",
"url-normalize==1.4.3",
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
@@ -65,18 +66,18 @@ dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"ipdb==0.13.13",
"ipython==8.26.0",
"ipython==8.27.0",
"pyfakefs==5.6.0",
"pylint-django==2.5.5",
"pylint==3.2.6",
"pylint==3.2.7",
"pytest-cov==5.0.0",
"pytest-django==4.8.0",
"pytest-django==4.9.0",
"pytest==8.3.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.5.6",
"types-requests==2.32.0.20240712",
"ruff==0.6.4",
"types-requests==2.32.0.20240907",
"freezegun==1.5.1",
]
+16 -16
View File
@@ -1,6 +1,6 @@
{
"name": "app-desk",
"version": "0.1.0",
"version": "1.1.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -17,35 +17,35 @@
"dependencies": {
"@gouvfr-lasuite/integration": "1.0.2",
"@hookform/resolvers": "3.9.0",
"@openfun/cunningham-react": "2.9.3",
"@tanstack/react-query": "5.51.16",
"i18next": "23.12.2",
"@openfun/cunningham-react": "2.9.4",
"@tanstack/react-query": "5.53.2",
"i18next": "23.14.0",
"lodash": "4.17.21",
"luxon": "3.4.4",
"next": "14.2.5",
"luxon": "3.5.0",
"next": "14.2.7",
"react": "*",
"react-aria-components": "1.3.1",
"react-aria-components": "1.3.3",
"react-dom": "*",
"react-hook-form": "7.52.1",
"react-i18next": "15.0.0",
"react-hook-form": "7.53.0",
"react-i18next": "15.0.1",
"react-select": "5.8.0",
"styled-components": "6.1.12",
"styled-components": "6.1.13",
"zod": "3.23.8",
"zustand": "4.5.4"
"zustand": "4.5.5"
},
"devDependencies": {
"@hookform/devtools": "4.3.1",
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.51.16",
"@tanstack/react-query-devtools": "5.53.2",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.4.8",
"@testing-library/react": "16.0.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.12",
"@types/lodash": "4.17.7",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.3",
"@types/react": "18.3.5",
"@types/react-dom": "*",
"dotenv": "16.4.5",
"eslint-config-people": "*",
@@ -54,7 +54,7 @@
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.3.3",
"stylelint": "16.8.1",
"stylelint": "16.9.0",
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*"
@@ -1,18 +1,41 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { render } 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', () => {
it('checks Page rendering', () => {
afterEach(() => jest.clearAllMocks());
it('checks Page rendering with team feature', () => {
useConfigStore.setState({
config: { FEATURES: { TEAMS: true }, LANGUAGES: [] },
});
render(<Page />, { wrapper: AppWrapper });
expect(
screen.getByRole('button', {
name: /Create a new team/i,
}),
).toBeInTheDocument();
expect(mockedPush).toHaveBeenCalledWith('/teams/');
});
it('checks Page rendering without team feature', () => {
useConfigStore.setState({
config: { FEATURES: { TEAMS: false }, LANGUAGES: [] },
});
render(<Page />, { wrapper: AppWrapper });
expect(mockedPush).toHaveBeenCalledWith('/mail-domains/');
});
});
@@ -0,0 +1,174 @@
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: {
name: {
causes: ['Mail domain with this name already exists.'],
handleError: handleErrorMock,
},
},
serverErrorParams: {
defaultMessage: 'Server error',
handleError: handleServerErrorMock,
},
});
expect(handleErrorMock).toHaveBeenCalled();
expect(result).toEqual([]);
});
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: {
name: {
causes: ['Mail domain with this name already exists.'],
handleError: handleErrorMock,
},
},
serverErrorParams: {
defaultMessage: 'Server error',
handleError: 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: {
defaultMessage: 'Server error occurred',
handleError: handleServerErrorMock,
},
});
expect(handleServerErrorMock).toHaveBeenCalled();
expect(result).toEqual(['Server error occurred']);
});
it('should handle absence of errors gracefully', () => {
const result = parseAPIError({
error: null,
errorParams: {
name: {
causes: ['Mail domain with this name already exists.'],
handleError: handleErrorMock,
},
},
serverErrorParams: {
defaultMessage: 'Server error',
handleError: 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 = {
name: {
causes: ['Mail domain with this name already exists.'],
handleError: handleErrorMock,
},
};
const result = parseAPIErrorCause({ causes, errorParams });
expect(handleErrorMock).toHaveBeenCalled();
expect(result).toEqual([]);
});
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 = {
name: {
causes: ['Mail domain with this name already exists.'],
handleError: handleErrorMock,
},
};
const result = parseAPIErrorCause({ causes, errorParams });
expect(handleErrorMock).toHaveBeenCalled();
expect(result).toEqual(['Unhandled error']);
});
});
describe('parseServerAPIError', () => {
it('should prepend the server error message when there are other causes', () => {
const causes = ['Some other error'];
const serverErrorParams = {
defaultMessage: 'Server error',
};
const result = parseServerAPIError({ causes, serverErrorParams });
expect(result).toEqual(['Server error', 'Some other error']);
});
it('should only return server error message when no other causes exist', () => {
const causes: string[] = [];
const serverErrorParams = {
defaultMessage: 'Server error',
};
const result = parseServerAPIError({ causes, serverErrorParams });
expect(result).toEqual(['Server error']);
});
it('should call handleError when provided as a param', () => {
const handleErrorMock = jest.fn();
const causes: string[] = [];
const serverErrorParams = {
defaultMessage: 'Server error',
handleError: handleErrorMock,
};
parseServerAPIError({ causes, serverErrorParams });
expect(handleErrorMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,85 @@
import { APIError } from '@/api/index';
type ErrorParams = {
[fieldName: string]: {
causes: string[];
causeShown?: string;
handleError: () => void;
};
};
type ServerErrorParams = {
defaultMessage: string;
handleError?: () => void;
};
export type parseAPIErrorParams = {
error: APIError | null;
errorParams?: ErrorParams;
serverErrorParams: ServerErrorParams;
};
export const parseAPIError = ({
error,
errorParams,
serverErrorParams,
}: parseAPIErrorParams) => {
if (!error || !serverErrorParams?.defaultMessage) {
return;
}
let causes: string[] =
error.cause?.length && errorParams
? parseAPIErrorCause({ causes: error.cause, errorParams })
: [];
if (error?.status === 500 || !error?.status) {
causes = parseServerAPIError({ causes, serverErrorParams });
}
return causes;
};
export const parseAPIErrorCause = ({
causes,
errorParams,
}: {
causes: string[];
errorParams: ErrorParams;
}): string[] =>
causes.reduce((arrayCauses, cause) => {
const foundErrorParams = Object.values(errorParams).find((params) =>
params.causes.find((knownCause) =>
new RegExp(knownCause, 'i').test(cause),
),
);
if (!foundErrorParams) {
arrayCauses.push(cause);
}
if (foundErrorParams?.causeShown) {
arrayCauses.push(foundErrorParams.causeShown);
}
if (typeof foundErrorParams?.handleError === 'function') {
foundErrorParams.handleError();
}
return arrayCauses;
}, [] as string[]);
export const parseServerAPIError = ({
causes,
serverErrorParams,
}: {
causes: string[];
serverErrorParams: ServerErrorParams;
}): string[] => {
causes.unshift(serverErrorParams.defaultMessage);
if (typeof serverErrorParams?.handleError === 'function') {
serverErrorParams.handleError();
}
return causes;
};

Before

Width:  |  Height:  |  Size: 617 B

After

Width:  |  Height:  |  Size: 617 B

Before

Width:  |  Height:  |  Size: 500 B

After

Width:  |  Height:  |  Size: 500 B

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 429 B

@@ -0,0 +1,42 @@
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,7 +15,6 @@ export interface TextProps extends BoxProps {
>;
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
// eslint-disable-next-line @typescript-eslint/ban-types
$size?: TextSizes | (string & {});
$theme?:
| 'primary'
@@ -0,0 +1,80 @@
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');
});
});
});
@@ -6,6 +6,7 @@ import { useCunninghamTheme } from '@/cunningham';
import '@/i18n/initI18n';
import { Auth } from './auth/Auth';
import { ConfigProvider } from './config';
/**
* QueryClient:
@@ -29,7 +30,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools />
<CunninghamProvider theme={theme}>
<Auth>{children}</Auth>
<ConfigProvider>
<Auth>{children}</Auth>
</ConfigProvider>
</CunninghamProvider>
</QueryClientProvider>
);
@@ -5,13 +5,17 @@ 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">
<Menu />
{config?.FEATURES.TEAMS && <Menu />}
<Box
as="main"
$height={`calc(100vh - ${HEADER_HEIGHT})`}
@@ -0,0 +1,50 @@
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: () => '/',
}));
describe('MainLayout', () => {
it('checks menu rendering with team feature', () => {
useConfigStore.setState({
config: { FEATURES: { TEAMS: true }, LANGUAGES: [] },
});
render(<MainLayout />, { wrapper: AppWrapper });
expect(
screen.getByRole('link', {
name: /Teams button/i,
}),
).toBeInTheDocument();
expect(
screen.getByRole('link', {
name: /Mail Domains button/i,
}),
).toBeInTheDocument();
});
it('checks menu rendering without team feature', () => {
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();
});
});
@@ -0,0 +1,24 @@
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;
};
@@ -0,0 +1,11 @@
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>;
};
@@ -0,0 +1 @@
export * from './getConfig';
@@ -0,0 +1,2 @@
export * from './ConfigProvider';
export * from './useConfigStore';
@@ -0,0 +1,6 @@
export interface Config {
LANGUAGES: [string, string][];
FEATURES: {
TEAMS: boolean;
};
}
@@ -0,0 +1,26 @@
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,3 +1,4 @@
export * from './AppProvider';
export * from './MainLayout';
export * from './PageLayout';
export * from './config';
@@ -484,11 +484,6 @@ input:-webkit-autofill:focus {
/**
* Modal
*/
.c__modal:focus-visible {
outline: none;
box-shadow: none;
}
.c__modal__backdrop {
z-index: 1000;
}
@@ -1,6 +1,6 @@
import { Select } from '@openfun/cunningham-react';
import Image from 'next/image';
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
@@ -60,6 +60,19 @@ 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')}
@@ -67,7 +80,7 @@ export const LanguagePicker = () => {
clearable={false}
hideLabel
defaultValue={i18n.language}
className="c_select__no_bg"
className="c_select__no_bg c__select-language-picker"
options={optionsPicker}
onChange={(e) => {
i18n.changeLanguage(e.target.value as string).catch((err) => {
@@ -2,3 +2,4 @@ export * from './useMailDomains';
export * from './useMailDomain';
export * from './useCreateMailbox';
export * from './useMailboxes';
export * from './useAddMailDomain';
@@ -0,0 +1,52 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { MailDomain } from '@/features/mail-domains';
import { KEY_LIST_MAIL_DOMAIN } from './useMailDomains';
export interface AddMailDomainParams {
name: string;
}
export const addMailDomain = async (
name: AddMailDomainParams['name'],
): Promise<MailDomain> => {
const response = await fetchAPI(`mail-domains/`, {
method: 'POST',
body: JSON.stringify({
name,
}),
});
if (!response.ok) {
throw new APIError(
'Failed to add the mail domain',
await errorCauses(response),
);
}
return response.json() as Promise<MailDomain>;
};
export const useAddMailDomain = ({
onSuccess,
onError,
}: {
onSuccess: (data: MailDomain) => void;
onError: (error: APIError) => void;
}) => {
const queryClient = useQueryClient();
return useMutation<MailDomain, APIError, string>({
mutationFn: addMailDomain,
onSuccess: (data) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_MAIL_DOMAIN],
});
onSuccess(data);
},
onError: (error) => {
onError(error);
},
});
};
@@ -26,7 +26,6 @@ export const createMailbox = async ({
});
if (!response.ok) {
// TODO: extend errorCauses to return the name of the invalid field names to highlight in the form?
throw new APIError(
'Failed to create the mailbox',
await errorCauses(response),
@@ -40,7 +39,7 @@ type UseCreateMailboxParams = { mailDomainSlug: string } & UseMutationOptions<
CreateMailboxParams
>;
export function useCreateMailbox(options: UseCreateMailboxParams) {
export const useCreateMailbox = (options: UseCreateMailboxParams) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, CreateMailboxParams>({
mutationFn: createMailbox,
@@ -61,4 +60,4 @@ export function useCreateMailbox(options: UseCreateMailboxParams) {
}
},
});
}
};
@@ -13,7 +13,7 @@ type MailDomainResponse = MailDomain;
export const getMailDomain = async ({
slug,
}: MailDomainParams): Promise<MailDomainResponse> => {
const response = await fetchAPI(`mail-domains/${slug}`);
const response = await fetchAPI(`mail-domains/${slug}/`);
if (!response.ok) {
throw new APIError(
@@ -40,7 +40,7 @@ export const getMailDomains = async ({
return response.json() as Promise<MailDomainsResponse>;
};
export const KEY_LIST_MAIL_DOMAINS = 'mail-domains';
export const KEY_LIST_MAIL_DOMAIN = 'mail-domains';
export function useMailDomains(
param: MailDomainsParams,
@@ -60,7 +60,7 @@ export function useMailDomains(
number
>({
initialPageParam: 1,
queryKey: [KEY_LIST_MAIL_DOMAINS, param],
queryKey: [KEY_LIST_MAIL_DOMAIN, param],
queryFn: ({ pageParam }) => getMailDomains({ ...param, page: pageParam }),
getNextPageParam(lastPage, allPages) {
return lastPage.next ? allPages.length + 1 : undefined;
@@ -1,23 +1,31 @@
import { UUID } from 'crypto';
import {
Alert,
Button,
DataGrid,
Loader,
SortModel,
VariantType,
usePagination,
} from '@openfun/cunningham-react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, Text, TextErrors } from '@/components';
import { Box, Card, Text, TextErrors, TextStyled } from '@/components';
import { useMailboxes } from '../api/useMailboxes';
import { default as MailDomainsLogo } from '../assets/mail-domains-logo.svg';
import { PAGE_SIZE } from '../conf';
import { MailDomain, MailDomainMailbox } from '../types';
import { CreateMailboxForm } from './forms/CreateMailboxForm';
import { ModalCreateMailbox } from './ModalCreateMailbox';
export type ViewMailbox = { email: string; id: string };
export type ViewMailbox = {
name: string;
email: string;
id: UUID;
};
// FIXME : ask Cunningham to export this type
type SortModelItem = {
@@ -29,12 +37,6 @@ const defaultOrderingMapping: Record<string, string> = {
email: 'local_part',
};
/**
* Formats the sorting model based on a given mapping.
* @param {SortModelItem} sortModel The sorting model item containing field and sort direction.
* @param {Record<string, string>} mapping The mapping object to map field names.
* @returns {string} The formatted sorting string.
*/
function formatSortModel(
sortModel: SortModelItem,
mapping = defaultOrderingMapping,
@@ -69,6 +71,7 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
mailDomain && data?.results?.length
? data.results.map((mailbox: MailDomainMailbox) => ({
email: `${mailbox.local_part}@${mailDomain.name}`,
name: `${mailbox.first_name} ${mailbox.last_name}`,
id: mailbox.id,
}))
: [];
@@ -76,6 +79,7 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
useEffect(() => {
setPagesCount(data?.count ? Math.ceil(data.count / pageSize) : 0);
}, [data?.count, pageSize, setPagesCount]);
return isLoading ? (
<Box $align="center" $justify="center" $height="100%">
<Loader />
@@ -83,23 +87,39 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
) : (
<>
{isCreateMailboxFormVisible && mailDomain ? (
<CreateMailboxForm
<ModalCreateMailbox
mailDomain={mailDomain}
closeModal={() => setIsCreateMailboxFormVisible(false)}
/>
) : null}
<TopBanner
name={mailDomain.name}
setIsFormVisible={setIsCreateMailboxFormVisible}
mailDomain={mailDomain}
showMailBoxCreationForm={setIsCreateMailboxFormVisible}
/>
<Card
$padding={{ bottom: 'small' }}
$margin={{ all: 'big', top: 'none' }}
$overflow="auto"
>
{error && <TextErrors causes={error.cause} />}
<DataGrid
columns={[
{
field: 'name',
headerName: t('Names'),
renderCell: ({ row }) => (
<Text
$weight="bold"
$theme="primary"
$css="text-transform: capitalize;"
>
{row.name}
</Text>
),
},
{
field: 'email',
headerName: t('Emails'),
@@ -125,35 +145,111 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
}
const TopBanner = ({
name,
setIsFormVisible,
mailDomain,
showMailBoxCreationForm,
}: {
name: string;
setIsFormVisible: (value: boolean) => void;
mailDomain: MailDomain;
showMailBoxCreationForm: (value: boolean) => void;
}) => {
const { t } = useTranslation();
return (
<>
<Box
$direction="column"
$margin={{ all: 'big', bottom: 'tiny' }}
$gap="1rem"
>
<Box
$direction="row"
$align="center"
$margin={{ all: 'big', vertical: 'xbig' }}
$gap="2.25rem"
$justify="space-between"
>
<MailDomainsLogo aria-hidden="true" />
<Text $margin="none" as="h3" $size="h3">
{name}
</Text>
<Box $direction="row" $margin="none" $gap="2.25rem">
<MailDomainsLogo aria-hidden="true" />
<Text $margin="none" as="h3" $size="h3">
{mailDomain?.name}
</Text>
</Box>
</Box>
<Box $margin={{ all: 'big', bottom: 'small' }} $align="flex-end">
<Button
aria-label={t(`Create a mailbox in {{name}} domain`, { name })}
onClick={() => setIsFormVisible(true)}
>
{t('Create a mailbox')}
</Button>
<Box $direction="row" $justify="space-between">
<AlertStatus status={mailDomain.status} />
</Box>
</>
{mailDomain?.abilities.post && (
<Box $direction="row-reverse">
<Box $display="inline">
<Button
aria-label={t('Create a mailbox in {{name}} domain', {
name: mailDomain?.name,
})}
disabled={mailDomain?.status !== 'enabled'}
onClick={() => showMailBoxCreationForm(true)}
>
{t('Create a mailbox')}
</Button>
</Box>
</Box>
)}
</Box>
);
};
const AlertStatus = ({ status }: { status: MailDomain['status'] }) => {
const { t } = useTranslation();
const getStatusAlertProps = (status?: string) => {
switch (status) {
case 'pending':
return {
variant: VariantType.WARNING,
message: t(
'Your domain name is being validated. ' +
'You will not be able to create mailboxes until your domain name has been validated by our team.',
),
};
case 'disabled':
return {
variant: VariantType.NEUTRAL,
message: t(
'This domain name is deactivated. No new mailboxes can be created.',
),
};
case 'failed':
return {
variant: VariantType.ERROR,
message: (
<Text $display="inline">
{t(
'The domain name encounters an error. Please contact our support team to solve the problem:',
)}{' '}
<TextStyled
as="a"
target="_blank"
$display="inline"
href="mailto:suiteterritoriale@anct.gouv.fr"
aria-label={t(
'Contact our support at "suiteterritoriale@anct.gouv.fr"',
)}
>
suiteterritoriale@anct.gouv.fr
</TextStyled>
.
</Text>
),
};
}
};
const alertStatusProps = getStatusAlertProps(status);
if (!alertStatusProps) {
return null;
}
return (
<Alert canClose={false} type={alertStatusProps.variant}>
<Text $display="inline">{alertStatusProps.message}</Text>
</Alert>
);
};
@@ -0,0 +1,179 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Button, Input, Loader, ModalSize } from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import React, { useState } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { parseAPIError } from '@/api/parseAPIError';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
import { useAddMailDomain } from '@/features/mail-domains';
import { default as MailDomainsLogo } from '../assets/mail-domains-logo.svg';
const FORM_ID = 'form-add-mail-domain';
export const ModalAddMailDomain = () => {
const { t } = useTranslation();
const router = useRouter();
const [errorCauses, setErrorCauses] = useState<string[]>([]);
const addMailDomainValidationSchema = z.object({
name: z.string().min(1, t('Example: saint-laurent.fr')),
});
const methods = useForm<{ name: string }>({
delayError: 0,
defaultValues: {
name: '',
},
mode: 'onChange',
reValidateMode: 'onChange',
resolver: zodResolver(addMailDomainValidationSchema),
});
const { mutate: addMailDomain, isPending } = useAddMailDomain({
onSuccess: (mailDomain) => {
router.push(`/mail-domains/${mailDomain.slug}`);
},
onError: (error) => {
const unhandledCauses = parseAPIError({
error,
errorParams: {
name: {
causes: [
'Mail domain with this name already exists.',
'Mail domain with this Slug already exists.',
],
handleError: () => {
if (methods.formState.errors.name) {
return;
}
methods.setError('name', {
type: 'manual',
message: t(
'This mail domain is already used. Please, choose another one.',
),
});
methods.setFocus('name');
},
},
},
serverErrorParams: {
handleError: () => {
methods.setFocus('name');
},
defaultMessage: t(
'Your request cannot be processed because the server is experiencing an error. If the problem ' +
'persists, please contact our support to resolve the issue: suiteterritoriale@anct.gouv.fr',
),
},
});
setErrorCauses((prevState) =>
unhandledCauses &&
JSON.stringify(unhandledCauses) !== JSON.stringify(prevState)
? unhandledCauses
: prevState,
);
},
});
const onSubmitCallback = (event: React.FormEvent) => {
event.preventDefault();
void methods.handleSubmit(({ name }) => {
void addMailDomain(name);
})();
};
if (!methods) {
return null;
}
return (
<Modal
isOpen
leftActions={
<Button color="secondary" onClick={() => router.push('/mail-domains/')}>
{t('Cancel')}
</Button>
}
hideCloseButton
closeOnClickOutside
closeOnEsc
onClose={() => router.push('/mail-domains/')}
rightActions={
<Button
type="submit"
form={FORM_ID}
disabled={
methods.formState.isSubmitting ||
!methods.formState.isValid ||
isPending
}
>
{t('Add the domain')}
</Button>
}
size={ModalSize.MEDIUM}
title={
<>
<MailDomainsLogo aria-hidden="true" />
<Text as="h3" $textAlign="center">
{t('Add a mail domain')}
</Text>
</>
}
>
{!!errorCauses?.length ? (
<TextErrors
$margin={{ bottom: 'small' }}
$textAlign="left"
causes={errorCauses}
/>
) : null}
<FormProvider {...methods}>
<form
id={FORM_ID}
onSubmit={onSubmitCallback}
title={t('Mail domain addition form')}
>
<Controller
control={methods.control}
name="name"
render={({ fieldState }) => (
<Input
fullWidth
type="text"
{...methods.register('name')}
aria-invalid={!!fieldState.error}
aria-required
required
autoComplete="off"
label={t('Domain name')}
state={fieldState.error ? 'error' : 'default'}
text={
fieldState?.error?.message
? fieldState.error.message
: t('Example: saint-laurent.fr')
}
/>
)}
/>
</form>
{isPending && (
<Box $align="center">
<Loader />
</Box>
)}
</FormProvider>
</Modal>
);
};
@@ -2,12 +2,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
import {
Button,
Input,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import React from 'react';
import React, { useState } from 'react';
import {
Controller,
FormProvider,
@@ -18,10 +17,12 @@ import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { z } from 'zod';
import { parseAPIError } from '@/api/parseAPIError';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
import { CreateMailboxParams, useCreateMailbox } from '../../api';
import { MailDomain } from '../../types';
import { CreateMailboxParams, useCreateMailbox } from '../api';
import { MailDomain } from '../types';
const FORM_ID: string = 'form-create-mailbox';
@@ -32,7 +33,7 @@ const GlobalStyle = createGlobalStyle`
}
`;
export const CreateMailboxForm = ({
export const ModalCreateMailbox = ({
mailDomain,
closeModal,
}: {
@@ -42,6 +43,8 @@ export const CreateMailboxForm = ({
const { t } = useTranslation();
const { toast } = useToastProvider();
const [errorCauses, setErrorCauses] = useState<string[]>([]);
const messageInvalidMinChar = t('You must have minimum 1 character');
const createMailboxValidationSchema = z.object({
@@ -77,7 +80,7 @@ export const CreateMailboxForm = ({
resolver: zodResolver(createMailboxValidationSchema),
});
const { mutate: createMailbox, error } = useCreateMailbox({
const { mutate: createMailbox, isPending } = useCreateMailbox({
mailDomainSlug: mailDomain.slug,
onSuccess: () => {
toast(t('Mailbox created!'), VariantType.SUCCESS, {
@@ -86,6 +89,52 @@ export const CreateMailboxForm = ({
closeModal();
},
onError: (error) => {
const unhandledCauses = parseAPIError({
error,
errorParams: {
local_part: {
causes: ['Mailbox with this Local_part and Domain already exists.'],
handleError: () => {
methods.setError('local_part', {
type: 'manual',
message: t('This email prefix is already used.'),
});
methods.setFocus('local_part');
},
},
secret: {
causes: [
"Please configure your domain's secret before creating any mailbox.",
`Secret not valid for this domain`,
],
causeShown: t(
'The mail domain secret is misconfigured. Please, contact ' +
'our support team to solve the issue: suiteterritoriale@anct.gouv.fr',
),
handleError: () => {
methods.setFocus('first_name');
},
},
},
serverErrorParams: {
handleError: () => {
methods.setFocus('first_name');
},
defaultMessage: t(
'Your request cannot be processed because the server is experiencing an error. If the problem ' +
'persists, please contact our support to resolve the issue: suiteterritoriale@anct.gouv.fr',
),
},
});
setErrorCauses((prevState) =>
unhandledCauses &&
JSON.stringify(unhandledCauses) !== JSON.stringify(prevState)
? unhandledCauses
: prevState,
);
},
});
const onSubmitCallback = (event: React.FormEvent) => {
@@ -95,20 +144,6 @@ export const CreateMailboxForm = ({
)();
};
const causes = error?.cause?.filter((cause) => {
const isFound =
cause === 'Mailbox with this Local_part and Domain already exists.';
if (isFound) {
methods.setError('local_part', {
type: 'manual',
message: t('This email prefix is already used.'),
});
}
return !isFound;
});
return (
<FormProvider {...methods}>
<Modal
@@ -132,7 +167,11 @@ export const CreateMailboxForm = ({
fullWidth
type="submit"
form={FORM_ID}
disabled={methods.formState.isSubmitting}
disabled={
methods.formState.isSubmitting ||
!methods.formState.isValid ||
isPending
}
>
{t('Create the mailbox')}
</Button>
@@ -152,8 +191,12 @@ export const CreateMailboxForm = ({
>
<GlobalStyle />
<Box $width="100%" $margin={{ top: 'none', bottom: 'xl' }}>
{!!causes?.length && (
<TextErrors $margin={{ bottom: 'small' }} causes={causes} />
{!!errorCauses?.length && (
<TextErrors
$margin={{ bottom: 'small' }}
causes={errorCauses}
$textAlign="left"
/>
)}
<Text
$margin={{ horizontal: 'none', vertical: 'big' }}
@@ -188,7 +231,11 @@ const Form = ({
const { t } = useTranslation();
return (
<form onSubmit={onSubmitCallback} id={FORM_ID}>
<form
onSubmit={onSubmitCallback}
id={FORM_ID}
title={t('Mailbox creation form')}
>
<Box $direction="column" $width="100%" $gap="2rem" $margin="auto">
<Box $margin={{ horizontal: 'none' }}>
<FieldMailBox
@@ -0,0 +1,241 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import React from 'react';
import { AppWrapper } from '@/tests/utils';
import { ModalAddMailDomain } from '../ModalAddMailDomain';
const mockPush = jest.fn();
jest.mock('next/navigation', () => ({
useRouter: jest.fn().mockImplementation(() => ({
push: mockPush,
})),
}));
describe('ModalAddMailDomain', () => {
const getElements = () => ({
modalElement: screen.getByText('Add a mail domain'),
formTag: screen.getByTitle('Mail domain addition form'),
inputName: screen.getByLabelText(/Domain name/i),
buttonCancel: screen.getByRole('button', { name: /Cancel/i, hidden: true }),
buttonSubmit: screen.getByRole('button', {
name: /Add the domain/i,
hidden: true,
}),
});
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
fetchMock.restore();
});
it('renders all the elements', () => {
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { modalElement, formTag, inputName, buttonCancel, buttonSubmit } =
getElements();
expect(modalElement).toBeVisible();
expect(formTag).toBeVisible();
expect(inputName).toBeVisible();
expect(screen.getByText('Example: saint-laurent.fr')).toBeVisible();
expect(buttonCancel).toBeVisible();
expect(buttonSubmit).toBeVisible();
});
it('should disable submit button when no field is filled', () => {
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { buttonSubmit } = getElements();
expect(buttonSubmit).toBeDisabled();
});
it('displays validation error on empty submit', async () => {
fetchMock.mock(`end:mail-domains/`, 201);
const user = userEvent.setup();
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, buttonSubmit } = getElements();
await user.type(inputName, 'domain.fr');
await user.clear(inputName);
await user.click(buttonSubmit);
await waitFor(() => {
expect(
screen.getByText(/Example: saint-laurent.fr/i),
).toBeInTheDocument();
});
expect(fetchMock.lastUrl()).toBeFalsy();
});
it('submits the form when validation passes', async () => {
fetchMock.mock(`end:mail-domains/`, {
status: 201,
body: {
name: 'domain.fr',
id: '456ac6ca-0402-4615-8005-69bc1efde43f',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
slug: 'domainfr',
status: 'enabled',
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
},
});
const user = userEvent.setup();
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, buttonSubmit } = getElements();
await user.type(inputName, 'domain.fr');
await user.click(buttonSubmit);
expect(fetchMock.lastUrl()).toContain('/mail-domains/');
expect(fetchMock.lastOptions()).toEqual({
body: JSON.stringify({
name: 'domain.fr',
}),
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
expect(mockPush).toHaveBeenCalledWith(`/mail-domains/domainfr`);
});
it('submits the form on key enter press', async () => {
fetchMock.mock(`end:mail-domains/`, 201);
const user = userEvent.setup();
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName } = getElements();
await user.type(inputName, 'domain.fr');
await user.type(inputName, '{enter}');
expect(fetchMock.lastUrl()).toContain('/mail-domains/');
expect(fetchMock.lastOptions()).toEqual({
body: JSON.stringify({
name: 'domain.fr',
}),
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
});
it('displays right error message error when maildomain name is already used', async () => {
fetchMock.mock(`end:mail-domains/`, {
status: 400,
body: {
name: 'Mail domain with this name already exists.',
},
});
const user = userEvent.setup();
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, buttonSubmit } = getElements();
await user.type(inputName, 'domain.fr');
await user.click(buttonSubmit);
await waitFor(() => {
expect(
screen.getByText(
/This mail domain is already used. Please, choose another one./i,
),
).toBeInTheDocument();
});
expect(inputName).toHaveFocus();
await user.type(inputName, 'domain2.fr');
expect(buttonSubmit).toBeEnabled();
});
it('displays right error message error when maildomain slug is already used', async () => {
fetchMock.mock(`end:mail-domains/`, {
status: 400,
body: {
name: 'Mail domain with this Slug already exists.',
},
});
const user = userEvent.setup();
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, buttonSubmit } = getElements();
await user.type(inputName, 'domainfr');
await user.click(buttonSubmit);
await waitFor(() => {
expect(
screen.getByText(
/This mail domain is already used. Please, choose another one./i,
),
).toBeInTheDocument();
});
expect(inputName).toHaveFocus();
await user.type(inputName, 'domain2fr');
expect(buttonSubmit).toBeEnabled();
});
it('displays right error message error when error 500 is received', async () => {
fetchMock.mock(`end:mail-domains/`, {
status: 500,
});
const user = userEvent.setup();
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, buttonSubmit } = getElements();
await user.type(inputName, 'domain.fr');
await user.click(buttonSubmit);
await waitFor(() => {
expect(
screen.getByText(
'Your request cannot be processed because the server is experiencing an error. If the problem ' +
'persists, please contact our support to resolve the issue: suiteterritoriale@anct.gouv.fr',
),
).toBeInTheDocument();
});
expect(inputName).toHaveFocus();
expect(buttonSubmit).toBeEnabled();
});
});
@@ -0,0 +1,333 @@
import { useMutation } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import React from 'react';
import { APIError } from '@/api';
import { AppWrapper } from '@/tests/utils';
import { CreateMailboxParams } from '../../api';
import { MailDomain } from '../../types';
import { ModalCreateMailbox } from '../ModalCreateMailbox';
const mockMailDomain: MailDomain = {
name: 'domain.fr',
id: '456ac6ca-0402-4615-8005-69bc1efde43f',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
slug: 'domainfr',
status: 'enabled',
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
};
const mockOnSuccess = jest.fn();
jest.mock('../../api/useCreateMailbox', () => {
const { createMailbox } = jest.requireActual('../../api/useCreateMailbox');
return {
useCreateMailbox: jest.fn().mockImplementation(({ onError }) =>
useMutation<void, APIError, CreateMailboxParams>({
mutationFn: createMailbox,
onSuccess: mockOnSuccess,
onError: (error) => onError(error),
}),
),
};
});
describe('ModalCreateMailbox', () => {
const mockCloseModal = jest.fn();
const renderModalCreateMailbox = () => {
return render(
<ModalCreateMailbox
mailDomain={mockMailDomain}
closeModal={mockCloseModal}
/>,
{ wrapper: AppWrapper },
);
};
const getFormElements = () => ({
formTag: screen.getByTitle('Mailbox creation form'),
inputFirstName: screen.getByLabelText(/First name/i),
inputLastName: screen.getByLabelText(/Last name/i),
inputLocalPart: screen.getByLabelText(/Email address prefix/i),
inputEmailAddress: screen.getByLabelText(/Secondary email address/i),
buttonCancel: screen.getByRole('button', { name: /Cancel/i, hidden: true }),
buttonSubmit: screen.getByRole('button', {
name: /Create the mailbox/i,
hidden: true,
}),
});
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
fetchMock.restore();
});
it('renders all the elements', () => {
renderModalCreateMailbox();
const {
formTag,
inputFirstName,
inputLastName,
inputLocalPart,
inputEmailAddress,
buttonCancel,
buttonSubmit,
} = getFormElements();
expect(formTag).toBeVisible();
expect(inputFirstName).toBeVisible();
expect(inputLastName).toBeVisible();
expect(inputLocalPart).toBeVisible();
expect(screen.getByText(`@${mockMailDomain.name}`)).toBeVisible();
expect(inputEmailAddress).toBeVisible();
expect(buttonCancel).toBeVisible();
expect(buttonSubmit).toBeVisible();
});
it('clicking on cancel button closes modal', async () => {
const user = userEvent.setup();
renderModalCreateMailbox();
const { buttonCancel } = getFormElements();
expect(buttonCancel).toBeVisible();
await user.click(buttonCancel);
expect(mockCloseModal).toHaveBeenCalled();
});
it('displays validation errors on empty submit', async () => {
const user = userEvent.setup();
renderModalCreateMailbox();
const {
inputFirstName,
inputLastName,
inputLocalPart,
inputEmailAddress,
buttonSubmit,
} = getFormElements();
// To bypass html form validation we need to fill and clear the fields
await user.type(inputFirstName, 'John');
await user.type(inputLastName, 'Doe');
await user.type(inputLocalPart, 'john.doe');
await user.type(inputEmailAddress, 'john.doe@mail.com');
await user.clear(inputFirstName);
await user.clear(inputLastName);
await user.clear(inputLocalPart);
await user.clear(inputEmailAddress);
await user.click(buttonSubmit);
expect(screen.getByText(`@${mockMailDomain.name}`)).toBeVisible();
await waitFor(() => {
expect(
screen.getByText(/Please enter your first name/i),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByText(/Please enter your last name/i),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByText(/You must have minimum 1 character/i),
).toBeInTheDocument();
});
expect(fetchMock.lastUrl()).toBeFalsy();
expect(buttonSubmit).toBeDisabled();
});
it('submits the form when validation passes', async () => {
fetchMock.mock(`end:mail-domains/${mockMailDomain.slug}/mailboxes/`, 201);
const user = userEvent.setup();
renderModalCreateMailbox();
const {
inputFirstName,
inputLastName,
inputLocalPart,
inputEmailAddress,
buttonSubmit,
} = getFormElements();
await user.type(inputFirstName, 'John');
await user.type(inputLastName, 'Doe');
await user.type(inputLocalPart, 'john.doe');
await user.type(inputEmailAddress, 'john.doe@mail.com');
await user.click(buttonSubmit);
await waitFor(() => {
expect(
screen.queryByText(/Please enter your first name/i),
).not.toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.queryByText(/Please enter your last name/i),
).not.toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.queryByText(/You must have minimum 1 character/i),
).not.toBeInTheDocument();
});
expect(fetchMock.lastOptions()).toEqual({
body: JSON.stringify({
first_name: 'John',
last_name: 'Doe',
local_part: 'john.doe',
secondary_email: 'john.doe@mail.com',
}),
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
expect(mockOnSuccess).toHaveBeenCalled();
});
it('submits the form on key enter press', async () => {
fetchMock.mock(`end:mail-domains/${mockMailDomain.slug}/mailboxes/`, 201);
const user = userEvent.setup();
renderModalCreateMailbox();
const {
inputFirstName,
inputLastName,
inputLocalPart,
inputEmailAddress,
buttonSubmit,
} = getFormElements();
await user.type(inputFirstName, 'John');
await user.type(inputLastName, 'Doe');
await user.type(inputLocalPart, 'john.doe');
await user.type(inputEmailAddress, 'john.doe@mail.com');
await user.type(buttonSubmit, '{enter}');
expect(fetchMock.lastOptions()).toEqual({
body: JSON.stringify({
first_name: 'John',
last_name: 'Doe',
local_part: 'john.doe',
secondary_email: 'john.doe@mail.com',
}),
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
expect(mockOnSuccess).toHaveBeenCalled();
});
it('displays right error message error when mailbox prefix is already used', async () => {
// mockCreateMailbox.mockRejectedValueOnce(
// new APIError('Failed to create the mailbox', {
// status: 400,
// cause: ['Mailbox with this Local_part and Domain already exists.'],
// }),
// );
fetchMock.mock(`end:mail-domains/${mockMailDomain.slug}/mailboxes/`, {
status: 400,
body: {
local_part: 'Mailbox with this Local_part and Domain already exists.',
},
});
const user = userEvent.setup();
renderModalCreateMailbox();
const {
inputFirstName,
inputLastName,
inputLocalPart,
inputEmailAddress,
buttonSubmit,
} = getFormElements();
await user.type(inputFirstName, 'John');
await user.type(inputLastName, 'Doe');
await user.type(inputLocalPart, 'john.doe');
await user.type(inputEmailAddress, 'john.doe@mail.com');
await user.click(buttonSubmit);
await waitFor(() => {
expect(
screen.getByText(/This email prefix is already used./i),
).toBeInTheDocument();
});
expect(inputLocalPart).toHaveFocus();
});
it('displays right error message error when error 500 is received', async () => {
fetchMock.mock(`end:mail-domains/${mockMailDomain.slug}/mailboxes/`, {
status: 500,
});
const user = userEvent.setup();
renderModalCreateMailbox();
const {
inputFirstName,
inputLastName,
inputLocalPart,
inputEmailAddress,
buttonSubmit,
} = getFormElements();
await user.type(inputFirstName, 'John');
await user.type(inputLastName, 'Doe');
await user.type(inputLocalPart, 'john.doe');
await user.type(inputEmailAddress, 'john.doe@mail.com');
await user.click(buttonSubmit);
await waitFor(() => {
expect(
screen.getByText(
'Your request cannot be processed because the server is experiencing an error. If the problem ' +
'persists, please contact our support to resolve the issue: suiteterritoriale@anct.gouv.fr',
),
).toBeInTheDocument();
});
expect(inputFirstName).toHaveFocus();
expect(buttonSubmit).toBeEnabled();
});
});
@@ -86,7 +86,7 @@ const ItemListState = ({
return (
<Box $justify="center" $margin="small">
<Text as="p" $margin={{ vertical: 'none' }}>
{t(`0 mail domain to display.`)}
{t(`No domains exist.`)}
</Text>
</Box>
);
@@ -1,17 +1,18 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import IconOpenClose from '@/assets/icons/icon-open-close.svg';
import { Box, BoxButton, Text } from '@/components';
import { useConfigStore } from '@/core/';
import { useCunninghamTheme } from '@/cunningham';
import IconOpenClose from '../../assets/icon-open-close.svg';
import { ItemList } from './ItemList';
import { PanelActions } from './PanelActions';
export const Panel = () => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { config } = useConfigStore();
const [isOpen, setIsOpen] = useState(true);
@@ -21,6 +22,11 @@ export const Panel = () => {
$minWidth: '0',
};
const styleNoTeam = !config?.FEATURES.TEAMS && {
$display: 'none',
tabIndex: -1,
};
const transition = 'all 0.5s ease-in-out';
return (
@@ -34,7 +40,7 @@ export const Panel = () => {
transition: ${transition};
`}
$height="inherit"
aria-label="mail domains panel"
aria-label={t('Mail domains panel')}
{...closedOverridingStyles}
>
<BoxButton
@@ -52,6 +58,7 @@ export const Panel = () => {
transition: ${transition};
`}
onClick={() => setIsOpen(!isOpen)}
{...styleNoTeam}
>
<IconOpenClose width={24} height={24} aria-hidden="true" />
</BoxButton>
@@ -1,13 +1,13 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton } from '@/components';
import IconAdd from '@/assets/icons/icon-add.svg';
import IconSort from '@/assets/icons/icon-sort.svg';
import { Box, BoxButton, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { EnumMailDomainsOrdering } from '@/features/mail-domains';
import { useMailDomainsStore } from '@/features/mail-domains/store/useMailDomainsStore';
import IconSort from '../../assets/icon-sort.svg';
export const PanelActions = () => {
const { t } = useTranslation();
const { changeOrdering, ordering } = useMailDomainsStore();
@@ -42,6 +42,16 @@ export const PanelActions = () => {
>
<IconSort width={30} height={30} aria-hidden="true" />
</BoxButton>
<StyledLink href="/mail-domains/add/">
<Text
$margin="auto"
aria-label={t('Add a mail domain')}
$theme="primary"
>
<IconAdd width={27} height={27} aria-hidden="true" />
</Text>
</StyledLink>
</Box>
);
};
@@ -1,5 +1,6 @@
import { useRouter } from 'next/router';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
@@ -11,6 +12,7 @@ interface MailDomainProps {
}
export const PanelMailDomains = ({ mailDomain }: MailDomainProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const {
query: { slug },
@@ -18,10 +20,23 @@ export const PanelMailDomains = ({ mailDomain }: MailDomainProps) => {
const isActive = mailDomain.slug === slug;
const getStatusText = (status: MailDomain['status']) => {
switch (status) {
case 'pending':
return t('[pending]');
case 'enabled':
return t('[enabled]');
case 'disabled':
return t('[disabled]');
case 'failed':
return t('[failed]');
}
};
const activeStyle = `
border-right: 4px solid ${colorsTokens()['primary-600']};
background: ${colorsTokens()['primary-400']};
span{
span {
color: ${colorsTokens()['primary-text']};
}
`;
@@ -31,12 +46,14 @@ export const PanelMailDomains = ({ mailDomain }: MailDomainProps) => {
border-right: 4px solid ${colorsTokens()['primary-400']};
background: ${colorsTokens()['primary-300']};
span{
span {
color: ${colorsTokens()['primary-text']};
}
}
`;
const statusText = getStatusText(mailDomain.status);
return (
<Box
$margin="none"
@@ -49,29 +66,44 @@ export const PanelMailDomains = ({ mailDomain }: MailDomainProps) => {
>
<StyledLink
className="p-s pt-t pb-t"
$css="width: 100%"
href={`/mail-domains/${mailDomain.slug}`}
>
<Box $align="center" $direction="row" $gap="0.5rem">
<IconMailDomains
aria-hidden="true"
color={colorsTokens()['primary-500']}
className="p-t"
width="52"
style={{
borderRadius: '10px',
flexShrink: 0,
background: '#fff',
border: `1px solid ${colorsTokens()['primary-300']}`,
}}
/>
<Text
$weight="bold"
$color={colorsTokens()['greyscale-600']}
$css={`
min-width: 14rem;
<Box
$position="relative"
$align="center"
$direction="row"
$justify="space-between"
$gap="1rem"
>
<Box $direction="row" $gap="0.5rem" $justify="left" $align="center">
<IconMailDomains
aria-hidden="true"
color={colorsTokens()['primary-500']}
className="p-t"
width="52"
style={{
borderRadius: '10px',
flexShrink: 0,
background: '#fff',
border: `1px solid ${colorsTokens()['primary-300']}`,
}}
/>
<Text
$weight="bold"
$color={colorsTokens()['greyscale-600']}
$css={`
display: inline-block;
width: 10rem;
overflow: hidden;
text-overflow: ellipsis !important;
`}
>
{mailDomain.name}
>
{mailDomain.name}
</Text>
</Box>
<Text $size="s" $theme="greyscale">
{statusText}
</Text>
</Box>
</StyledLink>
@@ -6,10 +6,21 @@ export interface MailDomain {
created_at: string;
updated_at: string;
slug: string;
status: 'pending' | 'enabled' | 'failed' | 'disabled';
abilities: {
get: boolean;
patch: boolean;
put: boolean;
post: boolean;
delete: boolean;
manage_accesses: boolean;
};
}
export interface MailDomainMailbox {
id: UUID;
local_part: string;
first_name: string;
last_name: string;
secondary_email: string;
}
@@ -25,7 +25,7 @@ export const Menu = () => {
<MenuItem
Icon={IconGroup}
label={t('Teams')}
href="/"
href="/teams"
alias={['/teams']}
/>
<MenuItem
@@ -70,7 +70,7 @@ const MenuItem = ({ Icon, label, href, alias }: MenuItemProps) => {
<BoxButton
aria-label={t(`{{label}} button`, { label })}
$color={color}
tabIndex={-1}
as="span"
>
<Icon
width="2.375rem"
@@ -1,6 +1,5 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
@@ -11,6 +10,7 @@ import { createGlobalStyle } from 'styled-components';
import { APIError } from '@/api';
import { Box, Text } from '@/components';
import { Modal } from '@/components/Modal';
import { useCunninghamTheme } from '@/cunningham';
import { ChooseRole } from '@/features/teams/member-management';
import { Role, Team } from '@/features/teams/team-management';
@@ -1,6 +1,5 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
@@ -10,6 +9,7 @@ import { useRouter } from 'next/navigation';
import IconUser from '@/assets/icons/icon-user.svg';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
import { useCunninghamTheme } from '@/cunningham';
import { Role, Team } from '@/features/teams/team-management';
@@ -48,7 +48,11 @@ export const ModalDelete = ({ access, onClose, team }: ModalDeleteProps) => {
// If we remove ourselves, we redirect to the home page
// because we are no longer part of the team
isMyself ? router.push('/') : onClose();
if (isMyself) {
router.push('/');
} else {
onClose();
}
},
});
@@ -1,6 +1,5 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
@@ -9,6 +8,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
import { Role } from '@/features/teams/team-management';
import { useUpdateTeamAccess } from '../api/useUpdateTeamAccess';
@@ -1,6 +1,5 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
@@ -10,6 +9,7 @@ import { useRouter } from 'next/navigation';
import IconGroup from '@/assets/icons/icon-group.svg';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import { useRemoveTeam } from '../api/useRemoveTeam';
@@ -1,6 +1,5 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
@@ -9,6 +8,7 @@ import { t } from 'i18next';
import { useState } from 'react';
import { Box, Text } from '@/components';
import { Modal } from '@/components/Modal';
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import { useUpdateTeam } from '../api';
@@ -1,4 +0,0 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="11.5" transform="rotate(-180 12 12)" fill="white" stroke="currentColor"/>
<path d="M14.1683 16.232C14.4803 15.92 14.4803 15.416 14.1683 15.104L11.0643 12L14.1683 8.896C14.4803 8.584 14.4803 8.08 14.1683 7.768C13.8563 7.456 13.3523 7.456 13.0403 7.768L9.36834 11.44C9.05634 11.752 9.05634 12.256 9.36834 12.568L13.0403 16.24C13.3443 16.544 13.8563 16.544 14.1683 16.232Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 500 B

@@ -1,13 +0,0 @@
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_178_17837)">
<path
d="M11.25 3.75L6.25 8.7375H10V17.5H12.5V8.7375H16.25L11.25 3.75ZM20 21.2625V12.5H17.5V21.2625H13.75L18.75 26.25L23.75 21.2625H20Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_178_17837">
<rect width="30" height="30" fill="white" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 429 B

@@ -1,11 +1,10 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import IconOpenClose from '@/assets/icons/icon-open-close.svg';
import { Box, BoxButton, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import IconOpenClose from '../assets/icon-open-close.svg';
import { PanelActions } from './PanelActions';
import { TeamList } from './TeamList';
@@ -1,12 +1,12 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import IconAdd from '@/assets/icons/icon-add.svg';
import IconSort from '@/assets/icons/icon-sort.svg';
import { Box, BoxButton, StyledLink } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { TeamsOrdering } from '@/features/teams/team-management/api';
import IconAdd from '../assets/icon-add.svg';
import IconSort from '../assets/icon-sort.svg';
import { useTeamStore } from '../store/useTeamsStore';
export const PanelActions = () => {
@@ -45,11 +45,13 @@ export const PanelActions = () => {
</BoxButton>
<StyledLink href="/teams/create">
<BoxButton
as="span"
$margin={{ all: 'auto' }}
aria-label={t('Add a team')}
$color={colorsTokens()['primary-600']}
tabIndex={-1}
>
<IconAdd width={30} height={30} aria-hidden="true" />
<IconAdd width={27} height={27} aria-hidden="true" />
</BoxButton>
</StyledLink>
</Box>

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