Compare commits
41 Commits
fix/groups
...
v1.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 675ba4b557 | |||
| f5c7abcd20 | |||
| 8c54e701c1 | |||
| ff2cbe3aed | |||
| 7f3f25c61d | |||
| bd36983e3c | |||
| e8cc6a6f23 | |||
| 9adb2d4cf4 | |||
| bb5058c478 | |||
| 78818ba541 | |||
| f1a2b7c603 | |||
| 5634a7f390 | |||
| 9c05167d80 | |||
| 21371dbd1b | |||
| b40aefc505 | |||
| 591b3eedff | |||
| 09cb7ff6f1 | |||
| 87966fa062 | |||
| e4dd3395bb | |||
| 93681b0030 | |||
| e04a994d37 | |||
| ba46d7de54 | |||
| b79b4b1853 | |||
| e3f8633931 | |||
| b84e8b89f7 | |||
| 32889b9e8c | |||
| 75647fe289 | |||
| 03bfef6061 | |||
| 85c789bb1a | |||
| 049d8695be | |||
| 49c238155c | |||
| b79725acbe | |||
| 439ddb9d4a | |||
| a7a923e790 | |||
| 5ed63fc091 | |||
| f55cb3a813 | |||
| 2c82f38c59 | |||
| 8963f0bb3d | |||
| 733a1d8861 | |||
| 7916f7d7d0 | |||
| 45dbdd6c4c |
@@ -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
|
||||
|
||||
@@ -7,3 +7,32 @@ and this project adheres to
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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.0.2...main
|
||||
[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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
```
|
||||
@@ -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-----
|
||||
"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -27,6 +31,7 @@ class MailDomainFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
name = factory.Faker("domain_name")
|
||||
slug = factory.LazyAttribute(lambda o: slugify(o.name))
|
||||
secret = factory.Faker("password")
|
||||
|
||||
@factory.post_generation
|
||||
def users(self, create, extracted, **kwargs):
|
||||
@@ -75,3 +80,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'),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
@@ -26,6 +26,7 @@ class MailDomain(BaseModel):
|
||||
default=MailDomainStatusChoices.PENDING,
|
||||
choices=MailDomainStatusChoices.choices,
|
||||
)
|
||||
secret = models.CharField(_("secret"), max_length=255, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_mail_domain"
|
||||
@@ -115,7 +116,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 +138,30 @@ 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 be created only on enabled domains, with a set secret."""
|
||||
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"
|
||||
)
|
||||
|
||||
if not self.domain.secret:
|
||||
raise exceptions.ValidationError(
|
||||
"Please configure your domain's secret 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,11 @@
|
||||
Unit tests for the mailbox API
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -75,10 +79,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 +92,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 +136,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 +150,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 +228,134 @@ 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"]
|
||||
|
||||
@@ -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,34 @@
|
||||
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
|
||||
|
||||
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 +38,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 +58,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 +81,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 +90,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 +104,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 +118,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 +132,135 @@ 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
|
||||
|
||||
|
||||
def test_models_mailboxes__no_secret():
|
||||
"""If no secret is declared on the domain, the function should raise an error."""
|
||||
domain = factories.MailDomainEnabledFactory(secret=None)
|
||||
|
||||
with pytest.raises(
|
||||
exceptions.ValidationError,
|
||||
match="Please configure your domain's secret before creating any mailbox.",
|
||||
):
|
||||
factories.MailboxFactory(domain=domain)
|
||||
|
||||
|
||||
def test_models_mailboxes__wrong_secret():
|
||||
"""If domain secret is inaccurate, the function should raise an error."""
|
||||
|
||||
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",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
body='{"detail": "Permission denied"}',
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
exceptions.PermissionDenied,
|
||||
match=f"Please check secret of the mail domain {domain.name}",
|
||||
):
|
||||
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",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""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
|
||||
|
||||
def get_headers(self, domain):
|
||||
"""Build header dict from domain object."""
|
||||
# self.secret is the encoded basic auth, to request a new token from dimail-api
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
response = requests.get(
|
||||
f"{self.API_URL}/token/",
|
||||
headers={"Authorization": f"Basic {domain.secret}"},
|
||||
timeout=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if response.json() == "{'detail': 'Permission denied'}":
|
||||
raise exceptions.PermissionDenied(
|
||||
"This secret does not allow for a new token."
|
||||
)
|
||||
|
||||
if "access_token" in response.json():
|
||||
headers["Authorization"] = f"Bearer {response.json()['access_token']}"
|
||||
logger.info("Token succesfully granted by mail-provisioning API.")
|
||||
|
||||
return headers
|
||||
|
||||
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}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = session.post(
|
||||
f"{self.API_URL}/domains/{mailbox.domain}/mailboxes/{mailbox.local_part}/",
|
||||
json=payload,
|
||||
headers=self.get_headers(mailbox.domain),
|
||||
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,
|
||||
)
|
||||
elif response.status_code == status.HTTP_403_FORBIDDEN:
|
||||
logger.error(
|
||||
"[DIMAIL] 403 Forbidden: please check the mail domain secret of %s",
|
||||
mailbox.domain.name,
|
||||
)
|
||||
raise exceptions.PermissionDenied(
|
||||
f"Please check secret of the mail domain {mailbox.domain.name}"
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Unexpected response: %s",
|
||||
response.content.decode("utf-8"),
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -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()),
|
||||
]
|
||||
|
||||
@@ -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,45 @@ 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)
|
||||
|
||||
# mailboxes 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,
|
||||
)
|
||||
|
||||
FEATURES = {
|
||||
"TEAMS": values.BooleanValue(
|
||||
default=True, environ_name="FEATURE_TEAMS", environ_prefix=None
|
||||
),
|
||||
}
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -451,6 +515,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"]
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "people"
|
||||
version = "0.1.0"
|
||||
version = "1.0.2"
|
||||
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.7",
|
||||
"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",
|
||||
"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==2.13.0",
|
||||
"url-normalize==1.4.3",
|
||||
"whitenoise==6.7.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
@@ -75,7 +76,7 @@ dev = [
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.3",
|
||||
"ruff==0.5.6",
|
||||
"ruff==0.6.2",
|
||||
"types-requests==2.32.0.20240712",
|
||||
"freezegun==1.5.1",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-desk",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.2",
|
||||
"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.52.3",
|
||||
"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.52.3",
|
||||
"@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.4",
|
||||
"@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/');
|
||||
});
|
||||
});
|
||||
|
||||
|
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 |
@@ -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'
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from './AppProvider';
|
||||
export * from './MainLayout';
|
||||
export * from './PageLayout';
|
||||
export * from './config';
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './useMailDomains';
|
||||
export * from './useMailDomain';
|
||||
export * from './useCreateMailbox';
|
||||
export * from './useMailboxes';
|
||||
export * from './useCreateMailDomain';
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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 const createMailDomain = async (name: string): 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 function useCreateMailDomain({
|
||||
onSuccess,
|
||||
}: {
|
||||
onSuccess: (data: MailDomain) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<MailDomain, APIError, string>({
|
||||
mutationFn: createMailDomain,
|
||||
onSuccess: (data) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_MAIL_DOMAIN],
|
||||
});
|
||||
onSuccess(data);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,14 +1,18 @@
|
||||
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';
|
||||
@@ -17,7 +21,11 @@ import { MailDomain, MailDomainMailbox } from '../types';
|
||||
|
||||
import { CreateMailboxForm } from './forms/CreateMailboxForm';
|
||||
|
||||
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 />
|
||||
@@ -88,18 +92,34 @@ export function MailDomainsContent({ mailDomain }: { 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,143 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Loader,
|
||||
Modal,
|
||||
ModalSize,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react';
|
||||
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Box, StyledLink, Text, TextErrors } from '@/components';
|
||||
import { useCreateMailDomain } from '@/features/mail-domains';
|
||||
|
||||
import { default as MailDomainsLogo } from '../assets/mail-domains-logo.svg';
|
||||
|
||||
const FORM_ID = 'form-add-mail-domain';
|
||||
|
||||
export const ModalCreateMailDomain = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const createMailDomainValidationSchema = 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(createMailDomainValidationSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: createMailDomain,
|
||||
isPending,
|
||||
error,
|
||||
} = useCreateMailDomain({
|
||||
onSuccess: (mailDomain) => {
|
||||
router.push(`/mail-domains/${mailDomain.slug}`);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmitCallback = () => {
|
||||
void methods.handleSubmit(({ name }, event) => {
|
||||
event?.preventDefault();
|
||||
void createMailDomain(name);
|
||||
})();
|
||||
};
|
||||
|
||||
const causes = error?.cause?.filter((cause) => {
|
||||
const isFound = cause === 'Mail domain with this name already exists.';
|
||||
|
||||
if (isFound) {
|
||||
methods.setError('name', {
|
||||
type: 'manual',
|
||||
message: t(
|
||||
'This mail domain is already used. Please, choose another one.',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return !isFound;
|
||||
});
|
||||
|
||||
if (!methods) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
leftActions={
|
||||
<StyledLink href="/mail-domains">
|
||||
<Button color="secondary" tabIndex={-1}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
</StyledLink>
|
||||
}
|
||||
hideCloseButton
|
||||
closeOnClickOutside
|
||||
closeOnEsc
|
||||
onClose={() => router.push('/mail-domains')}
|
||||
rightActions={
|
||||
<Button
|
||||
onClick={onSubmitCallback}
|
||||
disabled={!methods.watch('name') || isPending}
|
||||
>
|
||||
{t('Add the domain')}
|
||||
</Button>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<>
|
||||
<MailDomainsLogo aria-hidden="true" />
|
||||
<Text as="h3" $textAlign="center">
|
||||
{t('Add your mail domain')}
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<form action="" id={FORM_ID}>
|
||||
<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>
|
||||
{!!causes?.length ? <TextErrors causes={causes} /> : null}
|
||||
|
||||
{isPending && (
|
||||
<Box $align="center">
|
||||
<Loader />
|
||||
</Box>
|
||||
)}
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -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 your 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"
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
"fr": {
|
||||
"translation": {
|
||||
"0 group to display.": "0 groupe à afficher.",
|
||||
"0 mail domain to display.": "0 domaine de mail à afficher.",
|
||||
"Accessibility statement": "Déclaration d'accessibilité",
|
||||
"Accessibility: non-compliant": "Accessibilité : non conforme",
|
||||
"Add a member": "Ajouter un membre",
|
||||
"Add a team": "Ajouter un groupe",
|
||||
"Add members to the team": "Ajouter des membres à l'équipe",
|
||||
"Add the domain": "Ajouter le domaine",
|
||||
"Add to group": "Ajouter au groupe",
|
||||
"Add your mail domain": "Ajouter votre nom de domaine",
|
||||
"Address: National Agency for Territorial Cohesion - 20, avenue de Ségur TSA 10717 75 334 Paris Cedex 07 Paris": "Adresse : Agence Nationale de la Cohésion des Territoires - 20, avenue de Ségur TSA 10717 75 334 Paris Cedex 07",
|
||||
"Administration": "Administration",
|
||||
"All fields are mandatory.": "Tous les champs sont obligatoires.",
|
||||
@@ -29,6 +30,7 @@
|
||||
"Close the teams panel": "Fermer le panneau des groupes",
|
||||
"Compliance status": "État de conformité",
|
||||
"Confirm deletion": "Confirmer la suppression",
|
||||
"Contact our support at \"suiteterritoriale@anct.gouv.fr\"": "Contacter notre support à \"suiteterritoriale@anct.gouv.fr\"",
|
||||
"Content modal to delete the team": "Contenu modal pour supprimer le groupe",
|
||||
"Content modal to update the team": "Contenu modal pour mettre à jour le groupe",
|
||||
"Cookies placed": "Cookies déposés",
|
||||
@@ -46,12 +48,14 @@
|
||||
"Defender of Rights - Free response - 71120 75342 Paris CEDEX 07": "Défenseur des droits\nLibre réponse 71120 75342 Paris CEDEX 07",
|
||||
"Delete the team": "Supprimer le groupe",
|
||||
"Deleting the {{teamName}} team": "Suppression du groupe {{teamName}}",
|
||||
"Domain name": "Nom de domaine",
|
||||
"E-mail:": "E-mail:",
|
||||
"E.g. : jean.dupont@mail.fr": "Ex. : jean.dupont@mail.fr",
|
||||
"Email address prefix": "Préfixe de l'adresse mail",
|
||||
"Emails": "Emails",
|
||||
"Empty team icon": "Icône équipe vide",
|
||||
"Enter the new name of the selected team": "Entrez le nouveau nom du groupe sélectionné",
|
||||
"Example: saint-laurent.fr": "Exemple : saint-laurent.fr",
|
||||
"Failed to add {{name}} in the team": "Impossible d'ajouter {{name}} au groupe",
|
||||
"Failed to create the invitation for {{email}}": "Impossible de créer l'invitation pour {{email}}",
|
||||
"Find a member to add to the team": "Trouver un membre à ajouter au groupe",
|
||||
@@ -79,6 +83,7 @@
|
||||
"List members card": "Carte liste des membres",
|
||||
"Logout": "Se déconnecter",
|
||||
"Mail Domains": "Domaines de messagerie",
|
||||
"Mail domains panel": "Panel des domaines de messagerie",
|
||||
"Mailbox created!": "Boîte mail créée !",
|
||||
"Mailboxes list": "Liste des boîtes mail",
|
||||
"Marianne Logo": "Logo Marianne",
|
||||
@@ -89,6 +94,7 @@
|
||||
"My account": "Mon compte",
|
||||
"Names": "Noms",
|
||||
"New name...": "Nouveau nom...",
|
||||
"No domains exist.": "Aucun domaine existant.",
|
||||
"No mail box was created with this mail domain.": "Aucune boîte mail n'a été créée avec ce nom de domaine.",
|
||||
"Nothing exceptional, no special privileges related to a .gouv.fr.": "Rien d'exceptionnel, pas de privilèges spéciaux liés à un .gouv.fr.",
|
||||
"Open the mail domains panel": "Ouvrir le panneau des domaines de messagerie",
|
||||
@@ -127,6 +133,7 @@
|
||||
"Team name": "Nom du groupe",
|
||||
"Teams": "Équipes",
|
||||
"The National Agency for Territorial Cohesion undertakes to make its\n service accessible, in accordance with article 47 of law no. 2005-102\n of February 11, 2005.": "L'Agence Nationale de la Cohésion des Territoires s’engage à rendre son service accessible, conformément à l’article 47 de la loi n° 2005-102 du 11 février 2005.",
|
||||
"The domain name encounters an error. Please contact our support team to solve the problem:": "Le nom de domaine rencontre une erreur. Veuillez contacter notre support pour résoudre le problème :",
|
||||
"The member has been removed from the team": "Le membre a été supprimé de votre groupe",
|
||||
"The role has been updated": "Le rôle a bien été mis à jour",
|
||||
"The team has been removed.": "Le groupe a été supprimé.",
|
||||
@@ -134,7 +141,9 @@
|
||||
"The team in charge of the digital workspace \"La Suite numérique\" can be contacted directly at": "L'équipe responsable de l'espace de travail numérique \"La Suite numérique\" peut être contactée directement à l'adresse",
|
||||
"This accessibility statement applies to La Régie (Suite Territoriale)": "Cette déclaration d’accessibilité s’applique à La Régie (Suite Territoriale)",
|
||||
"This allows us to measure the number of visits and understand which pages are the most viewed.": "Cela nous permet de mesurer le nombre de visites et de comprendre quelles pages sont les plus consultées.",
|
||||
"This domain name is deactivated. No new mailboxes can be created.": "Ce nom de domaine est désactivé. Aucune nouvelle boîte mail ne peut être créée.",
|
||||
"This email prefix is already used.": "Ce préfixe d'email est déjà utilisé.",
|
||||
"This mail domain is already used. Please, choose another one.": "Ce domaine de messagerie est déjà utilisé. Veuillez en choisir un autre.",
|
||||
"This procedure is to be used in the following case: you have reported to the website \n manager an accessibility defect which prevents you from accessing content or one of the \n portal's services and you have not obtained a satisfactory response.": "Cette procédure est à utiliser dans le cas suivant : vous avez signalé au responsable du site internet un défaut d’accessibilité qui vous empêche d’accéder à un contenu ou à un des services du portail et vous n’avez pas obtenu de réponse satisfaisante.",
|
||||
"This site does not display a cookie consent banner, why?": "Ce site n'affiche pas de bannière de consentement des cookies, pourquoi?",
|
||||
"This site places a small text file (a \"cookie\") on your computer when you visit it.": "Ce site place un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le visitez.",
|
||||
@@ -154,6 +163,11 @@
|
||||
"You cannot remove other owner.": "Vous ne pouvez pas supprimer un autre propriétaire.",
|
||||
"You cannot update the role of other owner.": "Vous ne pouvez pas mettre à jour les rôles d'autre propriétaire.",
|
||||
"You must have minimum 1 character": "Vous devez entrer au moins 1 caractère",
|
||||
"Your domain name is being validated. You will not be able to create mailboxes until your domain name has been validated by our team.": "Votre nom de domaine est en cours de validation. Vous ne pourrez créer de boîtes mail que lorsque votre nom de domaine sera validé par notre équipe.",
|
||||
"[disabled]": "[désactivé]",
|
||||
"[enabled]": "[actif]",
|
||||
"[failed]": "[erroné]",
|
||||
"[pending]": "[en attente]",
|
||||
"accessibility-contact-defenseurdesdroits": "Contacter le délégué du<1>Défenseur des droits dans votre région</1>",
|
||||
"accessibility-form-defenseurdesdroits": "Écrire un message au<1>Défenseur des droits</1>",
|
||||
"mail domains list loading": "chargement de la liste des domaines de messagerie",
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { useRouter as useNavigate } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { TeamLayout } from '@/features/teams/team-management';
|
||||
import { useConfigStore } from '@/core/config';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
import Teams from './teams/';
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
return <Teams />;
|
||||
};
|
||||
const { config } = useConfigStore();
|
||||
const router = useNavigate();
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <TeamLayout>{page}</TeamLayout>;
|
||||
useEffect(() => {
|
||||
router.push(config?.FEATURES.TEAMS ? '/teams/' : '/mail-domains/');
|
||||
}, [config?.FEATURES.TEAMS, router]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { MailDomainsLayout } from '@/features/mail-domains';
|
||||
import { ModalCreateMailDomain } from '@/features/mail-domains/components/ModalAddMailDomain';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
return (
|
||||
<Box $padding="large" $height="inherit">
|
||||
<ModalCreateMailDomain />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MailDomainsLayout>{page}</MailDomainsLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,10 +1,29 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { MailDomainsLayout } from '@/features/mail-domains';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
width: fit-content;
|
||||
`;
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
return null;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Box $align="center" $justify="center" $height="inherit">
|
||||
<StyledButton onClick={() => void router.push('/mail-domains/add')}>
|
||||
{t('Add your mail domain')}
|
||||
</StyledButton>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useRouter as useNavigate } from 'next/navigation';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Box, StyledLink } from '@/components';
|
||||
import { Box } from '@/components';
|
||||
import { TeamLayout } from '@/features/teams/team-management';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
@@ -13,12 +14,13 @@ const StyledButton = styled(Button)`
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useNavigate();
|
||||
|
||||
return (
|
||||
<Box $align="center" $justify="center" $height="inherit">
|
||||
<StyledLink href="/teams/create">
|
||||
<StyledButton tabIndex={-1}>{t('Create a new team')}</StyledButton>
|
||||
</StyledLink>
|
||||
<StyledButton onClick={() => void router.push('/teams/create')}>
|
||||
{t('Create a new team')}
|
||||
</StyledButton>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,7 +24,6 @@ const spacings = {
|
||||
};
|
||||
|
||||
type SpacingsKey = keyof typeof spacings;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export type Spacings = SpacingsKey | (string & {});
|
||||
|
||||
export const spacingValue = (value?: Spacings) =>
|
||||
|
||||
@@ -24,6 +24,6 @@ test.describe('404', () => {
|
||||
page,
|
||||
}) => {
|
||||
await page.getByText('Back to home page').click();
|
||||
await expect(page).toHaveURL('/');
|
||||
await expect(page).toHaveURL('/teams/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ export const createTeam = async (
|
||||
const randomTeams = randomName(teamName, browserName, length);
|
||||
|
||||
for (let i = 0; i < randomTeams.length; i++) {
|
||||
await panel.getByRole('button', { name: 'Add a team' }).click();
|
||||
await panel.getByRole('link', { name: 'Add a team' }).click();
|
||||
await page.getByText('Team name').fill(randomTeams[i]);
|
||||
await expect(buttonCreate).toBeEnabled();
|
||||
await buttonCreate.click();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { keyCloakSignIn } from './common';
|
||||
|
||||
test.describe('Config', () => {
|
||||
test.beforeEach(async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, browserName);
|
||||
});
|
||||
|
||||
test('it checks the config api is called', async ({ page }) => {
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/config/') && response.status() === 200,
|
||||
);
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
expect(await response.json()).toStrictEqual({
|
||||
LANGUAGES: [
|
||||
['en-us', 'English'],
|
||||
['fr-fr', 'French'],
|
||||
],
|
||||
FEATURES: { TEAMS: true },
|
||||
});
|
||||
});
|
||||
|
||||
test('it checks that the config can deactivate the feature "teams"', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route('**/api/v1.0/config/', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
LANGUAGES: [
|
||||
['en-us', 'English'],
|
||||
['fr-fr', 'French'],
|
||||
],
|
||||
FEATURES: { TEAMS: false },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await expect(page.locator('menu')).toBeHidden();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Create a new team',
|
||||
}),
|
||||
).toBeHidden();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Add your mail domain',
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,15 @@ const mailDomainsFixtures: MailDomain[] = [
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'domainfr',
|
||||
status: 'enabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mails.fr',
|
||||
@@ -22,6 +31,15 @@ const mailDomainsFixtures: MailDomain[] = [
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'mailsfr',
|
||||
status: 'enabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'versailles.net',
|
||||
@@ -29,6 +47,15 @@ const mailDomainsFixtures: MailDomain[] = [
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'versaillesnet',
|
||||
status: 'enabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'paris.fr',
|
||||
@@ -36,6 +63,15 @@ const mailDomainsFixtures: MailDomain[] = [
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'parisfr',
|
||||
status: 'enabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -63,7 +99,7 @@ const interceptCommonApiRequests = (page: Page) => {
|
||||
});
|
||||
});
|
||||
|
||||
void page.route('**/api/v1.0/mail-domains/domainfr', (route) => {
|
||||
void page.route('**/api/v1.0/mail-domains/domainfr/', (route) => {
|
||||
void route.fulfill({
|
||||
json: mailDomainDomainFrFixture,
|
||||
});
|
||||
@@ -99,8 +135,7 @@ test.describe('Mail domain create mailbox', () => {
|
||||
await keyCloakSignIn(page, browserName);
|
||||
});
|
||||
|
||||
// user should have administrator or owner role on this domain to be able perform this action
|
||||
test('checks user can create a mailbox for a mail domain', async ({
|
||||
test('checks user can create a mailbox when he has post ability', async ({
|
||||
page,
|
||||
}) => {
|
||||
const newMailbox = {
|
||||
@@ -224,6 +259,62 @@ test.describe('Mail domain create mailbox', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('checks user is not allowed to create a mailbox when he is missing post ability', async ({
|
||||
page,
|
||||
}) => {
|
||||
const localMailDomainsFixtures = [...mailDomainsFixtures];
|
||||
localMailDomainsFixtures[0].abilities.post = false;
|
||||
const localMailDomainDomainFr = localMailDomainsFixtures[0];
|
||||
const localMailboxFixtures = { ...mailboxesFixtures };
|
||||
|
||||
const interceptRequests = (page: Page) => {
|
||||
void page.route('**/api/v1.0/mail-domains/?page=*', (route) => {
|
||||
void route.fulfill({
|
||||
json: {
|
||||
count: localMailDomainsFixtures.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: localMailDomainsFixtures,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
void page.route('**/api/v1.0/mail-domains/domainfr/', (route) => {
|
||||
void route.fulfill({
|
||||
json: localMailDomainDomainFr,
|
||||
});
|
||||
});
|
||||
|
||||
void page.route(
|
||||
'**/api/v1.0/mail-domains/domainfr/mailboxes/?page=1**',
|
||||
(route) => {
|
||||
void route.fulfill({
|
||||
json: {
|
||||
count: localMailboxFixtures.domainFr.page1.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: localMailboxFixtures.domainFr.page1,
|
||||
},
|
||||
});
|
||||
},
|
||||
{ times: 1 },
|
||||
);
|
||||
};
|
||||
|
||||
void interceptRequests(page);
|
||||
|
||||
await page
|
||||
.locator('menu')
|
||||
.first()
|
||||
.getByLabel(`Mail Domains button`)
|
||||
.click();
|
||||
await page.getByRole('listbox').first().getByText('domain.fr').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Create a mailbox' }),
|
||||
).not.toBeInViewport();
|
||||
});
|
||||
|
||||
test('checks client invalidation messages are displayed and no mailbox creation request is sent when fields are not properly filled', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -5,42 +5,113 @@ import { keyCloakSignIn } from './common';
|
||||
|
||||
const currentDateIso = new Date().toISOString();
|
||||
|
||||
const mailDomainsFixtures: MailDomain[] = [
|
||||
{
|
||||
name: 'domain.fr',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43f',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'domainfr',
|
||||
},
|
||||
{
|
||||
name: 'mails.fr',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43e',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'mailsfr',
|
||||
},
|
||||
{
|
||||
name: 'versailles.net',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43g',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'versaillesnet',
|
||||
},
|
||||
{
|
||||
name: 'paris.fr',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43h',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'parisfr',
|
||||
},
|
||||
];
|
||||
const interceptCommonApiCalls = async (
|
||||
page: Page,
|
||||
arrayMailDomains: MailDomain[],
|
||||
) => {
|
||||
const singleMailDomain = arrayMailDomains[0];
|
||||
await page.route('**/api/v1.0/mail-domains/?page=*', async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count: arrayMailDomains.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: arrayMailDomains,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const mailDomainDomainFrFixture = mailDomainsFixtures[0];
|
||||
await page.route('**/api/v1.0/mail-domains/domainfr/', async (route) => {
|
||||
await route.fulfill({
|
||||
json: singleMailDomain,
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(
|
||||
'**/api/v1.0/mail-domains/domainfr/mailboxes/?page=1',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count: 0,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: [],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
const clickOnMailDomainsNavButton = async (page: Page): Promise<void> =>
|
||||
await page.locator('menu').first().getByLabel(`Mail Domains button`).click();
|
||||
|
||||
const assertMailDomainUpperElementsAreVisible = async (page: Page) => {
|
||||
await expect(page).toHaveURL(/mail-domains\//);
|
||||
|
||||
await page.getByRole('listbox').first().getByText('domain.fr').click();
|
||||
await expect(page).toHaveURL(/mail-domains\/domainfr\//);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'domain.fr' })).toBeVisible();
|
||||
};
|
||||
|
||||
const assertFilledMailboxesTableElementsAreVisible = async (
|
||||
page: Page,
|
||||
domainFr: object & { name: string },
|
||||
multiLevelArrayMailboxes: object & Array<{ local_part: string }[]>,
|
||||
) => {
|
||||
await expect(page).toHaveURL(/mail-domains\//);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Names/ }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Emails/ }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await Promise.all(
|
||||
multiLevelArrayMailboxes[0].map((mailbox) =>
|
||||
expect(
|
||||
page.getByText(`${mailbox.local_part}@${domainFr.name}`),
|
||||
).toBeVisible(),
|
||||
),
|
||||
);
|
||||
|
||||
const table = page.locator('table');
|
||||
await expect(table).toBeVisible();
|
||||
|
||||
const tdNames = await table.getByText('John Doe').all();
|
||||
expect(tdNames.length).toEqual(20);
|
||||
|
||||
await expect(
|
||||
page.locator('.c__pagination__list').getByRole('button', { name: '1' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.locator('.c__pagination__list').getByText('navigate_next'),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.c__pagination__list')
|
||||
.getByRole('button', { name: '2' })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.locator('.c__pagination__list').getByText('navigate_next'),
|
||||
).toBeHidden();
|
||||
|
||||
await expect(
|
||||
page.locator('.c__pagination__list').getByText('navigate_before'),
|
||||
).toBeVisible();
|
||||
|
||||
await Promise.all(
|
||||
multiLevelArrayMailboxes[1].map((mailbox) =>
|
||||
expect(
|
||||
page.getByText(`${mailbox.local_part}@${domainFr.name}`),
|
||||
).toBeVisible(),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
test.describe('Mail domain', () => {
|
||||
test.beforeEach(async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
@@ -71,181 +142,645 @@ test.describe('Mail domain', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('checks all the elements are visible when domain exist but contains no mailboxes', async ({
|
||||
page,
|
||||
}) => {
|
||||
const interceptApiCalls = async () => {
|
||||
await page.route(
|
||||
'**/api/v1.0/mail-domains/domainfr/mailboxes/?page=1',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count: 0,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: [],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.route('**/api/v1.0/mail-domains/domainfr**', async (route) => {
|
||||
await route.fulfill({
|
||||
json: mailDomainDomainFrFixture,
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1.0/mail-domains/?page=*', async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count: mailDomainsFixtures.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: mailDomainsFixtures,
|
||||
test.describe('user is administrator or owner', () => {
|
||||
test.describe('mail domain is enabled', () => {
|
||||
const mailDomainsFixtures: MailDomain[] = [
|
||||
{
|
||||
name: 'domain.fr',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43f',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'domainfr',
|
||||
status: 'enabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
name: 'mails.fr',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43e',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'mailsfr',
|
||||
status: 'enabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'versailles.net',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43g',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'versaillesnet',
|
||||
status: 'enabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'paris.fr',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43h',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'parisfr',
|
||||
status: 'enabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test('checks all the elements are visible when domain exist but contains no mailboxes', async ({
|
||||
page,
|
||||
}) => {
|
||||
await interceptCommonApiCalls(page, mailDomainsFixtures);
|
||||
|
||||
await clickOnMailDomainsNavButton(page);
|
||||
|
||||
await assertMailDomainUpperElementsAreVisible(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Create a mailbox' }),
|
||||
).toBeEnabled();
|
||||
|
||||
await expect(
|
||||
page.getByText('No mail box was created with this mail domain.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
};
|
||||
|
||||
await interceptApiCalls();
|
||||
test('checks all the elements are visible when domain exists and contains 2 pages of mailboxes', async ({
|
||||
page,
|
||||
}) => {
|
||||
const mailboxesFixtures = {
|
||||
domainFr: {
|
||||
page1: Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `456ac6ca-0402-4615-8005-69bc1efde${i}f`,
|
||||
first_name: 'john',
|
||||
last_name: 'doe',
|
||||
local_part: `local_part-${i}`,
|
||||
secondary_email: `secondary_email-${i}`,
|
||||
})),
|
||||
page2: Array.from({ length: 2 }, (_, i) => ({
|
||||
id: `456ac6ca-0402-4615-8005-69bc1efde${i}d`,
|
||||
first_name: 'john',
|
||||
last_name: 'doe',
|
||||
local_part: `local_part-${i}`,
|
||||
secondary_email: `secondary_email-${i}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
const interceptApiCalls = async () => {
|
||||
await page.route(
|
||||
'**/api/v1.0/mail-domains/?page=*',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count: mailDomainsFixtures.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: mailDomainsFixtures,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1.0/mail-domains/domainfr/',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: mailDomainsFixtures[0],
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1.0/mail-domains/domainfr/mailboxes/?page=1**',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count:
|
||||
mailboxesFixtures.domainFr.page1.length +
|
||||
mailboxesFixtures.domainFr.page2.length,
|
||||
next: 'http://localhost:8071/api/v1.0/mail-domains/domainfr/mailboxes/?page=2',
|
||||
previous: null,
|
||||
results: mailboxesFixtures.domainFr.page1,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1.0/mail-domains/domainfr/mailboxes/?page=2**',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count:
|
||||
mailboxesFixtures.domainFr.page1.length +
|
||||
mailboxesFixtures.domainFr.page2.length,
|
||||
next: null,
|
||||
previous:
|
||||
'http://localhost:8071/api/v1.0/mail-domains/domainfr/mailboxes/?page=1',
|
||||
results: mailboxesFixtures.domainFr.page2,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
await clickOnMailDomainsNavButton(page);
|
||||
await interceptApiCalls();
|
||||
|
||||
await expect(page).toHaveURL(/mail-domains\//);
|
||||
await clickOnMailDomainsNavButton(page);
|
||||
|
||||
await page.getByRole('listbox').first().getByText('domain.fr').click();
|
||||
await expect(page).toHaveURL(/mail-domains\/domainfr\//);
|
||||
await assertMailDomainUpperElementsAreVisible(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /domain\.fr/ }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Create a mailbox' }),
|
||||
).toBeEnabled();
|
||||
|
||||
await expect(
|
||||
page.getByText('No mail box was created with this mail domain.'),
|
||||
).toBeVisible();
|
||||
await assertFilledMailboxesTableElementsAreVisible(
|
||||
page,
|
||||
mailDomainsFixtures[0],
|
||||
[mailboxesFixtures.domainFr.page1, mailboxesFixtures.domainFr.page2],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('mail domain creation is pending', () => {
|
||||
const mailDomainsFixtures: MailDomain[] = [
|
||||
{
|
||||
name: 'domain.fr',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43f',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'domainfr',
|
||||
status: 'pending',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test('checks expected elements are visible', async ({ page }) => {
|
||||
await interceptCommonApiCalls(page, mailDomainsFixtures);
|
||||
|
||||
await clickOnMailDomainsNavButton(page);
|
||||
|
||||
await expect(page).toHaveURL(/mail-domains\//);
|
||||
|
||||
await page.getByRole('listbox').first().getByText('domain.fr').click();
|
||||
await expect(page).toHaveURL(/mail-domains\/domainfr\//);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'domain.fr' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Your domain name is being validated. ' +
|
||||
'You will not be able to create mailboxes until your domain name has been validated by our team.',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Create a mailbox' }),
|
||||
).toBeDisabled();
|
||||
|
||||
await expect(
|
||||
page.getByText('No mail box was created with this mail domain.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('mail domain is disabled', () => {
|
||||
const mailDomainsFixtures: MailDomain[] = [
|
||||
{
|
||||
name: 'domain.fr',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43f',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'domainfr',
|
||||
status: 'disabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test('checks expected elements are visible', async ({ page }) => {
|
||||
await interceptCommonApiCalls(page, mailDomainsFixtures);
|
||||
|
||||
await clickOnMailDomainsNavButton(page);
|
||||
|
||||
await assertMailDomainUpperElementsAreVisible(page);
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'This domain name is deactivated. No new mailboxes can be created.',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Create a mailbox' }),
|
||||
).toBeDisabled();
|
||||
|
||||
await expect(
|
||||
page.getByText('No mail box was created with this mail domain.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('mail domain creation has failed', () => {
|
||||
const mailDomainsFixtures: MailDomain[] = [
|
||||
{
|
||||
name: 'domain.fr',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43f',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'domainfr',
|
||||
status: 'failed',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test('checks expected elements are visible', async ({ page }) => {
|
||||
await interceptCommonApiCalls(page, mailDomainsFixtures);
|
||||
|
||||
await clickOnMailDomainsNavButton(page);
|
||||
|
||||
await assertMailDomainUpperElementsAreVisible(page);
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'The domain name encounters an error. Please contact our support team to solve the problem:',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'suiteterritoriale@anct.gouv.fr' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Create a mailbox' }),
|
||||
).toBeDisabled();
|
||||
|
||||
await expect(
|
||||
page.getByText('No mail box was created with this mail domain.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('checks all the elements are visible when domain exists and contains 2 pages of mailboxes', async ({
|
||||
page,
|
||||
}) => {
|
||||
const mailboxesFixtures = {
|
||||
domainFr: {
|
||||
page1: Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `456ac6ca-0402-4615-8005-69bc1efde${i}f`,
|
||||
local_part: `local_part-${i}`,
|
||||
secondary_email: `secondary_email-${i}`,
|
||||
})),
|
||||
page2: Array.from({ length: 2 }, (_, i) => ({
|
||||
id: `456ac6ca-0402-4615-8005-69bc1efde${i}d`,
|
||||
local_part: `local_part-${i}`,
|
||||
secondary_email: `secondary_email-${i}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
const interceptApiCalls = async () => {
|
||||
await page.route('**/api/v1.0/mail-domains/?page=*', async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count: mailDomainsFixtures.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: mailDomainsFixtures,
|
||||
test.describe('user is member', () => {
|
||||
test.describe('mail domain is enabled', () => {
|
||||
const mailDomainsFixtures: MailDomain[] = [
|
||||
{
|
||||
name: 'domain.fr',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43f',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'domainfr',
|
||||
status: 'enabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: false,
|
||||
put: false,
|
||||
post: false,
|
||||
delete: false,
|
||||
manage_accesses: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1.0/mail-domains/domainfr', async (route) => {
|
||||
await route.fulfill({
|
||||
json: mailDomainDomainFrFixture,
|
||||
});
|
||||
});
|
||||
await page.route(
|
||||
'**/api/v1.0/mail-domains/domainfr/mailboxes/?page=1**',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count:
|
||||
mailboxesFixtures.domainFr.page1.length +
|
||||
mailboxesFixtures.domainFr.page2.length,
|
||||
next: 'http://localhost:8071/api/v1.0/mail-domains/domainfr/mailboxes/?page=2',
|
||||
previous: null,
|
||||
results: mailboxesFixtures.domainFr.page1,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1.0/mail-domains/domainfr/mailboxes/?page=2**',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count:
|
||||
mailboxesFixtures.domainFr.page1.length +
|
||||
mailboxesFixtures.domainFr.page2.length,
|
||||
next: null,
|
||||
previous:
|
||||
'http://localhost:8071/api/v1.0/mail-domains/domainfr/mailboxes/?page=1',
|
||||
results: mailboxesFixtures.domainFr.page2,
|
||||
},
|
||||
});
|
||||
{
|
||||
name: 'mails.fr',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43e',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'mailsfr',
|
||||
status: 'enabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: false,
|
||||
put: false,
|
||||
post: false,
|
||||
delete: false,
|
||||
manage_accesses: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
{
|
||||
name: 'versailles.net',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43g',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'versaillesnet',
|
||||
status: 'enabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: false,
|
||||
put: false,
|
||||
post: false,
|
||||
delete: false,
|
||||
manage_accesses: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'paris.fr',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43h',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'parisfr',
|
||||
status: 'enabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: false,
|
||||
put: false,
|
||||
post: false,
|
||||
delete: false,
|
||||
manage_accesses: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await interceptApiCalls();
|
||||
test('checks all the elements are visible when domain exist but contains no mailboxes', async ({
|
||||
page,
|
||||
}) => {
|
||||
await interceptCommonApiCalls(page, mailDomainsFixtures);
|
||||
|
||||
await clickOnMailDomainsNavButton(page);
|
||||
await clickOnMailDomainsNavButton(page);
|
||||
|
||||
await expect(page).toHaveURL(/mail-domains\//);
|
||||
await assertMailDomainUpperElementsAreVisible(page);
|
||||
|
||||
await page.getByRole('listbox').first().getByText('domain.fr').click();
|
||||
await expect(page).toHaveURL(/mail-domains\/domainfr\//);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Create a mailbox' }),
|
||||
).not.toBeInViewport();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'domain.fr' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('No mail box was created with this mail domain.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Emails/ }).first(),
|
||||
).toBeVisible();
|
||||
test('checks all the elements are visible when domain exists and contains 2 pages of mailboxes', async ({
|
||||
page,
|
||||
}) => {
|
||||
const mailboxesFixtures = {
|
||||
domainFr: {
|
||||
page1: Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `456ac6ca-0402-4615-8005-69bc1efde${i}f`,
|
||||
first_name: 'john',
|
||||
last_name: 'doe',
|
||||
local_part: `local_part-${i}`,
|
||||
secondary_email: `secondary_email-${i}`,
|
||||
})),
|
||||
page2: Array.from({ length: 2 }, (_, i) => ({
|
||||
id: `456ac6ca-0402-4615-8005-69bc1efde${i}d`,
|
||||
first_name: 'john',
|
||||
last_name: 'doe',
|
||||
local_part: `local_part-${i}`,
|
||||
secondary_email: `secondary_email-${i}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
const interceptApiCalls = async () => {
|
||||
await page.route(
|
||||
'**/api/v1.0/mail-domains/?page=*',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count: mailDomainsFixtures.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: mailDomainsFixtures,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1.0/mail-domains/domainfr/',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: mailDomainsFixtures[0],
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1.0/mail-domains/domainfr/mailboxes/?page=1**',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count:
|
||||
mailboxesFixtures.domainFr.page1.length +
|
||||
mailboxesFixtures.domainFr.page2.length,
|
||||
next: 'http://localhost:8071/api/v1.0/mail-domains/domainfr/mailboxes/?page=2',
|
||||
previous: null,
|
||||
results: mailboxesFixtures.domainFr.page1,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1.0/mail-domains/domainfr/mailboxes/?page=2**',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count:
|
||||
mailboxesFixtures.domainFr.page1.length +
|
||||
mailboxesFixtures.domainFr.page2.length,
|
||||
next: null,
|
||||
previous:
|
||||
'http://localhost:8071/api/v1.0/mail-domains/domainfr/mailboxes/?page=1',
|
||||
results: mailboxesFixtures.domainFr.page2,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
mailboxesFixtures.domainFr.page1.map((mailbox) =>
|
||||
expect(
|
||||
await interceptApiCalls();
|
||||
|
||||
await clickOnMailDomainsNavButton(page);
|
||||
|
||||
await assertMailDomainUpperElementsAreVisible(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Create a mailbox' }),
|
||||
).not.toBeInViewport();
|
||||
|
||||
await assertFilledMailboxesTableElementsAreVisible(
|
||||
page,
|
||||
mailDomainsFixtures[0],
|
||||
[mailboxesFixtures.domainFr.page1, mailboxesFixtures.domainFr.page2],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('mail domain creation is pending', () => {
|
||||
const mailDomainsFixtures: MailDomain[] = [
|
||||
{
|
||||
name: 'domain.fr',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43f',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'domainfr',
|
||||
status: 'pending',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: false,
|
||||
put: false,
|
||||
post: false,
|
||||
delete: false,
|
||||
manage_accesses: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test('checks expected elements are visible', async ({ page }) => {
|
||||
await interceptCommonApiCalls(page, mailDomainsFixtures);
|
||||
|
||||
await clickOnMailDomainsNavButton(page);
|
||||
|
||||
await expect(page).toHaveURL(/mail-domains\//);
|
||||
|
||||
await page.getByRole('listbox').first().getByText('domain.fr').click();
|
||||
await expect(page).toHaveURL(/mail-domains\/domainfr\//);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'domain.fr' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
`${mailbox.local_part}@${mailDomainDomainFrFixture.name}`,
|
||||
'Your domain name is being validated. ' +
|
||||
'You will not be able to create mailboxes until your domain name has been validated by our team.',
|
||||
),
|
||||
).toBeVisible(),
|
||||
),
|
||||
);
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.locator('.c__pagination__list').getByRole('button', { name: '1' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Create a mailbox' }),
|
||||
).not.toBeInViewport();
|
||||
|
||||
await expect(
|
||||
page.locator('.c__pagination__list').getByText('navigate_next'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('No mail box was created with this mail domain.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
await page
|
||||
.locator('.c__pagination__list')
|
||||
.getByRole('button', { name: '2' })
|
||||
.click();
|
||||
test.describe('mail domain is disabled', () => {
|
||||
const mailDomainsFixtures: MailDomain[] = [
|
||||
{
|
||||
name: 'domain.fr',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43f',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'domainfr',
|
||||
status: 'disabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: false,
|
||||
put: false,
|
||||
post: false,
|
||||
delete: false,
|
||||
manage_accesses: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await expect(
|
||||
page.locator('.c__pagination__list').getByText('navigate_next'),
|
||||
).toBeHidden();
|
||||
test('checks expected elements are visible', async ({ page }) => {
|
||||
await interceptCommonApiCalls(page, mailDomainsFixtures);
|
||||
|
||||
await expect(
|
||||
page.locator('.c__pagination__list').getByText('navigate_before'),
|
||||
).toBeVisible();
|
||||
await clickOnMailDomainsNavButton(page);
|
||||
|
||||
await Promise.all(
|
||||
mailboxesFixtures.domainFr.page2.map((mailbox) =>
|
||||
expect(
|
||||
await assertMailDomainUpperElementsAreVisible(page);
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
`${mailbox.local_part}@${mailDomainDomainFrFixture.name}`,
|
||||
'This domain name is deactivated. No new mailboxes can be created.',
|
||||
),
|
||||
).toBeVisible(),
|
||||
),
|
||||
);
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Create a mailbox' }),
|
||||
).not.toBeInViewport();
|
||||
|
||||
await expect(
|
||||
page.getByText('No mail box was created with this mail domain.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('mail domain creation has failed', () => {
|
||||
const mailDomainsFixtures: MailDomain[] = [
|
||||
{
|
||||
name: 'domain.fr',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43f',
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'domainfr',
|
||||
status: 'failed',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: false,
|
||||
put: false,
|
||||
post: false,
|
||||
delete: false,
|
||||
manage_accesses: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test('checks expected elements are visible', async ({ page }) => {
|
||||
await interceptCommonApiCalls(page, mailDomainsFixtures);
|
||||
|
||||
await clickOnMailDomainsNavButton(page);
|
||||
|
||||
await assertMailDomainUpperElementsAreVisible(page);
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'The domain name encounters an error. Please contact our support team to solve the problem:',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Create a mailbox' }),
|
||||
).not.toBeInViewport();
|
||||
|
||||
await expect(
|
||||
page.getByText('No mail box was created with this mail domain.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { keyCloakSignIn, randomName } from './common';
|
||||
|
||||
test.beforeEach(async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, browserName);
|
||||
});
|
||||
|
||||
test.describe('Add Mail Domains', () => {
|
||||
test('checks all the elements are visible', async ({ page }) => {
|
||||
await page.goto('/mail-domains');
|
||||
|
||||
const buttonFromHomePage = page.getByRole('button', {
|
||||
name: 'Add your mail domain',
|
||||
});
|
||||
|
||||
await expect(buttonFromHomePage).toBeVisible();
|
||||
await buttonFromHomePage.click();
|
||||
|
||||
await expect(buttonFromHomePage).toBeHidden();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: 'Add your mail domain',
|
||||
level: 3,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
const form = page.locator('form');
|
||||
|
||||
await expect(form.getByLabel('Domain name')).toBeVisible();
|
||||
|
||||
await expect(page.getByText('Example: saint-laurent.fr')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Add the domain',
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Cancel',
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks the cancel button interaction', async ({ page }) => {
|
||||
await page.goto('/mail-domains');
|
||||
|
||||
const buttonFromHomePage = page.getByRole('button', {
|
||||
name: 'Add your mail domain',
|
||||
});
|
||||
await buttonFromHomePage.click();
|
||||
|
||||
await expect(buttonFromHomePage).toBeHidden();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Cancel',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(buttonFromHomePage).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks form invalid status', async ({ page }) => {
|
||||
await page.goto('/mail-domains');
|
||||
|
||||
const buttonFromHomePage = page.getByRole('button', {
|
||||
name: 'Add your mail domain',
|
||||
});
|
||||
await buttonFromHomePage.click();
|
||||
|
||||
const form = page.locator('form');
|
||||
|
||||
const inputName = form.getByLabel('Domain name');
|
||||
const buttonSubmit = page.getByRole('button', {
|
||||
name: 'Add the domain',
|
||||
});
|
||||
|
||||
await expect(inputName).toBeVisible();
|
||||
await expect(page.getByText('Example: saint-laurent.fr')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Cancel',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
|
||||
await expect(buttonSubmit).toBeDisabled();
|
||||
|
||||
await inputName.fill('s');
|
||||
await expect(page.getByText('Example: saint-laurent.fr')).toBeVisible();
|
||||
|
||||
await inputName.clear();
|
||||
|
||||
await expect(page.getByText('Example: saint-laurent.fr')).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks the routing on new mail domain added', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const mailDomainName = randomName('versailles.fr', browserName, 1)[0];
|
||||
const mailDomainSlug = mailDomainName.replace('.', '');
|
||||
|
||||
await page.goto('/mail-domains');
|
||||
|
||||
const panel = page.getByLabel('Mail domains panel').first();
|
||||
|
||||
await panel.getByRole('link', { name: 'Add your mail domain' }).click();
|
||||
|
||||
const form = page.locator('form');
|
||||
|
||||
await form.getByLabel('Domain name').fill(mailDomainName);
|
||||
await page.getByRole('button', { name: 'Add the domain' }).click();
|
||||
|
||||
await expect(page).toHaveURL(`/mail-domains\/${mailDomainSlug}/`);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: mailDomainName,
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks error when duplicate mail domain', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await page.goto('/mail-domains');
|
||||
|
||||
const panel = page.getByLabel('Mail domains panel').first();
|
||||
const additionLink = panel.getByRole('link', {
|
||||
name: 'Add your mail domain',
|
||||
});
|
||||
const form = page.locator('form');
|
||||
const inputName = form.getByLabel('Domain name');
|
||||
const submitButton = page.getByRole('button', {
|
||||
name: 'Add the domain',
|
||||
});
|
||||
|
||||
const mailDomainName = randomName('duplicate.fr', browserName, 1)[0];
|
||||
const mailDomainSlug = mailDomainName.replace('.', '');
|
||||
|
||||
await additionLink.click();
|
||||
await inputName.fill(mailDomainName);
|
||||
await submitButton.click();
|
||||
|
||||
await expect(page).toHaveURL(`/mail-domains\/${mailDomainSlug}\/`);
|
||||
|
||||
await additionLink.click();
|
||||
|
||||
await inputName.fill(mailDomainName);
|
||||
await submitButton.click();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'This mail domain is already used. Please, choose another one.',
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks 404 on mail-domains/[slug] page', async ({ page }) => {
|
||||
await page.goto('/mail-domains/unknown-domain');
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
|
||||
),
|
||||
).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,15 @@ const mailDomainsFixtures: MailDomain[] = [
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'domainfr',
|
||||
status: 'pending',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mails.fr',
|
||||
@@ -18,6 +27,15 @@ const mailDomainsFixtures: MailDomain[] = [
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'mailsfr',
|
||||
status: 'enabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'versailles.net',
|
||||
@@ -25,6 +43,15 @@ const mailDomainsFixtures: MailDomain[] = [
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'versaillesnet',
|
||||
status: 'disabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'paris.fr',
|
||||
@@ -32,6 +59,15 @@ const mailDomainsFixtures: MailDomain[] = [
|
||||
created_at: currentDateIso,
|
||||
updated_at: currentDateIso,
|
||||
slug: 'parisfr',
|
||||
status: 'failed',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -67,7 +103,7 @@ test.describe('Mail domains', () => {
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const panel = page.getByLabel('mail domains panel').first();
|
||||
const panel = page.getByLabel('Mail domains panel').first();
|
||||
|
||||
await panel
|
||||
.getByRole('button', {
|
||||
@@ -107,9 +143,9 @@ test.describe('Mail domains', () => {
|
||||
.click();
|
||||
await expect(page).toHaveURL(/mail-domains\//);
|
||||
await expect(
|
||||
page.getByLabel('mail domains panel', { exact: true }),
|
||||
page.getByLabel('Mail domains panel', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('0 mail domain to display.')).toBeVisible();
|
||||
await expect(page.getByText('No domains exist.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('when 4 mail domains exist', async ({ page }) => {
|
||||
@@ -131,13 +167,17 @@ test.describe('Mail domains', () => {
|
||||
.click();
|
||||
await expect(page).toHaveURL(/mail-domains\//);
|
||||
await expect(
|
||||
page.getByLabel('mail domains panel', { exact: true }),
|
||||
page.getByLabel('Mail domains panel', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('0 mail domain to display.')).toHaveCount(0);
|
||||
await expect(page.getByText('domain.fr')).toBeVisible();
|
||||
await expect(page.getByText('mails.fr')).toBeVisible();
|
||||
await expect(page.getByText('versailles.net')).toBeVisible();
|
||||
await expect(page.getByText('paris.fr')).toBeVisible();
|
||||
await expect(page.getByText('No domains exist.')).toHaveCount(0);
|
||||
|
||||
await Promise.all(
|
||||
mailDomainsFixtures.map(async ({ name, status }) => {
|
||||
const linkName = page.getByRole('link', { name });
|
||||
await expect(linkName).toBeVisible();
|
||||
await expect(linkName.getByText(`[${status}]`)).toBeVisible();
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ test.describe('Menu', () => {
|
||||
{
|
||||
name: 'Teams',
|
||||
isDefault: true,
|
||||
expectedUrl: '',
|
||||
expectedUrl: '/teams/',
|
||||
expectedText: 'Create a new team',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -63,7 +63,7 @@ test.describe('Teams Create', () => {
|
||||
}) => {
|
||||
const panel = page.getByLabel('Teams panel').first();
|
||||
|
||||
await panel.getByRole('button', { name: 'Add a team' }).click();
|
||||
await panel.getByRole('link', { name: 'Add a team' }).click();
|
||||
|
||||
const teamName = `My routing team ${browserName}-${Math.floor(Math.random() * 1000)}`;
|
||||
await page.getByText('Team name').fill(teamName);
|
||||
@@ -72,7 +72,7 @@ test.describe('Teams Create', () => {
|
||||
const elTeam = page.getByRole('heading', { name: teamName });
|
||||
await expect(elTeam).toBeVisible();
|
||||
|
||||
await panel.getByRole('button', { name: 'Add a team' }).click();
|
||||
await panel.getByRole('link', { name: 'Add a team' }).click();
|
||||
await expect(elTeam).toBeHidden();
|
||||
|
||||
await panel.locator('li').getByText(teamName).click();
|
||||
@@ -96,13 +96,13 @@ test.describe('Teams Create', () => {
|
||||
test('checks error when duplicate team', async ({ page, browserName }) => {
|
||||
const panel = page.getByLabel('Teams panel').first();
|
||||
|
||||
await panel.getByRole('button', { name: 'Add a team' }).click();
|
||||
await panel.getByRole('link', { name: 'Add a team' }).click();
|
||||
|
||||
const teamName = `My duplicate team ${browserName}-${Math.floor(Math.random() * 1000)}`;
|
||||
await page.getByText('Team name').fill(teamName);
|
||||
await page.getByRole('button', { name: 'Create the team' }).click();
|
||||
|
||||
await panel.getByRole('button', { name: 'Add a team' }).click();
|
||||
await panel.getByRole('link', { name: 'Add a team' }).click();
|
||||
|
||||
await page.getByText('Team name').fill(teamName);
|
||||
await page.getByRole('button', { name: 'Create the team' }).click();
|
||||
|
||||
@@ -22,7 +22,7 @@ test.describe('Teams Panel', () => {
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
panel.getByRole('button', {
|
||||
panel.getByRole('link', {
|
||||
name: 'Add a team',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
@@ -9,7 +9,7 @@
|
||||
"test:ui": "yarn test --ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.45.3",
|
||||
"@playwright/test": "1.46.1",
|
||||
"@types/node": "*",
|
||||
"eslint-config-people": "*",
|
||||
"typescript": "*"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "people",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.2",
|
||||
"private": true,
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
@@ -24,7 +24,7 @@
|
||||
"i18n:test": "yarn I18N run test"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/node": "20.14.13",
|
||||
"@types/node": "20.16.2",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
{
|
||||
"name": "eslint-config-people",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "eslint --ext .js ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "14.2.5",
|
||||
"@tanstack/eslint-plugin-query": "5.51.15",
|
||||
"@typescript-eslint/eslint-plugin": "7.13.1",
|
||||
"@next/eslint-plugin-next": "14.2.7",
|
||||
"@tanstack/eslint-plugin-query": "5.52.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.3.0",
|
||||
"@typescript-eslint/parser": "8.3.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "14.2.5",
|
||||
"eslint-config-next": "14.2.7",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-jest": "28.6.0",
|
||||
"eslint-plugin-jest": "28.8.1",
|
||||
"eslint-plugin-jsx-a11y": "6.9.0",
|
||||
"eslint-plugin-playwright": "1.6.2",
|
||||
"eslint-plugin-prettier": "5.2.1",
|
||||
"eslint-plugin-react": "7.35.0",
|
||||
"eslint-plugin-testing-library": "6.2.2"
|
||||
"eslint-plugin-testing-library": "6.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "packages-i18n",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"extract-translation": "yarn extract-translation:desk",
|
||||
@@ -15,9 +15,9 @@
|
||||
"@types/node": "*",
|
||||
"eslint-config-people": "*",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"i18next-parser": "8.8.0",
|
||||
"i18next-parser": "9.0.2",
|
||||
"jest": "29.7.0",
|
||||
"ts-jest": "29.2.3",
|
||||
"ts-jest": "29.2.5",
|
||||
"typescript": "*",
|
||||
"yargs": "17.7.2"
|
||||
}
|
||||
|
||||
@@ -9,3 +9,9 @@ stringData:
|
||||
DJANGO_SECRET_KEY: {{ .Values.djangoSecretKey }}
|
||||
OIDC_RP_CLIENT_ID: {{ .Values.oidc.clientId }}
|
||||
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
|
||||
{{ if .Values.resourceServer }}
|
||||
OIDC_RS_CLIENT_ID: {{ .Values.resourceServer.clientId }}
|
||||
OIDC_RS_CLIENT_SECRET: {{ .Values.resourceServer.clientSecret }}
|
||||
OIDC_RS_PRIVATE_KEY_STR: |
|
||||
{{ .Values.resourceServer.privateKey | indent 4 }}
|
||||
{{ end }}
|
||||
|
||||
@@ -50,6 +50,7 @@ backend:
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
MAIL_PROVISIONING_API_URL: "http://host.docker.internal:8000"
|
||||
command:
|
||||
- "gunicorn"
|
||||
- "-c"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
image:
|
||||
repository: lasuite/people-backend
|
||||
pullPolicy: Always
|
||||
tag: "main"
|
||||
tag: "v1.0.2"
|
||||
|
||||
backend:
|
||||
migrateJobAnnotations:
|
||||
@@ -84,6 +84,8 @@ backend:
|
||||
secretKeyRef:
|
||||
name: redis.redis.libre.sh
|
||||
key: url
|
||||
MAIL_PROVISIONING_API_URL: "https://api.dev.ox.numerique.gouv.fr"
|
||||
FEATURE_TEAMS: False
|
||||
|
||||
createsuperuser:
|
||||
command:
|
||||
@@ -96,7 +98,7 @@ frontend:
|
||||
image:
|
||||
repository: lasuite/people-frontend
|
||||
pullPolicy: Always
|
||||
tag: "main"
|
||||
tag: "v1.0.1"
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
|
||||