Compare commits

...

153 Commits

Author SHA1 Message Date
Marie PUPO JEAMMET 9f85e66abe ♻️(refacto) refacto responses
WIP
2026-01-29 18:24:11 +01:00
Marie PUPO JEAMMET 6ae195b90c 🔥(cleanup) remove comment from permissions file
remote remaining commented line from previous PR's tests
2026-01-28 14:49:31 +00:00
Marie PUPO JEAMMET be64abb22f (invitations) can delete domain invitations
add delete method to domain viewset
2026-01-28 14:49:31 +00:00
mjeammet ca56eb0cac 🌐(i18n) update translated strings
Update translated files with new translations
2026-01-26 11:30:57 +00:00
Marie PUPO JEAMMET 99ca34719f 🔖(patch) release version 1.22.2
Update all version files and changelog for patch release.
2026-01-26 11:30:57 +00:00
renovate[bot] 014fb62f95 ⬆️(dependencies) update lodash to v4.17.23 [SECURITY] 2026-01-26 10:48:52 +00:00
Marie PUPO JEAMMET 99433a6722 🐛(aliases) alias destination can be devnull@devnull
devnull@devnull is not considered a valid email address by django's
EmailFieldValidator but it's a special address in dimail's config.

Make "destination" a CharField instead of an EmailField to replace
validator and add devnull to allowlist.
2026-01-23 17:56:53 +00:00
Marie PUPO JEAMMET 5ebc88bcff 🔖(patch) release version 1.22.1
Update all version files and changelog for patch release.
2026-01-22 11:10:44 +00:00
Quentin BEY a65e61bd96 🔒️(organization) the first user is not admin
The first user of a organization is probably not an admin.
This was implemented for first tests but for now it's more
a security issue than something helpful.

FIXES #775
2026-01-22 11:26:58 +01:00
Marie PUPO JEAMMET b8beb56135 🐛(admin) fix broken alias import
fix admin action to sync aliases of a given domain
2026-01-21 17:30:30 +00:00
mjeammet 37e5b5b346 🌐(i18n) update translated strings
Update translated files with new translations
2026-01-20 12:29:49 +00:00
Marie PUPO JEAMMET 31201fbd59 🔖(minor) release version 1.22.0
Update all version files and changelog for minor release.
2026-01-20 12:29:49 +00:00
elvoisin eb0683ffe0 (front) create, manage & delete aliases (#1013)
* (front) add aliases

add list view aliases + creation aliases

* (front) add delete alias

add modale to delete aliases

* 🐛(react-query) remove onMutateResult from mutation callbacks

remove onMutateResult from mutation callbacks
2026-01-19 17:04:57 +01:00
Marie PUPO JEAMMET 54219d25b8 (tests) update auth-related tests after bumping django-lasuite
update the expected number of queries in authentication-related tests
due to bumping django-lasuite to 0.0.22
2026-01-19 10:09:29 +00:00
renovate[bot] 89e7703d53 ⬆️(dependencies) update python dependencies
keep django < 6.0 for compatibility with django-celery-beat
See https://github.com/celery/django-celery-beat/issues/977
2026-01-19 10:09:29 +00:00
Marie PUPO JEAMMET 7d44e54913 (admin) manage aliases from admin
manage aliases from admin, including alias creation,
deletion and sync
2026-01-16 15:19:37 +00:00
Marie PUPO JEAMMET 01583ba94f 🐛(aliases) sort aliases by local part
sort aliases by local part to ease front-end interface
2026-01-16 15:19:37 +00:00
Marie PUPO JEAMMET 5feee53bdd 🔒️(security) upgrade python version to fix vulnerability
Vulnerability in jaraco.context caused security issue
in setuptools and python3. change python version to fix
see https://github.com/advisories/GHSA-58pv-8j8x-9vj2
2026-01-16 11:23:55 +00:00
Marie PUPO JEAMMET 9c62efc9f8 🐛(dimail) authorize alias and mailbox with same local part
a mailbox and alias can coexist despite having the same local part
2026-01-15 10:29:49 +00:00
Marie PUPO JEAMMET d2ef9e0beb 🐛(dimail) ignore oxadmin mailboxes when importing mailboxes
oxadmin mailbox are technical mailboxes used by dimail. It should
not be imported when importing mailboxes from dimail.
2026-01-15 10:29:49 +00:00
Marie PUPO JEAMMET bc1cbef168 (aliases) delete all aliases of a given local part
added a bulk delete method for aliases, when filtering on local part
this is convenient when in need to delete the local part and all its
destinations in a single call
2026-01-12 14:31:53 +00:00
Marie PUPO JEAMMET 8ab1b2e2ef 🔧(settings) separate dimail container from app-dev
separate dimail container from app-dev to avoid setting
dimail container everytime we run the test suite.
2026-01-12 14:31:53 +00:00
Marie PUPO JEAMMET f498e3b6d2 ♻️(lint) mark a few dimail methods as private
mark a few dimail methods as private, to calm linter
2026-01-12 14:31:53 +00:00
Marie PUPO JEAMMET e47573e22f (aliases) delete aliases
delete a single alias using its pk
2026-01-12 14:31:53 +00:00
Sabrina Demagny c7aab6f5b5 🔥(plugins) remove CommuneCreation plugin
Remove the French communes auto-provisioning plugin that handled
SIRET lookup, DNS setup, and MailDomain creation.
2026-01-09 09:38:01 +01:00
renovate[bot] 2144ad5da1 ⬆️(dependencies) update next to v15.4.10 [SECURITY] 2025-12-15 14:43:33 +00:00
renovate[bot] f00deeebe9 ⬆️(dependencies) update next to v15.4.9 [SECURITY] 2025-12-12 12:41:33 +00:00
Marie PUPO JEAMMET 30aea9b350 🔖(minor) release version 1.21.0
Update all version files and changelog for minor release.
2025-12-05 16:34:06 +00:00
Marie PUPO JEAMMET e01b422c13 (mailboxes) check imported mailboxes don't clash with existing alias
check that dimail imported mailboxes don't use the same username
as existing aliases.
2025-12-05 15:03:17 +00:00
Marie PUPO JEAMMET 040f949e5e (aliases) import existing aliases from dimail
import existing aliases from dimail, making sure usernames don't clash with
existing mailboxes. Convenient when people fall out of sync
with dimail or for domains partially operated outside people.
2025-12-05 15:03:17 +00:00
renovate[bot] befcecf33c ⬆️(dependencies) update django to v5.2.9 [SECURITY] 2025-12-04 13:21:36 +00:00
renovate[bot] c5edabf3b0 ⬆️(dependencies) update next to v15.4.8 [SECURITY] 2025-12-04 09:36:36 +00:00
Marie PUPO JEAMMET 3a2b5a6428 🛂(permissions) return 404 to users with no domain access
users having no domain access should be forbidden to know the domain is
managed in people. To this effect, 403_FORBIDDEN responses have been
replaced by 404_NOT_FOUND.
2025-11-21 17:04:20 +00:00
Marie PUPO JEAMMET 15337a2226 (aliases) warn that domain is out of sync when unexpected 404
deleting an alias should trigger a request to dimail. if dimail's
response if a 404, it means people and dimail are out of sync with
each other. log error and warn user
2025-11-20 11:24:59 +00:00
Marie PUPO JEAMMET 0f7e312eb6 (mailboxes) cannot create mailbox with same local part as alias
should not be able to create a mailbox having the same local part as an alias
2025-11-20 11:24:59 +00:00
Marie PUPO JEAMMET 23561cd0e0 🔥(sops) remove obsolete sops file
remove obsolete sops file
2025-11-20 11:24:59 +00:00
Marie PUPO JEAMMET 53d0336755 (aliases) delete aliases
add feature to delete aliases
2025-11-20 11:24:59 +00:00
Marie PUPO JEAMMET b79e12b4be (aliases) list aliases
Can GET a list of all aliases of a domain
2025-11-20 11:24:59 +00:00
Marie PUPO JEAMMET c237bb4b10 (aliases) create aliases
allow domain managers to create aliases on their domain
2025-11-20 11:24:59 +00:00
renovate[bot] 64068efff4 ⬆️(dependencies) update django to v5.2.8 [SECURITY] 2025-11-06 00:11:45 +01:00
renovate[bot] 6866babb32 ⬆️(dependencies) update Brotli to v1.2.0 [SECURITY] 2025-11-06 00:11:04 +01:00
Quentin BEY 80caa062e9 🐛(mail-domains) fix zod resolver use in forms
References:
https://github.com/react-hook-form/react-hook-form/issues/12816
https://github.com/react-hook-form/resolvers/issues/768
2025-11-04 16:10:27 +01:00
Quentin BEY 513dbe6f83 💚(frontend) fix using side effects within waitFor callback
New lint error detected.
2025-11-04 16:10:27 +01:00
Marie PUPO JEAMMET 490fdd2ed4 🐛(tests) fix lint-front and test-front errors
fix dependency import error and failing e2e tests due to
@tanstack upgrade
2025-11-04 16:10:27 +01:00
Marie PUPO JEAMMET fa7d24545f 🔥(config) remove empty duplicate yarn.lock
remove duplicate yarn.lock from previous config attempts
2025-11-04 16:10:27 +01:00
Marie PUPO JEAMMET e5938e144e 📌(frontend) hold next to v15.4.7
Avoid renovate to bump next to its latest major version.
2025-11-04 16:10:27 +01:00
Marie PUPO JEAMMET 39af4313cf 🚨(frontend) adapt signatures to @tanstack/react-query to >5.90
Recent upgrade of @tanstack/react-query to
version >5.90 introduced a breaking change in the
onSuccess and onError callback signatures for
the useMutation hook.
The context parameter has been replaced with an
onMutateResult parameter, which provides
information about the result of the
onMutate callback.

Shamelessly copied from https://github.com/suitenumerique/docs/pull/1375/commits
2025-11-04 16:10:27 +01:00
Marie PUPO JEAMMET abfe32569e ⬇️(dependencies) hold cunningham to v3.2.3
Cunningham v4.0.0 is not yet compatible with lasuite ui-kit
2025-11-04 16:10:27 +01:00
renovate[bot] 964981bbfb ⬆️(dependencies) update js dependencies 2025-11-04 16:10:27 +01:00
mjeammet 33faa5f224 🌐(i18n) update translated strings
Update translated files with new translations
2025-10-22 10:18:21 +00:00
Marie PUPO JEAMMET 99967b450e 🔖(minor) release version 1.20.0
Update all version files and changelog for minor release.
2025-10-22 10:18:21 +00:00
Marie PUPO JEAMMET 71a7bf688f 🐛(mailbox) fix case-sensitive duplicate display names
uniqueness on first name + last name was case-sensitive, which allowed
duplicates
2025-10-22 08:22:48 +00:00
Marie PUPO JEAMMET 302671bc69 ⬆️(dependencies) upgrade node to 22
node 18 reached end-of-life and is now unsupported. we jump straight
to 22 as recommended here
https://nodejs.org/en/blog/announcements/node-18-eol-support
2025-10-21 11:52:51 +00:00
Marie PUPO JEAMMET 4262f469d6 ♻️(tests) refacto dimail tests with fixtures
test_api_mailboxes_create exceeded 1000 lines. By using fixtures, we can
at least factorize dimail response when token is ok or mailbox_data sent
to our API when creating a mailbox.
2025-10-14 10:00:28 +00:00
Marie PUPO JEAMMET b24cb23a83 (models) impose uniqueness on display name, to match ox's constraint
OpenXchange's primary key is display name (= first name + last name).
It must be unique in the domain's context. We don't have context info
but we can impose uniqueness by domain.
2025-10-14 10:00:28 +00:00
Marie PUPO JEAMMET 608f8c6988 🐛(dimail) grab duplicate displayname error
OpenXchange's primary key is display name (= first name + last name).
In absence of clear error message from dimail (yet), we catch errors 500 and
check if they're not due to the display name already existing in the context
2025-10-14 10:00:28 +00:00
renovate[bot] 2ddfef59d8 ⬆️(dependencies) update next to v15.4.7 [SECURITY] 2025-10-13 10:01:30 +00:00
renovate[bot] 6b8cb16bd5 ⬆️(dependencies) update django to v5.2.7 [SECURITY] 2025-10-13 08:50:32 +00:00
Marie PUPO JEAMMET 8b89a1112d 📝(release) update release.md
Update documentation on release
2025-09-23 18:56:56 +02:00
Laurent Bossavit 230ff21220 (mailbox) synchronize password of newly created mailbox with Dimail's
When using La Régie as Identity Provider this allows signing in.
2025-09-23 17:40:08 +02:00
Quentin BEY a37c2a8e83 🔒️(frontend) update alpine packages in production image
Force an update of installed package in the image used for
the frontend in production.
2025-09-23 14:31:13 +02:00
mjeammet 80da8bea74 🌐(i18n) update translated strings
Update translated files with new translations
2025-09-19 17:04:02 +02:00
Marie PUPO JEAMMET 76f4bf36c7 🔖(patch) release version 1.19.1
Update all version files and changelog for patch release.
2025-09-19 17:04:02 +02:00
Eléonore Voisin 78a5d907ca 🐛(fix) add enabled update your mailbox
can update your mailbox as viewer
2025-09-19 16:45:40 +02:00
Marie PUPO JEAMMET 0ad60ab292 ⬆️(backend) bump Django to 5.2.6
Bump Django version to 5.2.6, fixing CVE-2025-57833. See
https://www.djangoproject.com/weblog/2025/sep/03/security-releases/
2025-09-15 14:36:26 +02:00
Quentin BEY ac443d3b6f 🔒️(all) refactor Docker Hub login to use official GitHub actions
Replace custom Docker Hub authentication with standard, secure,
official GitHub actions for improved security and maintainability.

Uses officially supported actions that follow security best practices
and receive regular updates from GitHub.

Avoid unsecure handling of GitHub secrets.

Thanks to @lebaudantoine
2025-09-05 14:55:17 +02:00
Quentin BEY 48ff0e9f3a 🔧(ci) always run all git-lint steps
git-lint steps are independant and we would like to have all checks at
once. Using the `if: always()` instruction should ensure all steps
should be run event if the previous fails.

thanks @lunika
2025-09-05 14:44:20 +02:00
mjeammet 675e719a22 🌐(i18n) update translated strings
Update translated files with new translations
2025-09-03 11:52:42 +02:00
mjeammet 0875fae87b 🌐(i18n) update translated strings
Update translated files with new translations
2025-09-03 11:52:42 +02:00
Marie PUPO JEAMMET 668a296142 🔖(minor) release version 1.19.0
Update all version files and changelog for minor release.
2025-09-03 11:52:42 +02:00
elvoisin f1892b7049 (front) add modal update mailboxes (#954)
* (front) add modal update mailboxes

add modal update mailboxes

* ️(labels) improve aria-labels on domain panel's buttons

improve descriptions on aria-label on domain panel's buttons

---------

Co-authored-by: Marie PUPO JEAMMET <marie.pupojeammet@numerique.gouv.fr>
2025-09-03 11:02:23 +02:00
Marie PUPO JEAMMET 1bfad507ef (api) retrieve mailboxes
add feature to retrieve mailboxes when having the right access
2025-09-02 13:45:26 +02:00
Marie PUPO JEAMMET 72e73bff45 (api) give update rights to domain viewer on own mailbox
Introduces the notion of self in permissions
allowing a domain viewer to update their own mailbox.
2025-09-02 13:45:26 +02:00
Marie PUPO JEAMMET e45cf8dd8b (api) update mailboxes
Allow update of mailboxes. Secondary email, first and last names can be updated
but not domain or local_part.
2025-09-02 13:45:26 +02:00
Marie PUPO JEAMMET 79f8e5276a 💚(ci) fix changelog CI job
The command git watchanged is deprecated and is flag to be removed soon.
It can be easily replace with git log
2025-08-29 15:32:26 +02:00
Marie PUPO JEAMMET 4f97685204 🐛(admin) fix mailbox import from dimail
importing mailboxes from dimail was broken due to a change of format in dimail's
response.
2025-08-29 11:46:33 +02:00
Marie PUPO JEAMMET 935058582e ⬆️(dependencies) upgrade dimail to 0.5.2
manually upgrade dimail to match prod version
2025-08-29 11:46:33 +02:00
Marie PUPO JEAMMET 636b27321b 🧑‍💻(demo) set invitations for local user
prepare invitations for local user. Upon first connexion
to keycloack, invitations are consumed and dev are ready to go
:)
2025-08-26 17:39:58 +02:00
Marie PUPO JEAMMET 2fdddb3e12 🧑‍💻(demo) add mailboxes to demo
add mailboxes to demo domains.
2025-08-26 17:39:58 +02:00
Quentin BEY f7a97e11e8 🎨(ruff) fix linter error after ruff update
Fix the `make lint` errors after dependencies update.
2025-08-26 11:36:05 +02:00
renovate[bot] a56f86965f ⬆️(dependencies) update python dependencies 2025-08-26 09:23:11 +00:00
Hsiaoming Yang 89674ad718 🎨(joserfc) fix import path of RSAKey
`rfc7518` module will be removed in v1.4.0 of joserfc.
2025-08-26 10:59:35 +02:00
Quentin BEY 4e592382dc 💥(sentry) remove DJANGO_ before Sentry DSN env variable
Other "La Suite" projects don't have the "DJANGO_" prefix, so
we align this project with others.
2025-07-24 23:53:47 +02:00
Marie PUPO JEAMMET 1fdd2e052b 🐛(config) remove default sentry_dsn
set default sentry_dsn to None to avoid all outside
deployments sending infos to our sentry instance.
2025-07-23 12:08:28 +02:00
Quentin BEY 25918830ff ⬆️(django-lasuite) bump to version 0.0.11
This allows to accept JWE from new Proconnect introspection endpoint.
2025-07-09 14:47:55 +02:00
Marie PUPO JEAMMET b47282430e 🔖(patch) release version 1.18.2
Update all version files and changelog for patch release.
2025-07-03 11:28:01 +02:00
Marie PUPO JEAMMET ebd3f467c4 🐛(admin) fix read_only preventing mailbox creation in admin
removed read_only on "local_part" and "domain" which prevented
admin user from creating mailboxes from admin
2025-07-02 18:06:02 +02:00
Marie PUPO JEAMMET 0f4ec63a9f 🐛(webhooks) fix special case for tchap integration
we want to use Tchap integration server for our own preprod
but it doesn't follow the same url naming convention as the
other Tchap instances, so it requires a special treatment.
This quick fix should do it.
2025-07-02 17:52:44 +02:00
Eléonore Voisin fdec3b4196 🐛(front) fix missing pagination mail domains
fix position div ref
2025-07-02 17:38:21 +02:00
Marie PUPO JEAMMET b78723ecce 🔖(patch) release version 1.18.1
Update all version files and changelog for patch release.
2025-07-02 11:29:10 +02:00
Marie PUPO JEAMMET df0cea225c 🐛(changelog) add missing changelog
add missing changelog for consistency
2025-07-02 11:29:10 +02:00
mjeammet 3b73cca2f5 🌐(i18n) update translated strings
Update translated files with new translations
2025-07-02 11:29:10 +02:00
elvoisin 06d4d5c9e8 🐛(front) fix missing pagination mail domains (#946)
fix missing pagination mail domains + mailboxes list
2025-07-02 09:03:11 +02:00
mjeammet 405e5fda90 🌐(i18n) update translated strings
Update translated files with new translations
2025-06-30 15:23:08 +02:00
Marie PUPO JEAMMET 8a434448dd 🔖(minor) release version 1.18.0
Update all version files and changelog for minor release.
2025-06-30 15:23:08 +02:00
Marie PUPO JEAMMET 5b5bd312b4 🔧(backend) fix Redis dependency to <6.0.0
Pin the redis dependency to <6.0.0 to fix ResolutionImpossible errors
in future renovate PRs.
See https://github.com/suitenumerique/meet/pull/553
2025-06-30 11:57:28 +02:00
Marie PUPO JEAMMET 99bf301a9c ⬆️(dependencies) update python dependencies
manually upgrade dependencies
2025-06-30 11:57:28 +02:00
Marie PUPO JEAMMET 3dc11c9b52 🐛(webhook) search existing Matrix user before inviting
fix wrong formatting of user id + now searches for existing Matrix
account of the user before inviting them to webhook room.
2025-06-27 16:34:18 +02:00
Marie PUPO JEAMMET 5327baacbd 🔧(settings) rename matrix bot token variable
rename "tchap access token" to "matrix bot access token" to
fit general uses
2025-06-27 16:34:18 +02:00
Manuel Raynaud 709628a26a 🔧(back) remove usage of deprecated db engine (#937)
The db engine postgresql_psycopg2 does not exists anymore in django but
for BC compat it is possible to use it in the configuration and it is
replace by postgresql at runtime. We changed this settings to use the
good one.
2025-06-27 15:18:27 +02:00
Marie PUPO JEAMMET d2bf267160 🐛(webhooks) fix InsecureRequestWarning from webhook calls
Matrix webhook calls were unverified and caused a security warning.
Setting option "verify" to "True" on related calls should help.
2025-06-25 19:54:45 +02:00
Marie PUPO JEAMMET 10dcdfc8c2 🐛(webhook) fix error raised when no secret
Our Tchap webhook contains no secret (as a dedicated access token is provided)
This lead to an error upon trying to join rooms.
2025-06-23 17:28:41 +02:00
elvoisin f30398fbc7 🐛(front) fix button add mail domain (#932)
fix button rules + fix bad wording
2025-06-23 16:27:52 +02:00
Marie PUPO JEAMMET cc39ed5298 (teams) add matrix webhook for teams
A webhook to invite/kick team members to a matrix room.
2025-06-21 00:15:16 +02:00
Marie PUPO JEAMMET 7bebf13d88 🐛(domains) reduce logs around domain invitations
reduce logs and add tests around domain invitations
2025-06-21 00:15:16 +02:00
Quentin BEY e64a34f167 🧑‍💻(keycloak) command to add new client
This introduce a command to create a new client into the "people" realm.
This could be use to create a specific client to test the resource
server mode on a local deployment:
 - run the people stack
 - add the new client, let say, for docs
 - configure the people backend for token introspection
 - make calls from docs backend to people's backend

The new client is not mandatory because the same client could be used
everywhere but this would not demonstrate the fact the introspection
works in a real world configuration.
2025-06-21 00:15:16 +02:00
Quentin BEY 3379d6d499 🔧(git) set LF line endings for all text files
Windows users are by default using CRLF line endings,
which can cause issues with some tools and
environments. This commit sets the `.gitattributes`
file to enforce LF line endings for all text
files in the repository.

Based on the same commit on docs
2025-06-21 00:15:16 +02:00
Quentin BEY 213656fc2e 🧑‍💻(docker) split frontend to another file
This commit aims at improving the user experience:
- Use a dedicated `Dockerfile` for the frontend
- Run the backend and frontend in "watch"/dev mode in Docker
- Do not start all Docker instances for small tasks
2025-06-21 00:15:16 +02:00
Quentin BEY 4dfd682cb6 (resource-server) add SCIM /Me endpoint
This provide a "self-care" SCIM endpoint, authenticated with OIDC token
introspection. This endpoint will be use by services to fetch the user's
team list.

We chose to use the SCIM format (even if this is not a SCIM context) to
make it easier to understand/maintain/plug.
2025-06-21 00:15:16 +02:00
mjeammet 160c45bf35 🌐(i18n) update translated strings
Update translated files with new translations
2025-06-11 11:40:56 +02:00
Marie PUPO JEAMMET 3fdd8a230c 🔖(minor) release version 1.17.0
Update all version files and changelog for minor release.
2025-06-11 11:40:56 +02:00
renovate[bot] b47246826e ⬆️(dependencies) manually update python dependencies
Many of our dependencies still aren't compatible with Redis 6.
This results in a partial upgrade of our dependencies.
2025-06-10 21:59:41 +02:00
Marie PUPO JEAMMET 5429354261 🐛(config) whitelist kube pod
Whitelist our pod's IP address. Based on Visio's PR
https://github.com/suitenumerique/meet/pull/95
2025-06-10 18:12:02 +02:00
Marie PUPO JEAMMET 86c98cc426 🧑‍💻(dimail) modify makefile to setup dimail container upon running demo
Setup dimail container upon running demo so that it's always in sync.
2025-06-10 16:52:04 +02:00
Marie PUPO JEAMMET 0bbce9ffc8 🔥(dimail) remove obsolete user and allow creation in dimail setup
Remove obsolete duplication to dimail database.
See PR https://github.com/suitenumerique/people/pull/886 for more context.
2025-06-10 16:52:04 +02:00
Marie PUPO JEAMMET ce66645294 🐛(demo) fix domains names
Mailbox creation was broken in dev because of wrong format of domains names
2025-06-10 16:52:04 +02:00
Eléonore Voisin 485eb88dd1 (frontend) add crisp script
add crisp chatbox to global layout
2025-06-10 16:13:24 +02:00
renovate[bot] a8b08c4b6d ⬆️(dependencies) update requests to v2.32.4 [SECURITY] 2025-06-10 15:47:00 +02:00
renovate[bot] 23f5a13ccc ⬆️(dependencies) update django to v5.2.2 [SECURITY] 2025-06-10 12:20:27 +00:00
elvoisin 1245c54c61 ️(fix) add error when mailbox create failed (#915)
fix toast error when mailbox create failed
2025-06-10 12:19:31 +00:00
Quentin BEY 95f63fa56d 🔒️(frontend) hide Nginx server version in error responses
Remove version disclosure in /assets/ error pages identified by security
auditor to prevent information leakage vulnerability.
2025-06-05 19:27:24 +02:00
Marie PUPO JEAMMET 4c3891047b (mailboxes) add a button to reset password on enabled mailboxes
Domain admins can now send requests to reset password for any mailbox
on their domains.
2025-05-20 16:45:14 +02:00
Marie PUPO JEAMMET fce9b1e490 🐛(dimail) fix broken auth while resetting passwords
Dimail client's "reset password" method was using basic auth while
dimail expects a token for this endpoint. Fixed it.
2025-05-20 16:45:14 +02:00
Marie PUPO JEAMMET 83bec33bdb ✏️(typo) fix typos
Did you know ? "Successful" actually takes two esses.
2025-05-20 16:45:14 +02:00
Quentin BEY 8fed2606d6 🧑‍💻(frontend) add makefile lint --fix
Add a Makefile command to easily run the automatic fixup for frontend
files.
2025-05-20 15:00:14 +02:00
Marie PUPO JEAMMET c7eb86eaa9 ⬆️(dependencies) manually upgrade python dependencies
Renovate seems to be in a weird place dealing with conflicting versions.
This PR handles the non-problematic upgrades
2025-05-16 19:32:15 +02:00
qbey be1513e106 🌐(i18n) update translated strings
Update translated files with new translations
2025-05-16 11:22:18 +00:00
Quentin BEY 8e85f303ec 🌐(frontend) update some translated messages
Slight cleanup of translations.
2025-05-16 11:55:32 +02:00
Quentin BEY ee564ff6ba 🎨(front) fix mail domain list display
The first implementation was using `div` instead of the proper
components.
2025-05-16 11:55:32 +02:00
renovate[bot] b0355059b7 ⬆️(dependencies) update js dependencies 2025-05-16 11:41:03 +02:00
Marie PUPO JEAMMET 6e792986be (admin) send pending mailboxes from admin
Provides an admin action to send all pending mailboxes for an active domain.
This allows quick fixes when mailboxes fell out of sync.
2025-05-16 11:30:46 +02:00
Quentin BEY fe9fb67fed 🔒️(docker) patch libxml to address CVE
Trivy scan detects some issue:
┌─────────┬────────────────┬──────────┬────────┬───────────────────┬───────────────┬───────────────────────────────────────────────────────────┐
│ Library │ Vulnerability  │ Severity │ Status │ Installed Version │ Fixed Version │                           Title                           │
├─────────┼────────────────┼──────────┼────────┼───────────────────┼───────────────┼───────────────────────────────────────────────────────────┤
│ libxml2 │ CVE-2025-32414 │ HIGH     │ fixed  │ 2.13.4-r5         │ 2.13.4-r6     │ libxml2: Out-of-Bounds Read in libxml2                    │
│         │                │          │        │                   │               │ https://avd.aquasec.com/nvd/cve-2025-32414                │
│         ├────────────────┤          │        │                   │               ├───────────────────────────────────────────────────────────┤
│         │ CVE-2025-32415 │          │        │                   │               │ libxml2: Out-of-bounds Read in xmlSchemaIDCFillNodeTables │
│         │                │          │        │                   │               │ https://avd.aquasec.com/nvd/cve-2025-32415                │
└─────────┴────────────────┴──────────┴────────┴───────────────────┴───────────────┴───────────────────────────────────────────────────────────┘
2025-05-16 10:03:52 +02:00
Quentin BEY 91fbef9066 🎨(front) rewrite the team main page
The first rewrite I made was using `div` instead of the proper
components.
2025-05-14 18:46:49 +02:00
Quentin BEY 4f3c9abe62 🐛(front) improve domain "return" button
The button to return to domain list was reloading the whole page which
was quite long (and somehow failing on staging).
2025-05-14 17:57:29 +02:00
Quentin BEY bd43e4620d 💄(teams) update team list page to match new UI
This is an attempt to quick fix the team page to match the new UI.
2025-05-14 17:57:29 +02:00
Quentin BEY 8c67d4a004 (e2e) add mailboxe (dis/en)able check
This provides a new test to check the action on the mailbox item.
For now we can only enable or disable a mailbox.

We need to create the mailbox in the test, so it exists on Dimail side.
2025-05-14 17:46:26 +02:00
Quentin BEY 78cb3e693c 💚(e2e) remove useless test on "tabs"
This is supposed to be a validation test on accessibility but in fact
it's just a flaky test which does not provide any information. We need
to replace this with something smarter.
2025-05-14 17:46:26 +02:00
Quentin BEY cec8e87edd 🐛(front-mail) dynamically display org name
The organization is not always DINUM.
2025-05-14 17:46:26 +02:00
Quentin BEY 35a700d522 💄(cunningham) fix style for display
Some CSS were not found (like color gold-500).
2025-05-14 17:46:26 +02:00
Quentin BEY c786ddbb82 🐛(frontend) re-enable mailbox actions
This code was commented but seems to work properly.
2025-05-14 17:46:26 +02:00
Quentin BEY cb198a9d04 💩(frontend) restore user name / org header
This was added previously and while the organization is not displayed
elsewhere it's better to keep the information displayed somewhere.
2025-05-14 17:43:37 +02:00
Quentin BEY 3d9645b561 ⚰️(i18n) utils module is not used anymore
The use of this module has been removed during the UI refacto.
2025-05-14 17:43:37 +02:00
Quentin BEY 21cbeded18 (frontend) fix end-to-end tests after refacto
Some test were broken other were flaky: fix them.
All tests are not worthy but since it was easy to fix, we keep them
until we write better tests...
2025-05-14 17:43:37 +02:00
Quentin BEY f0c609ef0b 🐛(frontend) fix create team button
The button to create a new team was not displayed properly anymore.
2025-05-14 17:43:37 +02:00
Quentin BEY aa3d90b686 🐛(frontend) fix browser language detection
The `Default language` E2E test detected the browser language was not
automatically detected when user does not have any cookie, it was always
falling back on the defaut language (en).
2025-05-14 17:43:37 +02:00
Quentin BEY 4a08a9ec92 ⚰️(front) remove unused code after UI v2
The linter was unhappy, so I removed the unused variables or code.
2025-05-14 17:43:37 +02:00
Quentin BEY 5544a40f5f 🎨(front) fix linter issues with --fix
Simply run the lint command with "autofix" to format files.
2025-05-14 17:43:37 +02:00
Eléonore Voisin e274c309cd 🎨(frontend) global UI rewrite to match new design
This commit is the result of several squashed commits which were
complicated to disjoin.
This rewrites the base UI, and the mail management interfaces.
2025-05-14 17:41:30 +02:00
renovate[bot] 560998083d ⬆️(dependencies) update django to v5.2.1 [SECURITY] 2025-05-08 18:32:32 +02:00
Marie PUPO JEAMMET 2d56c57102 (dimail) add reset password method
allow domain owner and admins to reset password for a mailbox
they manage. The request is sent to dimail, which responds with
a new randomly generated password. This new password is sent to
secondary email.
2025-05-05 17:28:00 +02:00
Benoit Serrano b5d86967ff 🐛(docker) fix dockerize no matching manifest for linux/arm64/v8 error
fixing docker config for linux/arm64/v8 error
2025-05-05 17:28:00 +02:00
Marie PUPO JEAMMET 08559d856d ⬆️(dimail) update dimail version to 2.11
update dimail version to last production-ready version.
2025-05-05 17:28:00 +02:00
Marie PUPO JEAMMET 8b17a5470d 🔖(changelog) fix changelog
Fixing changelog after having forgotten to update it in last release (1.16.0)
2025-05-05 12:39:34 +02:00
314 changed files with 19555 additions and 11642 deletions
+24
View File
@@ -0,0 +1,24 @@
# Set the default behavior for all files
* text=auto eol=lf
# Binary files (should not be modified)
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.mov binary
*.mp4 binary
*.mp3 binary
*.flv binary
*.fla binary
*.swf binary
*.gz binary
*.zip binary
*.7z binary
*.ttf binary
*.woff binary
*.woff2 binary
*.eot binary
*.pdf binary
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '18.x'
node_version: '22.x'
with-front-dependencies-installation: true
synchronize-with-crowdin:
+2 -2
View File
@@ -10,7 +10,7 @@ jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '18.x'
node_version: '22.x'
with-front-dependencies-installation: true
with-build_mails: true
@@ -25,7 +25,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.11"
python-version: '3.13'
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
+1 -1
View File
@@ -5,7 +5,7 @@ on:
inputs:
node_version:
required: false
default: '18.x'
default: '22.x'
type: string
with-front-dependencies-installation:
type: boolean
+10 -3
View File
@@ -40,7 +40,7 @@ jobs:
name: Run trivy scan (frontend)
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target frontend-production -f Dockerfile'
docker-build-args: '--target frontend-production -f src/frontend/Dockerfile'
docker-image-name: 'docker.io/lasuite/people-frontend:${{ github.sha }}'
build-and-push-backend:
@@ -58,7 +58,10 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: create-version-json
id: create-version-json
uses: jsdaniell/create-json@v1.2.3
@@ -99,12 +102,16 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
+10 -8
View File
@@ -13,7 +13,7 @@ jobs:
dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '18.x'
node_version: '22.x'
with-front-dependencies-installation: true
with-build_mails: true
@@ -28,14 +28,18 @@ jobs:
- name: show
run: git log
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/people.yml' | grep -E "(\bprint\(|\bpprint\()"
- name: Check absence of fixup commits
if: always()
run: |
! git log | grep 'fixup!'
- name: Install gitlint
if: always()
run: pip install --user requests gitlint
- name: Lint commit messages added to main
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
@@ -49,7 +53,7 @@ jobs:
with:
fetch-depth: 0
- name: Check that the CHANGELOG has been modified in the current branch
run: git whatchanged --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
run: git log --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
lint-changelog:
runs-on: ubuntu-latest
@@ -167,6 +171,8 @@ jobs:
COMPOSE_DOCKER_CLI_BUILD: 1
run: |
docker compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
make update-keycloak-realm-app
make add-dev-rsa-private-key-to-env
make run
- name: Apply DRF migrations
@@ -177,10 +183,6 @@ jobs:
run: |
make demo FLUSH_ARGS='--no-input'
- name: Setup Dimail DB
run: |
make dimail-setup-db
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
@@ -222,7 +224,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '3.13'
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -279,7 +281,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '3.13'
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install gettext (required to compile messages)
-11
View File
@@ -1,11 +0,0 @@
creation_rules:
- path_regex: ./*
key_groups:
- age:
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa # Marie Pupo Jeammet
- age1rjchule5sncn8r8gfph07muee6vzx4wqfrtldt5jjzke4vlfxy2qqplfvc # Quentin Bey
+119 -1
View File
@@ -10,10 +10,117 @@ and this project adheres to
### Added
- ✨(invitations) can delete domain invitations
## [1.22.2] - 2026-01-26
### Fixed
- 🐛(aliases) authorize special domain devnull in alias destinations #1029
## [1.22.1] - 2026-01-21
- 🔒️(organization) the first user is not admin #776
- 🐛(admin) fix broken alias import #1021
## [1.22.0] - 2026-01-19
### Added
- ✨(front) create, manage & delete aliases
- ✨(domains) alias sorting and admin
- ✨(aliases) delete all aliases in one call #1002
### Fixed
- 🔒️(security) upgrade python version to fix vulnerability #1010
- 🐛(dimail) ignore oxadmin when importing mailboxes from dimail #986
- ✨(aliases) fix deleting single aliases #1002
### Changed
- 🐛(dimail) allow mailboxes and aliases to have the same local part #986
### Removed
- 🔥(plugins) remove CommuneCreation plugin
## [1.21.0] - 2025-12-05
- ✨(aliases) import existing aliases from dimail
- 🛂(permissions) return 404 to users with no access to domain #985
- ✨(aliases) can create, list and delete aliases #974
## [1.20.0] - 2025-10-22
- ✨(models) impose uniqueness on display name, to match ox's constraint
- 🐛(dimail) catch duplicate displayname error
- ✨(mailbox) synchronize password of newly created mailbox with Dimail's
## [1.19.1] - 2025-09-19
- 🐛(fix) add enabled update your mailbox
## [1.19.0] - 2025-09-03
- ✨(front) add modal update mailboxes #954
### Added
- ✨(api) update mailboxes #934
- ✨(api) give update rights to domain viewer on own mailbox #934
### Fixed
- 🐛(dimail) grab duplicate displayname error #961
### Changed
- 💥(sentry) remove `DJANGO_` before Sentry DSN env variable #957
## [1.18.2] - 2025-07-03
### Fixed
- 🐛(front) fix missing pagination mail domains #950
## [1.18.1] - 2025-07-02
### Fixed
- 🐛(front) fix missing pagination on mail domains #946
## [1.18.0] - 2025-06-30
### Added
- 🐛(front) fix missing pagination mail domains
- 🐛(front) fix button add mail domain
- ✨(teams) add matrix webhook for teams #904
- ✨(resource-server) add SCIM /Me endpoint #895
- 🔧(git) set LF line endings for all text files #928
### Changed
- 🧑‍💻(docker) split frontend to another file #924
### Fixed
- 🐛(webhook) handle user on different home server than room server
## [1.17.0] - 2025-06-11
### Added
- ✨(frontend) add crisp script #914
- ⚡️(fix) add error when mailbox create failed
- ✨(mailbox) allow to reset password on mailboxes #834
## [1.16.0] - 2025-05-05
### Added
- 🔧(sentry) add Celery beat task integration #892
### Changed
- ✨(uiv2) change mail domains
- 🛂(dimail) simplify interop with dimail
- ✨(mailbox) remove secondary email as required field
@@ -383,7 +490,18 @@ and this project adheres to
- ✨(domains) create and manage domains on admin + API
- ✨(domains) mailbox creation + link to email provisioning API
[unreleased]: https://github.com/suitenumerique/people/compare/v1.16.0...main
[unreleased]: https://github.com/suitenumerique/people/compare/v1.22.2...main
[1.22.2]: https://github.com/suitenumerique/people/releases/v1.22.2
[1.22.1]: https://github.com/suitenumerique/people/releases/v1.22.1
[1.22.0]: https://github.com/suitenumerique/people/releases/v1.22.0
[1.21.0]: https://github.com/suitenumerique/people/releases/v1.21.0
[1.20.0]: https://github.com/suitenumerique/people/releases/v1.20.0
[1.19.1]: https://github.com/suitenumerique/people/releases/v1.19.1
[1.19.0]: https://github.com/suitenumerique/people/releases/v1.19.0
[1.18.2]: https://github.com/suitenumerique/people/releases/v1.18.2
[1.18.1]: https://github.com/suitenumerique/people/releases/v1.18.1
[1.18.0]: https://github.com/suitenumerique/people/releases/v1.18.0
[1.17.0]: https://github.com/suitenumerique/people/releases/v1.17.0
[1.16.0]: https://github.com/suitenumerique/people/releases/v1.16.0
[1.15.0]: https://github.com/suitenumerique/people/releases/v1.15.0
[1.14.1]: https://github.com/suitenumerique/people/releases/v1.14.1
+3 -58
View File
@@ -1,70 +1,15 @@
# Django People
# ---- base image to inherit from ----
FROM python:3.12.6-alpine3.20 AS base
FROM python:3.13.11-alpine AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
RUN python -m pip install --upgrade pip
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade
### ---- Front-end dependencies image ----
FROM node:20 AS frontend-deps
WORKDIR /deps
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/apps/desk/package.json ./apps/desk/package.json
COPY ./src/frontend/packages/i18n/package.json ./packages/i18n/package.json
COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint-config-people/package.json
RUN yarn --frozen-lockfile
### ---- Front-end builder dev image ----
FROM node:20 AS frontend-builder-dev
WORKDIR /builder
COPY --from=frontend-deps /deps/node_modules ./node_modules
COPY ./src/frontend .
WORKDIR ./apps/desk
### ---- Front-end builder image ----
FROM frontend-builder-dev AS frontend-builder
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.27-alpine AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3
USER nginx
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=frontend-builder \
/builder/apps/desk/out \
/usr/share/nginx/html
COPY ./src/frontend/apps/desk/conf/default.conf /etc/nginx/conf.d
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["nginx", "-g", "daemon off;"]
# ---- Back-end builder image ----
FROM base AS back-builder
@@ -78,7 +23,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:20 AS mail-builder
FROM node:22 AS mail-builder
COPY ./src/mail /mail/app
+42 -29
View File
@@ -41,11 +41,9 @@ DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN = $(COMPOSE) run --rm --no-deps
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
WAIT_KC_DB = $(COMPOSE_RUN) dockerize -wait tcp://kc_postgresql:5432 -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -78,24 +76,39 @@ create-env-files: \
env.d/development/kc_postgresql
.PHONY: create-env-files
add-dev-rsa-private-key-to-env: ## Add a generated RSA private key to the env file
@echo "Generating RSA private key PEM for development..."
@mkdir -p env.d/development/rsa
@openssl genrsa -out env.d/development/rsa/private.pem 2048
@echo -n "\nOAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY=\"" >> env.d/development/common
@openssl rsa -in env.d/development/rsa/private.pem -outform PEM >> env.d/development/common
@echo "\"" >> env.d/development/common
@rm -rf env.d/development/rsa
.PHONY: add-dev-rsa-private-key-to-env
update-keycloak-realm-app: ## Create the Keycloak realm for the project
@echo "$(BOLD)Creating Keycloak realm for 'app'$(RESET)"
@sed -i 's|http://app-dev:8000|http://app:8000|g' ./docker/auth/realm.json
.PHONY: update-keycloak-realm-app
bootstrap: ## Prepare Docker images for the project and install frontend dependencies
bootstrap: \
data/media \
data/static \
create-env-files \
build \
run \
run-dev \
migrate \
back-i18n-compile \
mails-install \
mails-build \
dimail-setup-db \
install-front-desk
dimail-setup-db
.PHONY: bootstrap
# -- Docker/compose
build: ## build the app-dev container
@$(COMPOSE) build app-dev
@$(COMPOSE) build dimail
.PHONY: build
down: ## stop and remove containers, networks, images, and volumes
@@ -106,19 +119,14 @@ logs: ## display app-dev logs (follow mode)
@$(COMPOSE) logs -f app-dev
.PHONY: logs
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d nginx
@$(COMPOSE) up --force-recreate -d app-dev
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d celery-beat-dev
@$(COMPOSE) up --force-recreate -d flower-dev
@$(COMPOSE) up --force-recreate -d keycloak
@$(COMPOSE) up -d dimail
@echo "Wait for postgresql to be up..."
@$(WAIT_KC_DB)
@$(WAIT_DB)
run: ## start the wsgi (production) and servers with production Docker images
@$(COMPOSE) up --force-recreate --detach app frontend celery celery-beat nginx maildev
.PHONY: run
run-dev: ## start the servers in development mode (watch) Docker images
@$(COMPOSE) up --force-recreate --detach app-dev dimail frontend-dev celery-dev celery-beat-dev nginx maildev
.PHONY: run-dev
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
@@ -132,6 +140,7 @@ stop: ## stop the development server using Docker
demo: ## flush db then create a demo for load testing purpose
@$(MAKE) resetdb
@$(MANAGE) create_demo
@$(MAKE) dimail-setup-db
.PHONY: demo
@@ -158,10 +167,14 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
bin/pylint --diff-only=origin/main
.PHONY: lint-pylint
lint-front:
lint-front: ## lint front-end sources with eslint
cd $(PATH_FRONT) && yarn lint
.PHONY: lint-front
lint-front-fix: ## fix front-end sources with eslint
cd $(PATH_FRONT) && yarn lint-fix
.PHONY: lint-front-fix
test: ## run project tests
@$(MAKE) test-back-parallel
.PHONY: test
@@ -182,22 +195,16 @@ test-coverage: ## compute, display and save test coverage
makemigrations: ## run django makemigrations for the people project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) makemigrations $(ARGS)
.PHONY: makemigrations
migrate: ## run django migrations for the people project.
@echo "$(BOLD)Running migrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) migrate $(ARGS)
.PHONY: migrate
showmigrations: ## run django showmigrations for the people project.
@echo "$(BOLD)Running showmigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) showmigrations $(ARGS)
.PHONY: showmigrations
@@ -289,12 +296,8 @@ i18n-generate-and-upload: \
# -- INTEROPERABILTY
# -- Dimail configuration
dimail-recreate-container:
@$(COMPOSE) up --force-recreate -d dimail
.PHONY: dimail-recreate-container
dimail-setup-db:
@$(COMPOSE) up --force-recreate -d dimail
@echo "$(BOLD)Populating database of local dimail API container$(RESET)"
@$(MANAGE) setup_dimail_db --populate-from-people
.PHONY: dimail-setup-db
@@ -425,3 +428,13 @@ install-secret: ## install the kubernetes secrets from Vaultwarden
fetch-domain-status:
@$(MANAGE) fetch_domain_status
.PHONY: fetch-domain-status
# -- Keycloak related
create-new-client: ## run the add-keycloak-client.sh script for keycloak.
@echo "$(BOLD)Running add-keycloak-client.sh$(RESET)"
@$(COMPOSE_RUN) \
-v ./scripts/keycloak/add-keycloak-client.sh:/tmp/add-keycloak-client.sh \
--entrypoint="/tmp/add-keycloak-client.sh" \
keycloak \
$(filter-out $@,$(MAKECMDGOALS))
.PHONY: create-new-client
+7 -1
View File
@@ -33,7 +33,7 @@ The easiest way to start working on the project is to use GNU Make:
$ make bootstrap
```
This command builds the `app` container, installs dependencies, performs
This command builds the `app-dev` container, installs dependencies, performs
database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
dependency-related or migration-related issues.
@@ -46,6 +46,12 @@ Note that if you need to run them afterward, you can use the eponym Make rule:
$ make run
```
or if you want to run them in development mode (with live reloading):
```bash
$ make run-dev
```
You can check all available Make rules using:
```bash
+17
View File
@@ -64,6 +64,23 @@ function _dc_run() {
_docker_compose run --rm $user_args "$@"
}
# _dc_run_no_deps: wrap docker compose run command without dependencies
#
# usage: _dc_run_no_deps [options] [ARGS...]
#
# options: docker compose run command options
# ARGS : docker compose run command arguments
function _dc_run_no_deps() {
_set_user
user_args="--user=$USER_ID"
if [ -z $USER_ID ]; then
user_args=""
fi
_docker_compose run --no-deps --rm $user_args "$@"
}
# _dc_exec: wrap docker compose exec command
#
# usage: _dc_exec [options] [ARGS...]
+1 -1
View File
@@ -35,4 +35,4 @@ fi
# Fix docker vs local path when project sources are mounted as a volume
read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")"
_dc_run app-dev pylint "${paths[@]}" "${args[@]}"
_dc_run_no_deps app-dev pylint "${paths[@]}" "${args[@]}"
+124 -40
View File
@@ -1,10 +1,16 @@
services:
postgresql:
image: postgres:16
platform: linux/amd64
env_file:
- env.d/development/postgresql
ports:
- "15432:5432"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ]
interval: 1s
timeout: 2s
retries: 300
redis:
image: redis:5
@@ -22,6 +28,7 @@ services:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:backend-development
pull_policy: never
environment:
- PYLINTHOME=/app/.pylint.d
- DJANGO_CONFIGURATION=Development
@@ -36,11 +43,67 @@ services:
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- dimail
- postgresql
- maildev
- redis
postgresql:
condition: service_healthy
restart: true
maildev:
condition: service_started
redis:
condition: service_started
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-dev
image: people:frontend-development
pull_policy: never
volumes:
- ./src/frontend:/home/frontend
- /home/frontend/node_modules
- /home/frontend/apps/desk/node_modules
ports:
- "3000:3000"
app:
build:
context: .
target: backend-production
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:backend-production
environment:
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/common
- env.d/development/france
- env.d/development/postgresql
ports:
- "8071:8000"
volumes:
- ./data/media:/data/media
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
frontend:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-production
args:
API_ORIGIN: "http://localhost:8071"
image: people:frontend-production
pull_policy: build
ports:
- "3000:3000"
celery-dev:
user: ${DOCKER_USER:-1000}
image: people:backend-development
@@ -55,7 +118,11 @@ services:
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- app-dev
postgresql:
condition: service_healthy
restart: true
app-dev:
condition: service_started
celery-beat-dev:
user: ${DOCKER_USER:-1000}
@@ -71,26 +138,9 @@ services:
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- app-dev
app:
build:
context: .
target: backend-production
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:backend-production
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
volumes:
- ./data/media:/data/media
depends_on:
- postgresql
- redis
postgresql:
condition: service_healthy
restart: true
celery:
user: ${DOCKER_USER:-1000}
@@ -102,7 +152,29 @@ services:
- env.d/development/common
- env.d/development/postgresql
depends_on:
- app
postgresql:
condition: service_healthy
restart: true
app:
condition: service_started
celery-beat:
user: ${DOCKER_USER:-1000}
image: people:backend-production
command: ["celery", "-A", "people.celery_app", "beat", "-l", "INFO"]
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
volumes:
- ./src/backend:/app
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
postgresql:
condition: service_healthy
restart: true
nginx:
image: nginx:1.25
@@ -111,11 +183,9 @@ services:
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- app
- keycloak
dockerize:
image: jwilder/dockerize
keycloak:
condition: service_healthy
restart: true
crowdin:
image: crowdin/cli:4.6.1
@@ -127,7 +197,7 @@ services:
working_dir: /app
node:
image: node:18
image: node:22
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
@@ -135,11 +205,16 @@ services:
- ".:/app"
kc_postgresql:
image: postgres:14.3
ports:
- "5433:5432"
env_file:
- env.d/development/kc_postgresql
image: postgres:14.3
ports:
- "5433:5432"
env_file:
- env.d/development/kc_postgresql
healthcheck:
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ]
interval: 1s
timeout: 2s
retries: 300
keycloak:
image: quay.io/keycloak/keycloak:20.0.1
@@ -156,6 +231,8 @@ services:
- --hostname-admin-url=http://localhost:8083/
- --hostname-strict=false
- --hostname-strict-https=false
- --health-enabled=true
- --metrics-enabled=true
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
@@ -166,14 +243,21 @@ services:
KC_DB_USERNAME: people
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true'
healthcheck:
test: [ "CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready" ]
interval: 1s
timeout: 2s
retries: 300
ports:
- "8080:8080"
depends_on:
- kc_postgresql
kc_postgresql:
condition: service_healthy
restart: true
dimail:
entrypoint: /opt/dimail-api/start-dev.sh
image: registry.mim-libre.fr/dimail/dimail-api:v0.1.1
image: registry.mim-libre.fr/dimail/dimail-api:v0.5.2
pull_policy: always
environment:
DIMAIL_MODE: FAKE
+1 -1
View File
@@ -26,7 +26,7 @@
"oauth2DeviceCodeLifespan": 600,
"oauth2DevicePollingInterval": 5,
"enabled": true,
"sslRequired": "external",
"sslRequired": "none",
"registrationAllowed": true,
"registrationEmailAsUsername": false,
"rememberMe": true,
+2 -3
View File
@@ -13,8 +13,7 @@ To ease local development, dimail provides a container that we embark in our doc
Bootstraping with command `make bootstrap` creates a container and initializes its database.
Additional commands :
- Reset the database by recreating the container with `dimail-recreate-container`.
- Populate the database with all the content of your People database with `dimail-setup-db`
- Reset and populate the database with all the content of your People database with `dimail-setup-db`
## Architecture
@@ -47,5 +46,5 @@ People users can have 3 levels of permissions on a domain:
Mailboxes can be created by a domain owners or administrators in People's domain tab.
On enabled domains, mailboxes are created at the same time on dimail (and a confirmation email is sent to the secondary email).
On pending/failed domains, mailboxes are only created locally with "pending" status and are sent to dimail upon domain's activation.
On pending/failed domains, mailboxes are only created locally with "pending" status and are sent to dimail upon domain's (re)activation.
On disabled domains, mailboxes creation is not allowed.
+27 -23
View File
@@ -17,48 +17,52 @@ Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standar
This script will ask you for the version you want to release and the kind of release (patch, minor, major). It will then:
1. Create a new branch named: `release/4.18.1`.
- Create a new branch named: `release/4.18.1`.
2. Bump the release number for backend and frontend project.
- Bump the release number for backend and frontend project.
3. Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
- Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
- Commit your changes with the following format: 🔖 release emoji, type of release (patch/minor/patch) and release version:
```text
🔖(minor) release version 4.18.1
```
3. Open a pull request ask you to wait for an approval from your peers and merge it.
- Triggers Crowdin translation action and open related PR
4. Ask you to tag and push your commit:
- Open release PR. Wait for an approval from your peers and merge it.
> [!NOTE]
> It also open the PR for pre-prod deployment, see following section.
3. Following release script instructions,
- merge translation and release pulls requests
- tag and push your commit:
```bash
git tag v4.18.1 && git push origin tag v4.18.1
```
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
This triggers the CI building new Docker images. You can ensure images were successfully built on Docker Hub [here for back-end](https://hub.docker.com/r/lasuite/people-backend/tags) and [here for front-end](https://hub.docker.com/r/lasuite/people-frontend/tags).
5. Ensure the new [backend](https://hub.docker.com/r/lasuite/people-backend/tags) and [frontend](https://hub.docker.com/r/lasuite/people-frontend/tags) image tags are on Docker Hub.
6. Create a PR on the [lasuite-deploiement](https://github.com/numerique-gouv/lasuite-deploiement) repository to bump the preprod version.
# Deploying
7. The release is now done!
## Staging
# Deploying
The `staging` platform is deployed automatically with every update of the `main` branch.
> [!TIP]
> The `staging` platform is deployed automatically with every update of the `main` branch.
## Pre-prod and production
Making a new release doesn't publish it automatically in production.
If you used the release script and had permission to push on [lasuite-deploiement](https://github.com/numerique-gouv/lasuite-deploiement), a deployment branch has been created. You can skip step 1.
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
Otherwise, for manual preprod and for production deployments :
1. Bump tag version for both front-end and back-end images in the `.gotmpl` file located in `manifests/<your-product>/env.d/<your-environment>/`,
2. Add optional new secrets and variables, if applicable
3. Create a pull request
4. Submit to approval and merge PR
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
To publish the release you just made:
```bash
git tag --force production v4.18.1
git push --force origin production
```
The release is now done! 🎉
+4
View File
@@ -3,6 +3,7 @@ DJANGO_ALLOWED_HOSTS=*
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=people.settings
DJANGO_SUPERUSER_PASSWORD=admin
DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:3000
# Python
PYTHONPATH=/app
@@ -65,3 +66,6 @@ IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
MbyqKyC6DAzv4jEEhHaN7oY=
-----END PRIVATE KEY-----
"
# INTEROP
MAIL_PROVISIONING_API_CREDENTIALS="bGFfcmVnaWU6cGFzc3dvcmQ=" # Dev and test key for dimail interop
+4 -2
View File
@@ -2,7 +2,9 @@
SUSTAINED_THROTTLE_RATES="200/hour"
BURST_THROTTLE_RATES="200/minute"
OAUTH2_PROVIDER_OIDC_ENABLED = True
OAUTH2_PROVIDER_VALIDATOR_CLASS: "mailbox_oauth2.validators.ProConnectValidator"
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD="siret"
OAUTH2_PROVIDER_OIDC_ENABLED=True
OAUTH2_PROVIDER_VALIDATOR_CLASS="mailbox_oauth2.validators.ProConnectValidator"
INSTALLED_PLUGINS=plugins.la_suite
-1
View File
@@ -1,2 +1 @@
INSTALLED_PLUGINS=plugins.la_suite
DNS_PROVISIONING_TARGET_ZONE=test.collectivite.fr
+149
View File
@@ -0,0 +1,149 @@
#!/bin/bash
# Script to add a new client to Keycloak using the kcadm.sh CLI
# Usage: ./add-keycloak-client.sh [client_id] [client_secret]
# Default values
CLIENT_ID=${1:-"some-client-id"}
CLIENT_SECRET=${2:-"ThisIsAnExampleKeyForDevPurposeOnly"}
KEYCLOAK_URL=${KEYCLOAK_URL:-"http://keycloak:8080"}
KEYCLOAK_ADMIN=${KEYCLOAK_ADMIN:-"admin"}
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD:-"admin"}
REALM=${REALM:-"people"}
# Check for kcadm.sh in common locations
KCADM_LOCATIONS=(
"/opt/keycloak/bin/kcadm.sh"
"/opt/jboss/keycloak/bin/kcadm.sh"
"/usr/local/bin/kcadm.sh"
"./bin/kcadm.sh"
"$(which kcadm.sh 2>/dev/null)"
)
KCADM=""
for loc in "${KCADM_LOCATIONS[@]}"; do
if [ -x "$loc" ]; then
KCADM="$loc"
break
fi
done
if [ -z "$KCADM" ]; then
echo "Error: kcadm.sh not found. Please specify its location manually."
echo "You can set the KCADM environment variable to the path of kcadm.sh"
exit 1
fi
echo "Using Keycloak Admin CLI at: $KCADM"
echo "Logging in to Keycloak at $KEYCLOAK_URL..."
# Login to Keycloak
$KCADM config credentials \
--server $KEYCLOAK_URL \
--realm master \
--user $KEYCLOAK_ADMIN \
--password $KEYCLOAK_ADMIN_PASSWORD
if [ $? -ne 0 ]; then
echo "Failed to login to Keycloak. Please check your credentials and try again."
exit 1
fi
echo "Successfully logged in to Keycloak."
echo "Creating new client '$CLIENT_ID' in realm '$REALM'..."
# Create a temporary JSON file with client configuration
CLIENT_JSON=$(mktemp)
cat > "$CLIENT_JSON" << EOF
{
"clientId": "$CLIENT_ID",
"name": "",
"description": "",
"rootUrl": "",
"adminUrl": "",
"baseUrl": "",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"secret": "$CLIENT_SECRET",
"redirectUris": [
"http://localhost:8070/*",
"http://localhost:8071/*",
"http://localhost:3200/*",
"http://localhost:8088/*",
"http://localhost:3000/*"
],
"webOrigins": [
"http://localhost:3200",
"http://localhost:8088",
"http://localhost:8070",
"http://localhost:3000"
],
"notBefore": 0,
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"publicClient": false,
"frontchannelLogout": true,
"protocol": "openid-connect",
"attributes": {
"access.token.lifespan": "-1",
"client.secret.creation.time": "$(date +%s)",
"user.info.response.signature.alg": "RS256",
"post.logout.redirect.uris": "http://localhost:8070/*##http://localhost:3200/*##http://localhost:3000/*",
"oauth2.device.authorization.grant.enabled": "false",
"use.jwks.url": "false",
"backchannel.logout.revoke.offline.tokens": "false",
"use.refresh.tokens": "true",
"tls-client-certificate-bound-access-tokens": "false",
"oidc.ciba.grant.enabled": "false",
"backchannel.logout.session.required": "true",
"client_credentials.use_refresh_token": "false",
"acr.loa.map": "{}",
"require.pushed.authorization.requests": "false",
"display.on.consent.screen": "false",
"client.session.idle.timeout": "-1",
"token.response.type.bearer.lower-case": "false"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [
"web-origins",
"acr",
"roles",
"profile",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
}
EOF
# Create the client using kcadm.sh
$KCADM create clients -r "$REALM" -f "$CLIENT_JSON"
if [ $? -ne 0 ]; then
echo "Failed to create client. Check the error message above."
rm "$CLIENT_JSON"
exit 1
fi
echo "✅ Client '$CLIENT_ID' created successfully!"
echo " Client ID: $CLIENT_ID"
echo " Client Secret: $CLIENT_SECRET"
# Clean up temporary file
rm "$CLIENT_JSON"
# Display the created client
echo "Client details:"
$KCADM get clients -r "$REALM" --query "clientId=$CLIENT_ID"
+7 -1
View File
@@ -586,7 +586,13 @@ class ConfigView(views.APIView):
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = ["LANGUAGES", "FEATURES", "RELEASE", "COMMIT"]
array_settings = [
"LANGUAGES",
"FEATURES",
"RELEASE",
"COMMIT",
"CRISP_WEBSITE_ID",
]
dict_settings = {}
for setting in array_settings:
dict_settings[setting] = getattr(settings, setting)
@@ -0,0 +1 @@
"""SCIM compliant API for resource server"""
@@ -0,0 +1,16 @@
"""Exceptions for SCIM API."""
from django.conf import settings
from core.api.resource_server.scim.response import ScimJsonResponse
def scim_exception_handler(exc, _context):
"""Handle SCIM exceptions and return them in the correct format."""
data = {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": str(exc.status_code),
"detail": str(exc.detail) if settings.DEBUG else "",
}
return ScimJsonResponse(data, status=exc.status_code)
@@ -0,0 +1,16 @@
"""SCIM API response classes."""
from rest_framework.response import Response
class ScimJsonResponse(Response):
"""
Custom JSON response class for SCIM API.
This class sets the content type to "application/json+scim" for SCIM
responses.
"""
def __init__(self, *args, **kwargs):
"""JSON response with enforced SCIM content type."""
super().__init__(*args, content_type="application/json+scim", **kwargs)
@@ -0,0 +1,90 @@
"""SCIM serializers for resource server API."""
from django.urls import reverse
from rest_framework import serializers
from core import models
class SCIMUserSerializer(serializers.ModelSerializer):
"""Serialize users in SCIM format."""
schemas = serializers.SerializerMethodField()
userName = serializers.CharField(source="sub")
displayName = serializers.CharField(source="name")
emails = serializers.SerializerMethodField()
meta = serializers.SerializerMethodField()
groups = serializers.SerializerMethodField()
active = serializers.BooleanField(source="is_active")
class Meta:
model = models.User
fields = [
"id",
"schemas",
"active",
"userName",
"displayName",
"emails",
"groups",
"meta",
]
read_only_fields = [
"id",
"schemas",
"active",
"userName",
"displayName",
"emails",
"groups",
"meta",
]
def get_schemas(self, _obj):
"""Return the SCIM schemas for the user."""
return ["urn:ietf:params:scim:schemas:core:2.0:User"]
def get_emails(self, obj):
"""Return the user's email as a list of email objects."""
if obj.email:
return [
{
"value": obj.email,
"primary": True,
"type": "work",
}
]
return []
def get_groups(self, obj):
"""
Return the groups the user belongs to.
WARNING: you need to prefetch the team accesses in the
viewset to avoid N+1 queries.
"""
return [
{
"value": str(team_access.team.external_id),
"display": team_access.team.name,
"type": "direct",
}
for team_access in obj.accesses.all()
]
def get_meta(self, obj):
"""Return metadata about the user."""
request = self.context.get("request")
location = (
f"{request.build_absolute_uri('/').rstrip('/')}{reverse('scim-me-list')}"
if request
else None
)
return {
"resourceType": "User",
"created": obj.created_at.isoformat(),
"lastModified": obj.updated_at.isoformat(),
"location": location,
}
@@ -0,0 +1,62 @@
"""Resource server SCIM API endpoints"""
from django.contrib.auth import get_user_model
from django.db.models import Prefetch, Q
from lasuite.oidc_resource_server.mixins import ResourceServerMixin
from rest_framework import (
viewsets,
)
from core.api import permissions
from core.models import TeamAccess
from . import serializers
from .exceptions import scim_exception_handler
from .response import ScimJsonResponse
User = get_user_model()
class MeViewSet(ResourceServerMixin, viewsets.ViewSet):
"""
SCIM-compliant ViewSet for the /Me endpoint.
This endpoint provides information about the currently authenticated user
in SCIM (System for Cross-domain Identity Management) format.
Features:
- Returns user details in SCIM format.
- Includes the user's teams, restricted to the audience.
Limitations:
- Does not currently support managing Team hierarchies.
Endpoint:
GET /resource-server/v1.0/scim/Me/
Retrieves the authenticated user's details and associated teams.
"""
permission_classes = [permissions.IsAuthenticated]
serializer_class = serializers.SCIMUserSerializer
def get_exception_handler(self):
"""Override the default exception handler to use SCIM-specific handling."""
return scim_exception_handler
def list(self, request, *args, **kwargs):
"""Return the current user's details in SCIM format."""
service_provider_audience = self._get_service_provider_audience()
user = User.objects.prefetch_related(
Prefetch(
"accesses",
queryset=TeamAccess.objects.select_related("team").filter(
Q(team__service_providers__audience_id=service_provider_audience)
| Q(team__is_visible_all_services=True)
),
)
).get(pk=request.user.pk)
serializer = self.serializer_class(user, context={"request": request})
return ScimJsonResponse(serializer.data)
@@ -17,8 +17,6 @@ from core.models import (
AccountService,
Contact,
Organization,
OrganizationAccess,
OrganizationRoleChoices,
)
logger = logging.getLogger(__name__)
@@ -131,15 +129,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
user = super().create_user(claims | {"organization": organization})
if organization_created:
# Warning: we may remove this behavior in the near future when we
# add a feature to claim the organization ownership.
OrganizationAccess.objects.create(
organization=organization,
user=user,
role=OrganizationRoleChoices.ADMIN,
)
# Initiate the user's profile
Contact.objects.create(
owner=user,
+7
View File
@@ -24,3 +24,10 @@ class WebhookStatusChoices(models.TextChoices):
FAILURE = "failure", _("Failure")
PENDING = "pending", _("Pending")
SUCCESS = "success", _("Success")
class WebhookProtocolChoices(models.TextChoices):
"""Defines the possible protocols of webhook."""
SCIM = "scim"
MATRIX = "matrix"
-1
View File
@@ -1,4 +1,3 @@
# ruff: noqa: S311
"""
Core application factories
"""
@@ -0,0 +1,40 @@
# Generated by Django 5.2 on 2025-06-12 09:58
import uuid
from django.conf import settings
from django.db import migrations, models
def gen_team_uuid(apps, schema_editor):
Team = apps.get_model("core", "Team")
for row in Team.objects.all():
row.external_id = uuid.uuid4()
# Don't need to batch update here, as the table is likely small or empty
row.save(update_fields=["external_id"])
class Migration(migrations.Migration):
dependencies = [
('core', '0015_alter_accountservice_api_key'),
]
operations = [
migrations.AddField(
model_name='team',
name='external_id',
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Team external UUID for synchronization with external systems', unique=False, verbose_name='external_id'),
),
migrations.RunPython(gen_team_uuid, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name='team',
name='external_id',
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Team external UUID for synchronization with external systems', unique=True, verbose_name='external_id'),
),
#
migrations.AlterField(
model_name='team',
name='users',
field=models.ManyToManyField(related_name='teams', through='core.TeamAccess', through_fields=('team', 'user'), to=settings.AUTH_USER_MODEL),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2025-06-17 14:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0016_team_external_id_alter_team_users'),
]
operations = [
migrations.AddField(
model_name='teamwebhook',
name='protocol',
field=models.CharField(choices=[('scim', 'Scim'), ('matrix', 'Matrix')], default='scim'),
),
]
+31 -16
View File
@@ -21,7 +21,7 @@ from django.contrib.postgres.fields import ArrayField
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.db import models
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import gettext, override
@@ -31,9 +31,9 @@ import jsonschema
from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager
from core.enums import WebhookStatusChoices
from core.enums import WebhookProtocolChoices, WebhookStatusChoices
from core.plugins.registry import registry as plugin_hooks_registry
from core.utils.webhooks import scim_synchronizer
from core.utils.webhooks import webhooks_synchronizer
from core.validators import get_field_validators_from_setting
logger = getLogger(__name__)
@@ -742,6 +742,11 @@ class Team(MP_Node, BaseModel):
can see it.
When a team is created from a Service Provider this one is automatically set in the
Team `service_providers`.
The team `external_id` is used to synchronize the team with external systems, this
is the equivalent of the User `sub` field but for teams (note: `sub` is highly related
to the OIDC standard, while `external_id` is not). The `external_id` is NOT the same as
the `externalId` field in the SCIM standard when importing teams from SCIM.
"""
# Allow up to 80 nested teams with 62^5 (916_132_832) root nodes
@@ -752,6 +757,14 @@ class Team(MP_Node, BaseModel):
name = models.CharField(max_length=100)
external_id = models.UUIDField(
verbose_name=_("external_id"),
help_text=_("Team external UUID for synchronization with external systems"),
unique=True,
default=uuid.uuid4,
editable=False,
)
users = models.ManyToManyField(
User,
through="TeamAccess",
@@ -851,16 +864,12 @@ class TeamAccess(BaseModel):
Override save function to fire webhooks on any addition or update
to a team access.
"""
if self._state.adding:
if self._state.adding and self.team.webhooks.exists():
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
with transaction.atomic():
instance = super().save(*args, **kwargs)
scim_synchronizer.add_user_to_group(self.team, self.user)
else:
instance = super().save(*args, **kwargs)
# try to synchronize all webhooks
webhooks_synchronizer.add_user_to_group(self.team, self.user)
return instance
return super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""
@@ -868,11 +877,12 @@ class TeamAccess(BaseModel):
Don't allow deleting a team access until it is successfully synchronized with all
its webhooks.
"""
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
with transaction.atomic():
arguments = self.team, self.user
super().delete(*args, **kwargs)
scim_synchronizer.remove_user_from_group(*arguments)
if webhooks := self.team.webhooks.all():
webhooks.update(status=WebhookStatusChoices.PENDING)
# try to synchronize all webhooks
webhooks_synchronizer.remove_user_from_group(self.team, self.user)
super().delete(*args, **kwargs)
def get_abilities(self, user):
"""
@@ -930,6 +940,11 @@ class TeamWebhook(BaseModel):
team = models.ForeignKey(Team, related_name="webhooks", on_delete=models.CASCADE)
url = models.URLField(_("url"))
secret = models.CharField(_("secret"), max_length=255, null=True, blank=True)
protocol = models.CharField(
max_length=None,
default=WebhookProtocolChoices.SCIM,
choices=WebhookProtocolChoices.choices,
)
status = models.CharField(
max_length=10,
default=WebhookStatusChoices.PENDING,
+2 -1
View File
@@ -18,6 +18,7 @@ class BasePluginAppConfigMixIn:
Initialize the hooks registry when the application is ready.
This is called by Django when the application is loaded.
"""
from .registry import registry # pylint: disable=import-outside-toplevel
# pylint: disable=import-outside-toplevel
from .registry import registry # noqa: PLC0415
registry.register_app(self.name) # pylint: disable=no-member
@@ -100,7 +100,7 @@ def test_authentication_getter_existing_user_change_fields(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
with django_assert_num_queries(4):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -160,7 +160,8 @@ def test_authentication_getter_existing_user_via_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(3): # user by email + user by sub + update sub
with django_assert_num_queries(5):
# 5 = user by email + user by sub + update sub + 2 from django-lasuite
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -390,6 +391,8 @@ def test_authentication_getter_new_user_with_registration_id_new_organization(
assert user.organization.domain_list == expected_domain_list
assert user.organization.registration_id_list == expected_registration_id_list
assert models.OrganizationAccess.objects.filter(user=user).exists() is False
def test_authentication_getter_existing_user_via_email_update_organization(
django_assert_num_queries, monkeypatch
+1
View File
@@ -0,0 +1 @@
"""Test fixtures."""
+123
View File
@@ -0,0 +1,123 @@
"""Define here some fake responses from Matrix API, useful to mock responses in tests."""
from rest_framework import status
# SEARCH
def mock_search_empty():
"""Mock response when no Matrix user has been found through search."""
return {
"message": {"limited": "false", "results": []},
"status_code": status.HTTP_200_OK,
}
def mock_search_successful(user):
"""Mock response when exactly one user has been found through search."""
return {
"message": {
"limited": "false",
"results": [
{
"user_id": f"@{user.email.replace('@', '-')}:user_server.com",
"display_name": f"@{user.name} [Fake]",
"avatar_url": "null",
},
],
},
"status_code": status.HTTP_200_OK,
}
def mock_search_successful_multiple(user):
"""Mock response when more than one user has been found through search."""
return {
"message": {
"limited": "false",
"results": [
{
"user_id": f"@{user.email.replace('@', '-')}:user_server1.com",
"display_name": f"@{user.name} [Fake]",
"avatar_url": "null",
},
{
"user_id": f"@{user.email.replace('@', '-')}:user_server2.com",
"display_name": f"@{user.name} [Other Fake]",
"avatar_url": "null",
},
],
},
"status_code": status.HTTP_200_OK,
}
# JOIN ROOMS
def mock_join_room_successful(room_id):
"""Mock response when succesfully joining room. Same response if already in room."""
return {"message": {"room_id": str(room_id)}, "status_code": status.HTTP_200_OK}
def mock_join_room_no_known_servers():
"""Mock response when room to join cannot be found."""
return {
"message": {"errcode": "M_UNKNOWN", "error": "No known servers"},
"status_code": status.HTTP_404_NOT_FOUND,
}
def mock_join_room_forbidden():
"""Mock response when room cannot be joined."""
return {
"message": {
"errcode": "M_FORBIDDEN",
"error": "You do not belong to any of the required rooms/spaces to join this room.",
},
"status_code": status.HTTP_403_FORBIDDEN,
}
# INVITE USER
def mock_invite_successful():
"""Mock response when invite request was succesful. Does not check the user exists."""
return {"message": {}, "status_code": status.HTTP_200_OK}
def mock_invite_user_already_in_room(user):
"""Mock response when invitation forbidden for People user."""
return {
"message": {
"errcode": "M_FORBIDDEN",
"error": f"{user.email.replace('@', '-')}:home_server.fr is already in the room.",
},
"status_code": status.HTTP_403_FORBIDDEN,
}
# KICK USER
def mock_kick_successful():
"""Mock response when succesfully joining room."""
return {"message": {}, "status_code": status.HTTP_200_OK}
def mock_kick_user_forbidden(user):
"""Mock response when kick request is forbidden (i.e. wrong permission or user is room admin."""
return {
"message": {
"errcode": "M_FORBIDDEN",
"error": f"You cannot kick user @{user.email.replace('@', '-')}.",
},
"status_code": status.HTTP_403_FORBIDDEN,
}
def mock_kick_user_not_in_room():
"""
Mock response when trying to kick a user who isn't in the room. Don't check the user exists.
"""
return {
"message": {
"errcode": "M_FORBIDDEN",
"error": "The target user is not in the room",
},
"status_code": status.HTTP_403_FORBIDDEN,
}
@@ -1,7 +1,6 @@
"""Tests for the authentication process of the resource server."""
import base64
import json
import pytest
import responses
@@ -9,7 +8,7 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from joserfc import jwe as jose_jwe
from joserfc import jwt as jose_jwt
from joserfc.rfc7518.rsa_key import RSAKey
from joserfc.jwk import RSAKey
from jwt.utils import to_base64url_uint
from lasuite.oidc_resource_server.authentication import (
ResourceServerAuthentication,
@@ -84,8 +83,7 @@ def test_resource_server_authentication_class(client, settings):
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
responses.post(
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
@@ -181,23 +179,20 @@ def test_jwt_resource_server_authentication_class( # pylint: disable=unused-arg
# Mock the JWKS endpoint
public_numbers = private_key.public_key().public_numbers()
responses.add(
responses.GET,
responses.get(
settings.OIDC_OP_JWKS_ENDPOINT,
body=json.dumps(
{
"keys": [
{
"kty": settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
"alg": settings.OIDC_RS_SIGNING_ALGO,
"use": "sig",
"kid": "1234567890",
"n": to_base64url_uint(public_numbers.n).decode("ascii"),
"e": to_base64url_uint(public_numbers.e).decode("ascii"),
}
]
}
),
json={
"keys": [
{
"kty": settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
"alg": settings.OIDC_RS_SIGNING_ALGO,
"use": "sig",
"kid": "1234567890",
"n": to_base64url_uint(public_numbers.n).decode("ascii"),
"e": to_base64url_uint(public_numbers.e).decode("ascii"),
}
]
},
)
def encrypt_jwt(json_data):
@@ -225,8 +220,7 @@ def test_jwt_resource_server_authentication_class( # pylint: disable=unused-arg
],
)
responses.add(
responses.POST,
responses.post(
"https://oidc.example.com/introspect",
body=encrypt_jwt(
{
@@ -0,0 +1 @@
"""Tests for the resource server SCIM API endpoints."""
@@ -0,0 +1,186 @@
"""
Tests for the SCIM Me API endpoint in People's core app
"""
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_401_UNAUTHORIZED,
HTTP_405_METHOD_NOT_ALLOWED,
)
from core import factories
from core.models import RoleChoices
pytestmark = pytest.mark.django_db
def test_api_me_anonymous(client):
"""Anonymous users should not be allowed to access the Me endpoint."""
response = client.get("/resource-server/v1.0/scim/Me/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.headers["Content-Type"] == "application/json+scim"
# Check the full response with the expected structure
assert response.json() == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "401",
"detail": "",
}
def test_api_me_authenticated(
client, force_login_via_resource_server, django_assert_num_queries
):
"""
Authenticated users should be able to access their own information
in SCIM format.
"""
user = factories.UserFactory(name="Test User", email="test@example.com")
service_provider = factories.ServiceProviderFactory()
# Authenticate using the resource server, ie via the Authorization header
with force_login_via_resource_server(client, user, service_provider.audience_id):
with django_assert_num_queries(2):
response = client.get(
"/resource-server/v1.0/scim/Me/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_200_OK
assert response.headers["Content-Type"] == "application/json+scim"
# Check the full response with the expected structure
assert response.json() == {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": str(user.pk),
"active": True,
"userName": user.sub,
"displayName": user.name,
"emails": [
{
"value": user.email,
"primary": True,
"type": "work",
}
],
"groups": [],
"meta": {
"resourceType": "User",
"created": user.created_at.isoformat(),
"lastModified": user.updated_at.isoformat(),
"location": "http://testserver/resource-server/v1.0/scim/Me/",
},
}
def test_api_me_authenticated_with_team_access(
client, force_login_via_resource_server, django_assert_num_queries
):
"""
Authenticated users with TeamAccess should see their team information
in SCIM format, but only for teams visible to the service provider.
"""
user = factories.UserFactory(name="Test User", email="test@example.com")
service_provider = factories.ServiceProviderFactory()
# Create teams with different visibility settings
team_visible = factories.TeamFactory(name="Visible Team")
team_visible.service_providers.add(service_provider)
team_all_services = factories.TeamFactory(
name="All Services Team", is_visible_all_services=True
)
team_not_visible = factories.TeamFactory(name="Not Visible Team")
# This team is not associated with the service provider
# Add user to all teams
factories.TeamAccessFactory(user=user, team=team_visible, role=RoleChoices.MEMBER)
factories.TeamAccessFactory(
user=user, team=team_all_services, role=RoleChoices.ADMIN
)
factories.TeamAccessFactory(
user=user, team=team_not_visible, role=RoleChoices.OWNER
)
# Authenticate using the resource server, ie via the Authorization header
with force_login_via_resource_server(client, user, service_provider.audience_id):
with django_assert_num_queries(
2
): # User + TeamAccess (with select_related teams)
response = client.get(
"/resource-server/v1.0/scim/Me/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_200_OK
assert response.headers["Content-Type"] == "application/json+scim"
# Check the full response with the expected structure
assert response.json() == {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": str(user.pk),
"active": True,
"userName": user.sub,
"displayName": user.name,
"emails": [
{
"value": user.email,
"primary": True,
"type": "work",
}
],
"groups": [
{
"value": str(team_visible.external_id),
"display": team_visible.name,
"type": "direct",
},
{
"value": str(team_all_services.external_id),
"display": team_all_services.name,
"type": "direct",
},
],
"meta": {
"resourceType": "User",
"created": user.created_at.isoformat(),
"lastModified": user.updated_at.isoformat(),
"location": "http://testserver/resource-server/v1.0/scim/Me/",
},
}
@pytest.mark.parametrize(
"http_method",
["post", "put", "patch", "delete"],
ids=["POST", "PUT", "PATCH", "DELETE"],
)
def test_api_me_method_not_allowed(
client, force_login_via_resource_server, http_method
):
"""Test that methods other than GET are not allowed for the Me endpoint."""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
# Authenticate using the resource server, ie via the Authorization header
with force_login_via_resource_server(client, user, service_provider.audience_id):
client_method = getattr(client, http_method)
response = client_method(
"/resource-server/v1.0/scim/Me/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.headers["Content-Type"] == "application/json+scim"
# Check the full response with the expected structure
assert response.json() == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": str(HTTP_405_METHOD_NOT_ALLOWED),
"detail": "",
}
@@ -3,14 +3,17 @@ Test for team accesses API endpoints in People's core app : create
"""
import json
import logging
import random
import re
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories, models
from core import enums, factories, models
from core.tests.fixtures import matrix
pytestmark = pytest.mark.django_db
@@ -171,14 +174,17 @@ def test_api_team_accesses_create_authenticated_owner():
}
def test_api_team_accesses_create_webhook():
def test_api_team_accesses_create__with_scim_webhook():
"""
When the team has a webhook, creating a team access should fire a call.
If a team has a SCIM webhook, creating a team access should fire a call
with the expected payload.
"""
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "owner")])
webhook = factories.TeamWebhookFactory(team=team)
webhook = factories.TeamWebhookFactory(
team=team, protocol=enums.WebhookProtocolChoices.SCIM
)
role = random.choice([role[0] for role in models.RoleChoices.choices])
@@ -226,3 +232,207 @@ def test_api_team_accesses_create_webhook():
}
],
}
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
@responses.activate
def test_api_team_accesses_create__with_matrix_webhook():
"""
If a team has a Matrix webhook, creating a team access should fire a call
with the expected payload.
"""
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "owner")])
webhook = factories.TeamWebhookFactory(
team=team,
url="https://server.fr/#/room/room_id:room_server.fr",
secret="some-secret-you-should-not-store-on-a-postit",
protocol=enums.WebhookProtocolChoices.MATRIX,
)
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
# Mock successful responses by matrix server
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
content_type="application/json",
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(other_user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
status=matrix.mock_invite_successful()["status_code"],
content_type="application/json",
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert len(responses.calls) == 3
assert (
responses.calls[0].request.url
== "https://room_server.fr/_matrix/client/v3/rooms/room_id:room_server.fr/join"
)
# Payload sent to matrix server
assert webhook.secret in responses.calls[0].request.headers["Authorization"]
assert json.loads(responses.calls[2].request.body) == {
"user_id": f"@{other_user.email.replace('@', '-')}:user_server.com",
"reason": f"User added to team {webhook.team} on People",
}
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
@responses.activate
def test_api_team_accesses_create__multiple_webhooks_success(caplog):
"""
When the team has multiple webhooks, creating a team access should fire all the expected calls.
If all responses are positive, proceeds to add the user to the team.
"""
caplog.set_level(logging.INFO)
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "owner")])
webhook_scim = factories.TeamWebhookFactory(
team=team, protocol=enums.WebhookProtocolChoices.SCIM, secret="wesh"
)
webhook_matrix = factories.TeamWebhookFactory(
team=team,
url="https://www.webhookserver.fr/#/room/room_id:home_server.fr/",
protocol=enums.WebhookProtocolChoices.MATRIX,
secret="yo",
)
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
# Ensure successful response by scim provider using "responses":
responses.patch(
re.compile(r".*/Groups/.*"),
body="{}",
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
status=matrix.mock_invite_successful()["status_code"],
content_type="application/json",
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
# Logger
log_messages = [msg.message for msg in caplog.records]
for webhook in [webhook_scim, webhook_matrix]:
assert (
f"add_user_to_group synchronization succeeded with {webhook.url}"
in log_messages
)
# Status
for webhook in [webhook_scim, webhook_matrix]:
webhook.refresh_from_db()
assert webhook.status == "success"
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
@responses.activate
def test_api_team_accesses_create__multiple_webhooks_failure(caplog):
"""When a webhook fails, user should still be added to the team."""
caplog.set_level(logging.INFO)
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "owner")])
webhook_scim = factories.TeamWebhookFactory(
team=team, protocol=enums.WebhookProtocolChoices.SCIM, secret="wesh"
)
webhook_matrix = factories.TeamWebhookFactory(
team=team,
url="https://www.webhookserver.fr/#/room/room_id:home_server/",
protocol=enums.WebhookProtocolChoices.MATRIX,
secret="secret",
)
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
responses.patch(
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
)
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_forbidden()["message"]),
status=str(matrix.mock_join_room_forbidden()["status_code"]),
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
# Logger
log_messages = [msg.message for msg in caplog.records]
assert (
f"add_user_to_group synchronization succeeded with {webhook_scim.url}"
in log_messages
)
assert (
f"add_user_to_group synchronization failed with {webhook_matrix.url}"
in log_messages
)
# Status
webhook_scim.status = "success"
webhook_matrix.status = "failure"
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
@@ -19,6 +19,7 @@ def test_api_config_anonymous():
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"CRISP_WEBSITE_ID": None,
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
"COMMIT": "NA",
"FEATURES": {
@@ -42,6 +43,7 @@ def test_api_config_authenticated():
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"CRISP_WEBSITE_ID": None,
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
"COMMIT": "NA",
"FEATURES": {
@@ -0,0 +1,367 @@
"""Test Team synchronization webhooks : focus on matrix client."""
import json
import logging
import re
from django.test import override_settings
import pytest
import responses
from rest_framework import status
from core import factories
from core.enums import WebhookProtocolChoices
from core.tests.fixtures import matrix
from core.utils.matrix import MatrixAPIClient
from core.utils.webhooks import webhooks_synchronizer
pytestmark = pytest.mark.django_db
## SEARCH
@responses.activate
def test_matrix_webhook__search_user_unknown(caplog):
"""When searching for a user in the Matrix federation but cannot find any,
we invite a (future ?) user using user's email and room's server."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:room_server.au",
secret="secret-access-token",
)
client = MatrixAPIClient()
# Mock successful responses
responses.post(
re.compile(r".*/search"),
json=matrix.mock_search_empty()["message"],
status=status.HTTP_200_OK,
content_type="application/json",
)
response = client.get_user_id(user=user, webhook=webhook)
assert response == f"@{user.email.replace('@', '-')}:room_server.au"
@responses.activate
def test_matrix_webhook__search_multiple_ids(caplog):
"""When searching for a user in Matrix federation,
if user directory returns multiple ids, invite the first one."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:room_server.au",
secret="secret-access-token",
)
client = MatrixAPIClient()
# Mock successful responses
responses.post(
re.compile(r".*/search"),
json=matrix.mock_search_successful_multiple(user)["message"],
status=status.HTTP_200_OK,
content_type="application/json",
)
response = client.get_user_id(user=user, webhook=webhook)
assert response == f"@{user.email.replace('@', '-')}:user_server1.com"
## INVITE
@responses.activate
def test_matrix_webhook__invite_user_to_room_forbidden(caplog):
"""Cannot invite when Matrix returns forbidden. This might mean the user is an admin."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
# Mock successful responses
error = matrix.mock_kick_user_forbidden(user)
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/search"),
json=matrix.mock_search_successful(user)["message"],
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(error["message"]),
status=error["status_code"],
)
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
@responses.activate
def test_matrix_webhook__invite_user_to_room_already_in_room(caplog):
"""If user is already in room, webhooks should be set to success."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
# Mock successful responses
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
)
responses.post(
re.compile(r".*/search"),
json=matrix.mock_search_successful(user)["message"],
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_user_already_in_room(user)["message"]),
status=matrix.mock_invite_user_already_in_room(user)["status_code"],
)
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
# Logger
log_messages = [msg.message for msg in caplog.records]
expected_messages = (
f"add_user_to_group synchronization succeeded with {webhook.url}"
)
assert expected_messages in log_messages
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
@responses.activate
def test_matrix_webhook__invite_user_to_room_success(caplog):
"""The user passed to the function should get invited."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
# Mock successful responses
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
)
responses.post(
re.compile(r".*/search"),
json=matrix.mock_search_successful(user)["message"],
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
status=matrix.mock_invite_successful()["status_code"],
)
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
# Check headers
headers = responses.calls[0].request.headers
assert webhook.secret in headers["Authorization"]
# Check payloads sent to Matrix API
assert json.loads(responses.calls[2].request.body) == {
"user_id": f"@{user.email.replace('@', '-')}:user_server.com",
"reason": f"User added to team {webhook.team} on People",
}
# Logger
log_messages = [msg.message for msg in caplog.records]
expected_messages = (
f"add_user_to_group synchronization succeeded with {webhook.url}"
)
assert expected_messages in log_messages
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
@responses.activate
@override_settings(MATRIX_BOT_ACCESS_TOKEN="TCHAP_TOKEN")
def test_matrix_webhook__override_secret_for_tchap():
"""The user passed to the function should get invited."""
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.tchap.gouv.fr/#/room/room_id:home_server",
secret=None,
)
# Mock successful responses
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
)
responses.post(
re.compile(r".*/search"),
json=matrix.mock_search_successful(user)["message"],
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
status=matrix.mock_invite_successful()["status_code"],
)
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
# Check headers
headers = responses.calls[0].request.headers
assert "TCHAP_TOKEN" in headers["Authorization"]
# Check correctly inferred base url from room_id
assert "matrix.home_server" in responses.calls[0].request.url
## KICK
@responses.activate
def test_matrix_webhook__kick_user_from_room_not_in_room(caplog):
"""Webhook should report a success when user was already not in room."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
# Mock successful responses
responses.post(
re.compile(r".*/join"),
json=matrix.mock_join_room_successful,
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/search"),
json=matrix.mock_search_successful(user)["message"],
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/kick"),
json=matrix.mock_kick_user_not_in_room()["message"],
status=matrix.mock_kick_user_not_in_room()["status_code"],
)
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
# Logger
log_messages = [msg.message for msg in caplog.records]
assert (
f"remove_user_from_group synchronization succeeded with {webhook.url}"
in log_messages
)
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
@responses.activate
def test_matrix_webhook__kick_user_from_room_success(caplog):
"""The user passed to the function should get removed."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:room_server",
secret="secret-access-token",
)
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/search"),
json=matrix.mock_search_successful(user)["message"],
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/kick"),
body=str(matrix.mock_kick_successful),
status=status.HTTP_200_OK,
)
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
# Check payloads sent to Matrix API
assert json.loads(responses.calls[2].request.body) == {
"user_id": f"@{user.email.replace('@', '-')}:user_server.com",
"reason": f"User removed from team {webhook.team} on People",
}
# Logger
log_messages = [msg.message for msg in caplog.records]
expected_messages = (
f"remove_user_from_group synchronization succeeded with {webhook.url}"
)
assert expected_messages in log_messages
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
@responses.activate
def test_matrix_webhook__kick_user_from_room_forbidden(caplog):
"""Cannot kick an admin."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
# Mock successful responses
error = matrix.mock_kick_user_forbidden(user)
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/search"),
json=matrix.mock_search_successful(user)["message"],
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/kick"),
body=str(error["message"]),
status=error["status_code"],
)
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
# Logger
log_messages = [msg.message for msg in caplog.records]
assert (
f"remove_user_from_group synchronization failed with {webhook.url}"
in log_messages
)
# Status
webhook.refresh_from_db()
assert webhook.status == "failure"
@@ -1,4 +1,4 @@
"""Test Team synchronization webhooks."""
"""Test Team synchronization webhooks : focus on scim client"""
import json
import random
@@ -10,7 +10,7 @@ import pytest
import responses
from core import factories
from core.utils.webhooks import scim_synchronizer
from core.utils.webhooks import webhooks_synchronizer
pytestmark = pytest.mark.django_db
@@ -20,7 +20,7 @@ def test_utils_webhooks_add_user_to_group_no_webhooks():
access = factories.TeamAccessFactory()
with responses.RequestsMock():
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
assert len(responses.calls) == 0
@@ -42,7 +42,7 @@ def test_utils_webhooks_add_user_to_group_success(mock_info):
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
@@ -107,7 +107,7 @@ def test_utils_webhooks_remove_user_from_group_success(mock_info):
content_type="application/json",
)
scim_synchronizer.remove_user_from_group(access.team, access.user)
webhooks_synchronizer.remove_user_from_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
@@ -163,11 +163,11 @@ def test_utils_webhooks_add_user_to_group_failure(mock_error):
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=random.choice([404, 301, 302]),
status=404,
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
@@ -228,7 +228,7 @@ def test_utils_webhooks_add_user_to_group_retries(mock_info, mock_error):
rsps.add(rsps.PATCH, url, status=200, content_type="application/json"),
]
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
for i in range(4):
assert all_rsps[i].call_count == 1
@@ -285,7 +285,7 @@ def test_utils_synchronize_course_runs_max_retries_exceeded(mock_error):
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
assert rsp.call_count == 5
assert rsps.calls[0].request.url == webhook.url
@@ -339,7 +339,7 @@ def test_utils_webhooks_add_user_to_group_authorization():
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
assert rsps.calls[0].request.url == webhook.url
# Check headers
+152
View File
@@ -0,0 +1,152 @@
"""Matrix client for interoperability to synchronize with remote service providers."""
import logging
from django.conf import settings
import requests
from rest_framework.status import (
HTTP_200_OK,
)
from urllib3.util import Retry
logger = logging.getLogger(__name__)
adapter = requests.adapters.HTTPAdapter(
max_retries=Retry(
total=4,
backoff_factor=0.1,
status_forcelist=[500, 502],
allowed_methods=["POST"],
)
)
session = requests.Session()
session.mount("http://", adapter)
session.mount("https://", adapter)
class MatrixAPIClient:
"""A client to interact with Matrix API"""
def get_headers(self, webhook):
"""Build header dict from webhook object."""
headers = {"Content-Type": "application/json"}
if "tchap.gouv.fr" in webhook.url:
token = settings.MATRIX_BOT_ACCESS_TOKEN
elif webhook.secret:
token = webhook.secret
else:
raise ValueError("Please configure this webhook's secret access token.")
headers["Authorization"] = f"Bearer {token}"
return headers
def _get_room_url(self, webhook_url):
"""Returns room id from webhook url."""
room_id = webhook_url.split("/room/")[1]
base_url = room_id.split(":")[1]
if "tchap.gouv.fr" in webhook_url and "i.tchap.gouv.fr" not in webhook_url:
base_url = f"matrix.{base_url}"
return f"https://{base_url}/_matrix/client/v3/rooms/{room_id}"
def get_user_id(self, user, webhook):
"""Returns user id from email."""
if user.email is None:
raise ValueError("You must first set an email for the user.")
if settings.MATRIX_BASE_HOME_SERVER:
home_server = settings.MATRIX_BASE_HOME_SERVER
search = session.post(
f"{home_server}/_matrix/client/v3/user_directory/search",
json={"search_term": f"@{user.email.replace('@', '-')}"},
headers=self.get_headers(webhook),
verify=True,
timeout=3,
)
results = search.json()["results"]
if len(results) > 0:
return results[0]["user_id"]
# try and invite unknown user using room home server
room_home_server = webhook.url.split(":")[2]
return f"@{user.email.replace('@', '-')}:{room_home_server}"
def join_room(self, webhook):
"""Accept invitation to the room. As of today, it is a mandatory step
to make sure our account will be able to invite/remove users."""
return session.post(
f"{self._get_room_url(webhook.url)}/join",
json={},
headers=self.get_headers(webhook),
verify=True,
timeout=3,
)
def add_user_to_group(self, webhook, user):
"""Send request to invite an user to a room or space upon adding them to group.."""
join_response = self.join_room(webhook)
if join_response.status_code != HTTP_200_OK:
logger.error(
"Synchronization failed (cannot join room) %s",
webhook.url,
)
return join_response, False
logger.info(
"Succesfully joined room",
)
user_id = self.get_user_id(user, webhook)
response = session.post(
f"{self._get_room_url(webhook.url)}/invite",
json={
"user_id": user_id,
"reason": f"User added to team {webhook.team} on People",
},
headers=self.get_headers(webhook),
verify=True,
timeout=3,
)
# Checks for false negative
# (i.e. trying to invite user already in room)
webhook_succeeded = False
if (
response.status_code == HTTP_200_OK
or b"is already in the room." in response.content
):
webhook_succeeded = True
return response, webhook_succeeded
def remove_user_from_group(self, webhook, user):
"""Send request to kick an user from a room or space upon removing them from group."""
join_response = self.join_room(webhook)
if join_response.status_code != HTTP_200_OK:
logger.error(
"Synchronization failed (cannot join room) %s",
webhook.url,
)
return join_response, False
user_id = self.get_user_id(user, webhook)
response = session.post(
f"{self._get_room_url(webhook.url)}/kick",
json={
"user_id": user_id,
"reason": f"User removed from team {webhook.team} on People",
},
headers=self.get_headers(webhook),
verify=True,
timeout=3,
)
# Checks for false negative
# (i.e. trying to remove user who already left the room)
webhook_succeeded = False
if (
response.status_code == HTTP_200_OK
or b"The target user is not in the room" in response.content
):
webhook_succeeded = True
return response, webhook_succeeded
+6 -2
View File
@@ -39,7 +39,7 @@ class SCIMClient:
],
}
return session.patch(
response = session.patch(
webhook.url,
json=payload,
headers=webhook.get_headers(),
@@ -47,6 +47,8 @@ class SCIMClient:
timeout=3,
)
return response, response.ok
def remove_user_from_group(self, webhook, user):
"""Remove a user from a group by its ID or email."""
payload = {
@@ -61,10 +63,12 @@ class SCIMClient:
}
],
}
return session.patch(
response = session.patch(
webhook.url,
json=payload,
headers=webhook.get_headers(),
verify=False,
timeout=3,
)
return response, response.ok
+42 -25
View File
@@ -4,14 +4,16 @@ import logging
import requests
from core import enums
from core.enums import WebhookStatusChoices
from .matrix import MatrixAPIClient
from .scim import SCIMClient
logger = logging.getLogger(__name__)
class WebhookSCIMClient:
class WebhookClient:
"""Wraps the SCIM client to record call results on webhooks."""
def __getattr__(self, name):
@@ -26,31 +28,14 @@ class WebhookSCIMClient:
if not webhook.url:
continue
client = SCIMClient()
status = WebhookStatusChoices.FAILURE
try:
response = getattr(client, name)(webhook, user)
except requests.exceptions.RetryError as exc:
logger.error(
"%s synchronization failed due to max retries exceeded with url %s",
name,
webhook.url,
exc_info=exc,
)
except requests.exceptions.RequestException as exc:
logger.error(
"%s synchronization failed with %s.",
name,
webhook.url,
exc_info=exc,
)
else:
extra = {
"response": response.content,
}
response, webhook_succeeded = self._get_response_and_status(
name, webhook, user
)
if response is not None:
extra = {"response": response.content}
# pylint: disable=no-member
if response.status_code == requests.codes.ok:
if webhook_succeeded:
logger.info(
"%s synchronization succeeded with %s",
name,
@@ -71,5 +56,37 @@ class WebhookSCIMClient:
return wrapper
def _get_client(self, webhook):
"""Get client depending on the protocol."""
if webhook.protocol == enums.WebhookProtocolChoices.MATRIX:
return MatrixAPIClient()
scim_synchronizer = WebhookSCIMClient()
return SCIMClient()
def _get_response_and_status(self, name, webhook, user):
"""Get response from webhook outside party."""
client = self._get_client(webhook)
try:
response, webhook_succeeded = getattr(client, name)(webhook, user)
except requests.exceptions.RetryError as exc:
logger.error(
"%s synchronization failed due to max retries exceeded with url %s",
name,
webhook.url,
exc_info=exc,
)
except requests.exceptions.RequestException as exc:
logger.error(
"%s synchronization failed with %s.",
name,
webhook.url,
exc_info=exc,
)
else:
return response, webhook_succeeded
return None, False
webhooks_synchronizer = WebhookClient()
+1 -1
View File
@@ -4,5 +4,5 @@ NB_OBJECTS = {
"users": 1000,
"teams": 100,
"max_users_per_team": 100,
"domains": 20,
"domains": 10,
}
@@ -21,7 +21,11 @@ from core import models
from demo import defaults
from mailbox_manager import models as mailbox_models
from mailbox_manager.enums import MailboxStatusChoices, MailDomainStatusChoices
from mailbox_manager.enums import (
MailboxStatusChoices,
MailDomainRoleChoices,
MailDomainStatusChoices,
)
fake = Faker()
@@ -177,7 +181,7 @@ def create_oidc_people_idp_client_user():
)
def create_demo(stdout): # pylint: disable=too-many-locals
def create_demo(stdout): # pylint: disable=too-many-branches too-many-statements too-many-locals # noqa: PLR0912, PLR0915
"""
Create a database with demo data for developers to work in a realistic environment.
The code is engineered to create a huge number of objects fast.
@@ -267,10 +271,11 @@ def create_demo(stdout): # pylint: disable=too-many-locals
queue.push(
models.TeamAccess(team_id=team_id, user_id=user_id, role=role[0])
)
queue.flush()
with Timeit(stdout, "Creating domains"):
for i in range(defaults.NB_OBJECTS["domains"]):
name = f"{fake.domain_name()}-i{i:d}"
name = fake.domain_name().replace(".", f"-i{i:d}.")
queue.push(
mailbox_models.MailDomain(
@@ -298,6 +303,30 @@ def create_demo(stdout): # pylint: disable=too-many-locals
queue.flush()
with Timeit(stdout, "Creating mailboxes"):
domains_ids = list(
mailbox_models.MailDomain.objects.values_list("id", flat=True)
)
for domain_id in domains_ids:
for i in range(random.randint(1, 10)):
first_name = fake.first_name()
last_name = fake.last_name()
local_part = f"{first_name.lower()}.{last_name.lower()}{i}"
queue.push(
mailbox_models.Mailbox(
first_name=first_name,
last_name=last_name,
local_part=local_part,
domain_id=domain_id,
secondary_email=f"{local_part}@example.fr",
status=random.choice(MailboxStatusChoices.values),
dn_email=local_part,
)
)
queue.flush()
with Timeit(stdout, "Creating specific users"):
# ⚠️ Warning: this users also need to be created in the keycloak
# realm.json AND the OIDC setting to fallback on user email
@@ -366,6 +395,58 @@ def create_demo(stdout): # pylint: disable=too-many-locals
queue.flush()
# Enabled domain for 2E2 tests
enabled_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="enabled-domain.com",
status=MailDomainStatusChoices.ENABLED,
support_email="support@enabled-domain.com",
)
domain_owner = models.User.objects.get(email="e2e.mail-owner@example.com")
mailbox_models.MailDomainAccess.objects.get_or_create(
domain=enabled_domain,
user=domain_owner,
role=MailDomainRoleChoices.OWNER,
)
mailbox_models.MailDomainInvitation.objects.create(
issuer=domain_owner,
domain=enabled_domain,
email="people@people.world",
role=MailDomainRoleChoices.ADMIN,
)
# Many mailboxes domain
many_boxes_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="many-boxes-domain.com",
status=MailDomainStatusChoices.ENABLED,
support_email="support@mbd.com",
)
domain_owner = models.User.objects.get(email="e2e.mail-owner@example.com")
mailbox_models.MailDomainAccess.objects.get_or_create(
domain=many_boxes_domain,
user=domain_owner,
role=MailDomainRoleChoices.OWNER,
)
mailbox_models.MailDomainInvitation.objects.create(
issuer=domain_owner,
domain=many_boxes_domain,
email="people@people.world",
role=MailDomainRoleChoices.OWNER,
)
for _i in range(30):
first_name = fake.first_name()
last_name = fake.last_name()
local_part = f"{first_name.lower()}.{last_name.lower()}"
mailbox_models.Mailbox.objects.create(
domain=many_boxes_domain,
first_name=first_name,
last_name=last_name,
local_part=local_part,
secondary_email=f"{local_part}@example.fr",
status=random.choice(MailboxStatusChoices.values),
password=make_password(None), # unusable password
dn_email=f"{local_part}@{many_boxes_domain}",
)
# OIDC configuration
if settings.OAUTH2_PROVIDER.get("OIDC_ENABLED", False):
stdout.write("Creating OIDC client for People Identity Provider")
@@ -19,7 +19,7 @@ TEST_NB_OBJECTS = {
"users": 100,
"teams": 100,
"max_users_per_team": 5,
"domains": 100,
"domains": 10,
}
@@ -40,13 +40,19 @@ def test_commands_create_demo(settings):
assert models.Team.objects.count() == TEST_NB_OBJECTS["teams"]
assert models.TeamAccess.objects.count() >= TEST_NB_OBJECTS["teams"]
assert mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"] + 1
# nb_domains + example.com + enabled + many-boxed-domain
assert (
mailbox_models.MailDomain.objects.count()
== TEST_NB_OBJECTS["domains"] + 1 + 1 + 1
)
# 3 domain access for each user with domain rights
# 3 x 3 domain access for each user with both rights
# 2 domains for E2E mail owner user
assert (
mailbox_models.MailDomainAccess.objects.count()
== TEST_NB_OBJECTS["domains"] + 3 + 9
== TEST_NB_OBJECTS["domains"] + 3 + 9 + 2
)
Binary file not shown.
+180 -98
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-28 14:01+0000\n"
"PO-Revision-Date: 2025-04-28 15:40\n"
"POT-Creation-Date: 2026-01-23 17:58+0000\n"
"PO-Revision-Date: 2026-01-26 11:02\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -45,24 +45,24 @@ msgstr ""
msgid "People core application"
msgstr ""
#: build/lib/core/authentication/backends.py:104
#: core/authentication/backends.py:104
#: build/lib/core/authentication/backends.py:102
#: core/authentication/backends.py:102
msgid "Claims contained no recognizable user identification"
msgstr ""
#: build/lib/core/authentication/backends.py:124
#: core/authentication/backends.py:124
#: build/lib/core/authentication/backends.py:122
#: core/authentication/backends.py:122
msgid "Claims contained no recognizable organization identification"
msgstr ""
#: build/lib/core/authentication/backends.py:181
#: build/lib/core/authentication/backends.py:183
#: core/authentication/backends.py:181 core/authentication/backends.py:183
#: build/lib/core/authentication/backends.py:170
#: build/lib/core/authentication/backends.py:172
#: core/authentication/backends.py:170 core/authentication/backends.py:172
msgid "Invalid authorization header."
msgstr ""
#: build/lib/core/authentication/backends.py:188
#: core/authentication/backends.py:188
#: build/lib/core/authentication/backends.py:177
#: core/authentication/backends.py:177
msgid "Invalid api key."
msgstr ""
@@ -148,9 +148,9 @@ msgid "contacts"
msgstr ""
#: build/lib/core/models.py:250 build/lib/core/models.py:364
#: build/lib/core/models.py:510 build/lib/core/models.py:1106
#: build/lib/mailbox_manager/models.py:53 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1106 mailbox_manager/models.py:53
#: build/lib/core/models.py:510 build/lib/core/models.py:1121
#: build/lib/mailbox_manager/models.py:55 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1121 mailbox_manager/models.py:55
msgid "name"
msgstr ""
@@ -211,8 +211,8 @@ msgstr ""
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: build/lib/core/models.py:509 build/lib/core/models.py:958 core/models.py:509
#: core/models.py:958
#: build/lib/core/models.py:509 build/lib/core/models.py:973 core/models.py:509
#: core/models.py:973
msgid "email address"
msgstr ""
@@ -268,160 +268,198 @@ msgstr ""
msgid "This user is already in this organization."
msgstr ""
#: build/lib/core/models.py:774 core/models.py:774
#: build/lib/core/models.py:761 core/models.py:761
msgid "external_id"
msgstr ""
#: build/lib/core/models.py:762 core/models.py:762
msgid "Team external UUID for synchronization with external systems"
msgstr ""
#: build/lib/core/models.py:787 core/models.py:787
msgid "is visible for all SP"
msgstr ""
#: build/lib/core/models.py:776 core/models.py:776
#: build/lib/core/models.py:789 core/models.py:789
msgid "Whether this team is visible to all service providers."
msgstr ""
#: build/lib/core/models.py:784 core/models.py:784
#: build/lib/core/models.py:797 core/models.py:797
msgid "Team"
msgstr ""
#: build/lib/core/models.py:785 core/models.py:785
#: build/lib/core/models.py:798 core/models.py:798
msgid "Teams"
msgstr ""
#: build/lib/core/models.py:836 core/models.py:836
#: build/lib/core/models.py:849 core/models.py:849
msgid "Team/user relation"
msgstr ""
#: build/lib/core/models.py:837 core/models.py:837
#: build/lib/core/models.py:850 core/models.py:850
msgid "Team/user relations"
msgstr ""
#: build/lib/core/models.py:842 core/models.py:842
#: build/lib/core/models.py:855 core/models.py:855
msgid "This user is already in this team."
msgstr ""
#: build/lib/core/models.py:931 core/models.py:931
#: build/lib/core/models.py:941 core/models.py:941
msgid "url"
msgstr ""
#: build/lib/core/models.py:932 core/models.py:932
#: build/lib/core/models.py:942 core/models.py:942
msgid "secret"
msgstr ""
#: build/lib/core/models.py:941 core/models.py:941
#: build/lib/core/models.py:956 core/models.py:956
msgid "Team webhook"
msgstr ""
#: build/lib/core/models.py:942 core/models.py:942
#: build/lib/core/models.py:957 core/models.py:957
msgid "Team webhooks"
msgstr ""
#: build/lib/core/models.py:986 core/models.py:986
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1015 core/models.py:1015
msgid "Invitation to join La Régie!"
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:1061 core/models.py:1061
msgid "Team invitation"
msgstr ""
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1062 core/models.py:1062
msgid "Team invitations"
msgstr ""
#: build/lib/core/models.py:1060 core/models.py:1060
#: build/lib/core/models.py:1075 core/models.py:1075
#, python-format
msgid "[La Suite] You have been invited to become a %(role)s of a group"
msgstr ""
#: build/lib/core/models.py:1108 core/models.py:1108
#: build/lib/core/models.py:1123 core/models.py:1123
msgid "api key"
msgstr ""
#: build/lib/core/models.py:1113 core/models.py:1113
#: build/lib/core/models.py:1128 core/models.py:1128
msgid "allowed scopes"
msgstr ""
#: build/lib/core/models.py:1114 core/models.py:1114
#: build/lib/core/models.py:1129 core/models.py:1129
msgid "Allowed scopes for this service"
msgstr ""
#: build/lib/core/models.py:1119 core/models.py:1119
#: build/lib/core/models.py:1134 core/models.py:1134
msgid "Account service"
msgstr ""
#: build/lib/core/models.py:1120 core/models.py:1120
#: build/lib/core/models.py:1135 core/models.py:1135
msgid "Account services"
msgstr ""
#: build/lib/mailbox_manager/admin.py:17 mailbox_manager/admin.py:17
#: build/lib/mailbox_manager/admin.py:14 mailbox_manager/admin.py:14
msgid "Import emails from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:35 mailbox_manager/admin.py:35
#: build/lib/mailbox_manager/admin.py:32 build/lib/mailbox_manager/admin.py:71
#: mailbox_manager/admin.py:32 mailbox_manager/admin.py:71
#, python-format
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:42 mailbox_manager/admin.py:42
#: build/lib/mailbox_manager/admin.py:39 mailbox_manager/admin.py:39
#, python-format
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:49 mailbox_manager/admin.py:49
#: build/lib/mailbox_manager/admin.py:46 build/lib/mailbox_manager/admin.py:91
#: mailbox_manager/admin.py:46 mailbox_manager/admin.py:91
#, python-format
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:54 mailbox_manager/admin.py:54
#: build/lib/mailbox_manager/admin.py:51 mailbox_manager/admin.py:51
msgid "Import aliases from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:78 mailbox_manager/admin.py:78
#, python-format
msgid "Synchronisation succeed for %(domain)s.Imported %(count_imported)s aliases: %(aliases)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:96 mailbox_manager/admin.py:96
msgid "Check and update status from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:71 mailbox_manager/admin.py:71
#: build/lib/mailbox_manager/admin.py:113 mailbox_manager/admin.py:113
#, python-format
msgid "- %(domain)s with message: %(err)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:84 mailbox_manager/admin.py:84
#: build/lib/mailbox_manager/admin.py:126 mailbox_manager/admin.py:126
msgid "Check domains done with success."
msgstr ""
#: build/lib/mailbox_manager/admin.py:85 mailbox_manager/admin.py:85
#, python-format
msgid "Domains updated: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:87 mailbox_manager/admin.py:87
msgid "No domain updated."
msgstr ""
#: build/lib/mailbox_manager/admin.py:94 mailbox_manager/admin.py:94
msgid "Check domain failed for:"
msgstr ""
#: build/lib/mailbox_manager/admin.py:102 mailbox_manager/admin.py:102
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:107 mailbox_manager/admin.py:107
msgid "Fetch domain expected config from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:121 mailbox_manager/admin.py:121
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:127 mailbox_manager/admin.py:127
#, python-format
msgid "Domains updated: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:129 mailbox_manager/admin.py:129
msgid "No domain updated."
msgstr ""
#: build/lib/mailbox_manager/admin.py:136 mailbox_manager/admin.py:136
msgid "Check domain failed for:"
msgstr ""
#: build/lib/mailbox_manager/admin.py:144 mailbox_manager/admin.py:144
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:149 mailbox_manager/admin.py:149
msgid "Fetch domain expected config from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:163 mailbox_manager/admin.py:163
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:169 mailbox_manager/admin.py:169
#, python-format
msgid "Failed to fetch domain expected config for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:133 mailbox_manager/admin.py:133
#: build/lib/mailbox_manager/admin.py:175 mailbox_manager/admin.py:175
#, python-format
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:180 mailbox_manager/admin.py:180
msgid "Send pending mailboxes to dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:196 mailbox_manager/admin.py:196
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:202 mailbox_manager/admin.py:202
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:208 mailbox_manager/admin.py:208
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/apps.py:11 mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr ""
@@ -449,100 +487,117 @@ msgstr ""
msgid "Action required"
msgstr ""
#: build/lib/mailbox_manager/models.py:32 mailbox_manager/models.py:32
#: build/lib/mailbox_manager/models.py:34 mailbox_manager/models.py:34
msgid "[La Suite] Your domain is ready"
msgstr ""
#: build/lib/mailbox_manager/models.py:37 mailbox_manager/models.py:37
#: build/lib/mailbox_manager/models.py:39 mailbox_manager/models.py:39
msgid "[La Suite] Your domain requires action"
msgstr ""
#: build/lib/mailbox_manager/models.py:42 mailbox_manager/models.py:42
#: build/lib/mailbox_manager/models.py:44 mailbox_manager/models.py:44
msgid "[La Suite] Your domain has failed"
msgstr ""
#: build/lib/mailbox_manager/models.py:68 mailbox_manager/models.py:68
#: build/lib/mailbox_manager/models.py:70 mailbox_manager/models.py:70
msgid "support email"
msgstr ""
#: build/lib/mailbox_manager/models.py:72 mailbox_manager/models.py:72
#: build/lib/mailbox_manager/models.py:74 mailbox_manager/models.py:74
msgid "last check details"
msgstr ""
#: build/lib/mailbox_manager/models.py:73 mailbox_manager/models.py:73
#: build/lib/mailbox_manager/models.py:75 mailbox_manager/models.py:75
msgid "A JSON object containing the last health check details"
msgstr ""
#: build/lib/mailbox_manager/models.py:78 mailbox_manager/models.py:78
#: build/lib/mailbox_manager/models.py:80 mailbox_manager/models.py:80
msgid "expected config"
msgstr ""
#: build/lib/mailbox_manager/models.py:79 mailbox_manager/models.py:79
#: build/lib/mailbox_manager/models.py:81 mailbox_manager/models.py:81
msgid "A JSON object containing the expected config"
msgstr ""
#: build/lib/mailbox_manager/models.py:84 mailbox_manager/models.py:84
#: build/lib/mailbox_manager/models.py:86 mailbox_manager/models.py:86
msgid "Mail domain"
msgstr ""
#: build/lib/mailbox_manager/models.py:85 mailbox_manager/models.py:85
#: build/lib/mailbox_manager/models.py:87 mailbox_manager/models.py:87
msgid "Mail domains"
msgstr ""
#: build/lib/mailbox_manager/models.py:199 mailbox_manager/models.py:199
#: build/lib/mailbox_manager/models.py:203 mailbox_manager/models.py:203
msgid "User/mail domain relation"
msgstr ""
#: build/lib/mailbox_manager/models.py:200 mailbox_manager/models.py:200
#: build/lib/mailbox_manager/models.py:204 mailbox_manager/models.py:204
msgid "User/mail domain relations"
msgstr ""
#: build/lib/mailbox_manager/models.py:273 mailbox_manager/models.py:273
#: build/lib/mailbox_manager/models.py:277 mailbox_manager/models.py:277
msgid "local_part"
msgstr ""
#: build/lib/mailbox_manager/models.py:287 mailbox_manager/models.py:287
#: build/lib/mailbox_manager/models.py:291 mailbox_manager/models.py:291
msgid "secondary email address"
msgstr ""
#: build/lib/mailbox_manager/models.py:298 mailbox_manager/models.py:298
#: build/lib/mailbox_manager/models.py:302 mailbox_manager/models.py:302
msgid "email"
msgstr ""
#: build/lib/mailbox_manager/models.py:304 mailbox_manager/models.py:304
#: build/lib/mailbox_manager/models.py:308 mailbox_manager/models.py:308
msgid "Mailbox"
msgstr ""
#: build/lib/mailbox_manager/models.py:305 mailbox_manager/models.py:305
#: build/lib/mailbox_manager/models.py:309 mailbox_manager/models.py:309
msgid "Mailboxes"
msgstr ""
#: build/lib/mailbox_manager/models.py:331 mailbox_manager/models.py:331
#: build/lib/mailbox_manager/models.py:351 mailbox_manager/models.py:351
msgid "You can't create or update a mailbox for a disabled domain."
msgstr ""
#: build/lib/mailbox_manager/models.py:369 mailbox_manager/models.py:369
#: build/lib/mailbox_manager/models.py:408 mailbox_manager/models.py:408
msgid "Mail domain invitation"
msgstr ""
#: build/lib/mailbox_manager/models.py:370 mailbox_manager/models.py:370
#: build/lib/mailbox_manager/models.py:409 mailbox_manager/models.py:409
msgid "Mail domain invitations"
msgstr ""
#: build/lib/mailbox_manager/models.py:382 mailbox_manager/models.py:382
#: build/lib/mailbox_manager/models.py:421 mailbox_manager/models.py:421
msgid "[La Suite] You have been invited to join La Régie"
msgstr ""
#: build/lib/mailbox_manager/utils/dimail.py:260
#: mailbox_manager/utils/dimail.py:260
#: build/lib/mailbox_manager/models.py:465 mailbox_manager/models.py:465
msgid "destination address"
msgstr ""
#: build/lib/mailbox_manager/models.py:483 mailbox_manager/models.py:483
msgid "Alias"
msgstr ""
#: build/lib/mailbox_manager/models.py:484 mailbox_manager/models.py:484
msgid "Aliases"
msgstr ""
#: build/lib/mailbox_manager/utils/dimail.py:296
#: mailbox_manager/utils/dimail.py:296
msgid "Your new mailbox information"
msgstr ""
#: build/lib/people/settings.py:158 people/settings.py:158
msgid "English"
#: build/lib/mailbox_manager/utils/dimail.py:307
#: mailbox_manager/utils/dimail.py:307
msgid "Your password has been updated"
msgstr ""
#: build/lib/people/settings.py:159 people/settings.py:159
msgid "English"
msgstr ""
#: build/lib/people/settings.py:160 people/settings.py:160
msgid "French"
msgstr ""
@@ -603,12 +658,14 @@ msgstr ""
#: core/templates/mail/html/maildomain_failed.html:233
#: core/templates/mail/html/maildomain_invitation.html:256
#: core/templates/mail/html/new_mailbox.html:273
#: core/templates/mail/html/reset_password.html:268
#: core/templates/mail/html/team_invitation.html:269
#: core/templates/mail/text/maildomain_action_required.txt:14
#: core/templates/mail/text/maildomain_enabled.txt:14
#: core/templates/mail/text/maildomain_failed.txt:15
#: core/templates/mail/text/maildomain_invitation.txt:24
#: core/templates/mail/text/new_mailbox.txt:17
#: core/templates/mail/text/reset_password.txt:16
#: core/templates/mail/text/team_invitation.txt:22
msgid "La Suite Team"
msgstr ""
@@ -715,7 +772,9 @@ msgid "Welcome aboard!"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:159
#: core/templates/mail/html/reset_password.html:159
#: core/templates/mail/text/new_mailbox.txt:3
#: core/templates/mail/text/reset_password.txt:3
msgid "La Messagerie"
msgstr ""
@@ -740,25 +799,48 @@ msgid "Please find below your login info: "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:228
#: core/templates/mail/html/reset_password.html:223
#: core/templates/mail/text/new_mailbox.txt:10
#: core/templates/mail/text/reset_password.txt:9
msgid "Email address: "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:233
#: core/templates/mail/html/reset_password.html:228
#: core/templates/mail/text/new_mailbox.txt:11
msgid "Temporary password (to be modify on first login): "
#: core/templates/mail/text/reset_password.txt:10
msgid "Temporary password (to be modified on first login): "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:261
#: core/templates/mail/html/reset_password.html:256
#: core/templates/mail/text/new_mailbox.txt:13
#: core/templates/mail/text/reset_password.txt:12
msgid "Go to La Messagerie"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:272
#: core/templates/mail/html/reset_password.html:267
#: core/templates/mail/text/new_mailbox.txt:15
#: core/templates/mail/text/reset_password.txt:14
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/reset_password.html:188
#: core/templates/mail/text/reset_password.txt:5
msgid "Your password has been reset"
msgstr ""
#: core/templates/mail/html/reset_password.html:194
#: core/templates/mail/text/reset_password.txt:6
msgid "Your password has been reset."
msgstr ""
#: core/templates/mail/html/reset_password.html:199
#: core/templates/mail/text/reset_password.txt:7
msgid "Please find below your new login information: "
msgstr ""
#: core/templates/mail/html/team_invitation.html:206
#: core/templates/mail/text/team_invitation.txt:7
#, python-format
Binary file not shown.
+172 -90
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-28 14:01+0000\n"
"PO-Revision-Date: 2025-04-28 15:40\n"
"POT-Creation-Date: 2026-01-23 17:58+0000\n"
"PO-Revision-Date: 2026-01-26 11:02\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -19,7 +19,7 @@ msgstr ""
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Personal info"
msgstr "Infos Personnelles"
msgstr "Informations personnelles"
#: build/lib/core/admin.py:73 core/admin.py:73
msgid "Permissions"
@@ -43,28 +43,28 @@ msgstr "Les plugins de post-création ont été exécutés pour les organisation
#: build/lib/core/apps.py:65 core/apps.py:65
msgid "People core application"
msgstr ""
msgstr "Application cœur de People"
#: build/lib/core/authentication/backends.py:104
#: core/authentication/backends.py:104
#: build/lib/core/authentication/backends.py:102
#: core/authentication/backends.py:102
msgid "Claims contained no recognizable user identification"
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
#: build/lib/core/authentication/backends.py:124
#: core/authentication/backends.py:124
#: build/lib/core/authentication/backends.py:122
#: core/authentication/backends.py:122
msgid "Claims contained no recognizable organization identification"
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
#: build/lib/core/authentication/backends.py:181
#: build/lib/core/authentication/backends.py:183
#: core/authentication/backends.py:181 core/authentication/backends.py:183
#: build/lib/core/authentication/backends.py:170
#: build/lib/core/authentication/backends.py:172
#: core/authentication/backends.py:170 core/authentication/backends.py:172
msgid "Invalid authorization header."
msgstr ""
msgstr "En-tête d'autorisation invalide."
#: build/lib/core/authentication/backends.py:188
#: core/authentication/backends.py:188
#: build/lib/core/authentication/backends.py:177
#: core/authentication/backends.py:177
msgid "Invalid api key."
msgstr ""
msgstr "Clé API invalide."
#: build/lib/core/enums.py:24 core/enums.py:24
msgid "Failure"
@@ -148,9 +148,9 @@ msgid "contacts"
msgstr "contacts"
#: build/lib/core/models.py:250 build/lib/core/models.py:364
#: build/lib/core/models.py:510 build/lib/core/models.py:1106
#: build/lib/mailbox_manager/models.py:53 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1106 mailbox_manager/models.py:53
#: build/lib/core/models.py:510 build/lib/core/models.py:1121
#: build/lib/mailbox_manager/models.py:55 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1121 mailbox_manager/models.py:55
msgid "name"
msgstr "nom"
@@ -211,8 +211,8 @@ msgstr "sub"
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_ seulement."
#: build/lib/core/models.py:509 build/lib/core/models.py:958 core/models.py:509
#: core/models.py:958
#: build/lib/core/models.py:509 build/lib/core/models.py:973 core/models.py:509
#: core/models.py:973
msgid "email address"
msgstr "adresse email"
@@ -268,160 +268,198 @@ msgstr "Relations organisation/utilisateur"
msgid "This user is already in this organization."
msgstr "Cet utilisateur est déjà dans cette organisation."
#: build/lib/core/models.py:774 core/models.py:774
msgid "is visible for all SP"
msgstr "est visible pour tous les SP"
#: build/lib/core/models.py:761 core/models.py:761
msgid "external_id"
msgstr "ID externe"
#: build/lib/core/models.py:776 core/models.py:776
#: build/lib/core/models.py:762 core/models.py:762
msgid "Team external UUID for synchronization with external systems"
msgstr "UUID externe de l'équipe pour la synchronisation avec des systèmes externes"
#: build/lib/core/models.py:787 core/models.py:787
msgid "is visible for all SP"
msgstr "est visible pour tous les fournisseurs de service"
#: build/lib/core/models.py:789 core/models.py:789
msgid "Whether this team is visible to all service providers."
msgstr "Si cette équipe est visible pour tous les fournisseurs de services."
#: build/lib/core/models.py:784 core/models.py:784
#: build/lib/core/models.py:797 core/models.py:797
msgid "Team"
msgstr "Équipe"
#: build/lib/core/models.py:785 core/models.py:785
#: build/lib/core/models.py:798 core/models.py:798
msgid "Teams"
msgstr "Équipes"
#: build/lib/core/models.py:836 core/models.py:836
#: build/lib/core/models.py:849 core/models.py:849
msgid "Team/user relation"
msgstr "Relation équipe/utilisateur"
#: build/lib/core/models.py:837 core/models.py:837
#: build/lib/core/models.py:850 core/models.py:850
msgid "Team/user relations"
msgstr "Relations équipe/utilisateur"
#: build/lib/core/models.py:842 core/models.py:842
#: build/lib/core/models.py:855 core/models.py:855
msgid "This user is already in this team."
msgstr "Cet utilisateur est déjà dans cette équipe."
#: build/lib/core/models.py:931 core/models.py:931
#: build/lib/core/models.py:941 core/models.py:941
msgid "url"
msgstr "url"
#: build/lib/core/models.py:932 core/models.py:932
#: build/lib/core/models.py:942 core/models.py:942
msgid "secret"
msgstr "secret"
#: build/lib/core/models.py:941 core/models.py:941
#: build/lib/core/models.py:956 core/models.py:956
msgid "Team webhook"
msgstr "Webhook d'équipe"
#: build/lib/core/models.py:942 core/models.py:942
#: build/lib/core/models.py:957 core/models.py:957
msgid "Team webhooks"
msgstr "Webhooks d'équipe"
#: build/lib/core/models.py:986 core/models.py:986
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur enregistré."
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1015 core/models.py:1015
msgid "Invitation to join La Régie!"
msgstr "Invitation à rejoindre La Régie !"
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:1061 core/models.py:1061
msgid "Team invitation"
msgstr "Invitation d'équipe"
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1062 core/models.py:1062
msgid "Team invitations"
msgstr "Invitations d'équipe"
#: build/lib/core/models.py:1060 core/models.py:1060
#: build/lib/core/models.py:1075 core/models.py:1075
#, python-format
msgid "[La Suite] You have been invited to become a %(role)s of a group"
msgstr "[La Suite] Vous avez été invité(e) à être %(role)s d'un groupe"
#: build/lib/core/models.py:1108 core/models.py:1108
#: build/lib/core/models.py:1123 core/models.py:1123
msgid "api key"
msgstr ""
msgstr "Clé d'API"
#: build/lib/core/models.py:1113 core/models.py:1113
#: build/lib/core/models.py:1128 core/models.py:1128
msgid "allowed scopes"
msgstr ""
msgstr "périmètres autorisés"
#: build/lib/core/models.py:1114 core/models.py:1114
#: build/lib/core/models.py:1129 core/models.py:1129
msgid "Allowed scopes for this service"
msgstr ""
msgstr "Périmètres d'application autorisés pour ce service"
#: build/lib/core/models.py:1119 core/models.py:1119
#: build/lib/core/models.py:1134 core/models.py:1134
msgid "Account service"
msgstr ""
msgstr "Compte de service"
#: build/lib/core/models.py:1120 core/models.py:1120
#: build/lib/core/models.py:1135 core/models.py:1135
msgid "Account services"
msgstr ""
msgstr "Comptes de service"
#: build/lib/mailbox_manager/admin.py:17 mailbox_manager/admin.py:17
#: build/lib/mailbox_manager/admin.py:14 mailbox_manager/admin.py:14
msgid "Import emails from dimail"
msgstr ""
msgstr "Importer les emails depuis dimail"
#: build/lib/mailbox_manager/admin.py:35 mailbox_manager/admin.py:35
#: build/lib/mailbox_manager/admin.py:32 build/lib/mailbox_manager/admin.py:71
#: mailbox_manager/admin.py:32 mailbox_manager/admin.py:71
#, python-format
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
msgstr "La synchronisation a échoué pour %(domain)s avec le message : %(err)s"
#: build/lib/mailbox_manager/admin.py:42 mailbox_manager/admin.py:42
#: build/lib/mailbox_manager/admin.py:39 mailbox_manager/admin.py:39
#, python-format
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
msgstr "La synchronisation a réussi pour %(domain)s. Importation des boîtes mails : %(mailboxes)s"
#: build/lib/mailbox_manager/admin.py:49 mailbox_manager/admin.py:49
#: build/lib/mailbox_manager/admin.py:46 build/lib/mailbox_manager/admin.py:91
#: mailbox_manager/admin.py:46 mailbox_manager/admin.py:91
#, python-format
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
msgstr "La synchro nécessite des domaines activés. Les domaines exclus sont : %(domains)s"
#: build/lib/mailbox_manager/admin.py:54 mailbox_manager/admin.py:54
#: build/lib/mailbox_manager/admin.py:51 mailbox_manager/admin.py:51
msgid "Import aliases from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:78 mailbox_manager/admin.py:78
#, python-format
msgid "Synchronisation succeed for %(domain)s.Imported %(count_imported)s aliases: %(aliases)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:96 mailbox_manager/admin.py:96
msgid "Check and update status from dimail"
msgstr "Vérifier et mettre à jour le statut à partir de dimail"
#: build/lib/mailbox_manager/admin.py:71 mailbox_manager/admin.py:71
#: build/lib/mailbox_manager/admin.py:113 mailbox_manager/admin.py:113
#, python-format
msgid "- %(domain)s with message: %(err)s"
msgstr "- %(domain)s avec le message : %(err)s"
#: build/lib/mailbox_manager/admin.py:84 mailbox_manager/admin.py:84
#: build/lib/mailbox_manager/admin.py:126 mailbox_manager/admin.py:126
msgid "Check domains done with success."
msgstr "Vérification des domaines effectuée avec succès."
#: build/lib/mailbox_manager/admin.py:85 mailbox_manager/admin.py:85
#: build/lib/mailbox_manager/admin.py:127 mailbox_manager/admin.py:127
#, python-format
msgid "Domains updated: %(domains)s"
msgstr "Domaines mis à jour : %(domains)s"
#: build/lib/mailbox_manager/admin.py:87 mailbox_manager/admin.py:87
#: build/lib/mailbox_manager/admin.py:129 mailbox_manager/admin.py:129
msgid "No domain updated."
msgstr "Aucun domaine mis à jour."
#: build/lib/mailbox_manager/admin.py:94 mailbox_manager/admin.py:94
#: build/lib/mailbox_manager/admin.py:136 mailbox_manager/admin.py:136
msgid "Check domain failed for:"
msgstr "La vérification du domaine a échoué pour :"
#: build/lib/mailbox_manager/admin.py:102 mailbox_manager/admin.py:102
#: build/lib/mailbox_manager/admin.py:144 mailbox_manager/admin.py:144
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la vérification : %(domains)s"
#: build/lib/mailbox_manager/admin.py:107 mailbox_manager/admin.py:107
#: build/lib/mailbox_manager/admin.py:149 mailbox_manager/admin.py:149
msgid "Fetch domain expected config from dimail"
msgstr "Récupérer la configuration attendue du domaine depuis dimail"
#: build/lib/mailbox_manager/admin.py:121 mailbox_manager/admin.py:121
#: build/lib/mailbox_manager/admin.py:163 mailbox_manager/admin.py:163
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr "La configuration du domaine attendue a été récupérée avec succès pour %(domain)s."
#: build/lib/mailbox_manager/admin.py:127 mailbox_manager/admin.py:127
#: build/lib/mailbox_manager/admin.py:169 mailbox_manager/admin.py:169
#, python-format
msgid "Failed to fetch domain expected config for %(domain)s."
msgstr "Impossible de récupérer la configuration attendue pour %(domain)s."
#: build/lib/mailbox_manager/admin.py:133 mailbox_manager/admin.py:133
#: build/lib/mailbox_manager/admin.py:175 mailbox_manager/admin.py:175
#, python-format
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la récupération : %(domains)s"
#: build/lib/mailbox_manager/admin.py:180 mailbox_manager/admin.py:180
msgid "Send pending mailboxes to dimail"
msgstr "Envoyer les adresses mail en attente à dimail"
#: build/lib/mailbox_manager/admin.py:196 mailbox_manager/admin.py:196
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr "Échec de l'envoi des adresses mail suivantes : %(mailboxes)s."
#: build/lib/mailbox_manager/admin.py:202 mailbox_manager/admin.py:202
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr "Succès de l'envoi des adresses en attente pour le domaine %(domain)s."
#: build/lib/mailbox_manager/admin.py:208 mailbox_manager/admin.py:208
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr "Les domaines désactivés ont été exclu : %(domains)s"
#: build/lib/mailbox_manager/apps.py:11 mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr "Gestion des boîtes mails"
@@ -449,100 +487,117 @@ msgstr "Désactivé"
msgid "Action required"
msgstr "Action requise"
#: build/lib/mailbox_manager/models.py:32 mailbox_manager/models.py:32
#: build/lib/mailbox_manager/models.py:34 mailbox_manager/models.py:34
msgid "[La Suite] Your domain is ready"
msgstr "[La Suite] Votre domaine est prêt"
#: build/lib/mailbox_manager/models.py:37 mailbox_manager/models.py:37
#: build/lib/mailbox_manager/models.py:39 mailbox_manager/models.py:39
msgid "[La Suite] Your domain requires action"
msgstr "[La Suite] Des actions sont requises sur votre domaine"
#: build/lib/mailbox_manager/models.py:42 mailbox_manager/models.py:42
#: build/lib/mailbox_manager/models.py:44 mailbox_manager/models.py:44
msgid "[La Suite] Your domain has failed"
msgstr "[La Suite] Votre domaine est en erreur"
#: build/lib/mailbox_manager/models.py:68 mailbox_manager/models.py:68
#: build/lib/mailbox_manager/models.py:70 mailbox_manager/models.py:70
msgid "support email"
msgstr "adresse email du support"
#: build/lib/mailbox_manager/models.py:72 mailbox_manager/models.py:72
#: build/lib/mailbox_manager/models.py:74 mailbox_manager/models.py:74
msgid "last check details"
msgstr "détails de la dernière vérification"
#: build/lib/mailbox_manager/models.py:73 mailbox_manager/models.py:73
#: build/lib/mailbox_manager/models.py:75 mailbox_manager/models.py:75
msgid "A JSON object containing the last health check details"
msgstr "Un objet JSON contenant les derniers détails du bilan de santé"
#: build/lib/mailbox_manager/models.py:78 mailbox_manager/models.py:78
#: build/lib/mailbox_manager/models.py:80 mailbox_manager/models.py:80
msgid "expected config"
msgstr "configuration attendue"
#: build/lib/mailbox_manager/models.py:79 mailbox_manager/models.py:79
#: build/lib/mailbox_manager/models.py:81 mailbox_manager/models.py:81
msgid "A JSON object containing the expected config"
msgstr "Un objet JSON contenant la configuration attendue"
#: build/lib/mailbox_manager/models.py:84 mailbox_manager/models.py:84
#: build/lib/mailbox_manager/models.py:86 mailbox_manager/models.py:86
msgid "Mail domain"
msgstr "Domaine de messagerie"
#: build/lib/mailbox_manager/models.py:85 mailbox_manager/models.py:85
#: build/lib/mailbox_manager/models.py:87 mailbox_manager/models.py:87
msgid "Mail domains"
msgstr "Domaines de messagerie"
#: build/lib/mailbox_manager/models.py:199 mailbox_manager/models.py:199
#: build/lib/mailbox_manager/models.py:203 mailbox_manager/models.py:203
msgid "User/mail domain relation"
msgstr "Relation utilisateur/domaine de messagerie"
#: build/lib/mailbox_manager/models.py:200 mailbox_manager/models.py:200
#: build/lib/mailbox_manager/models.py:204 mailbox_manager/models.py:204
msgid "User/mail domain relations"
msgstr "Relations utilisateur/domaine de messagerie"
#: build/lib/mailbox_manager/models.py:273 mailbox_manager/models.py:273
#: build/lib/mailbox_manager/models.py:277 mailbox_manager/models.py:277
msgid "local_part"
msgstr "local_part"
#: build/lib/mailbox_manager/models.py:287 mailbox_manager/models.py:287
#: build/lib/mailbox_manager/models.py:291 mailbox_manager/models.py:291
msgid "secondary email address"
msgstr "adresse email secondaire"
#: build/lib/mailbox_manager/models.py:298 mailbox_manager/models.py:298
#: build/lib/mailbox_manager/models.py:302 mailbox_manager/models.py:302
msgid "email"
msgstr "email"
#: build/lib/mailbox_manager/models.py:304 mailbox_manager/models.py:304
#: build/lib/mailbox_manager/models.py:308 mailbox_manager/models.py:308
msgid "Mailbox"
msgstr "Boîte mail"
#: build/lib/mailbox_manager/models.py:305 mailbox_manager/models.py:305
#: build/lib/mailbox_manager/models.py:309 mailbox_manager/models.py:309
msgid "Mailboxes"
msgstr "Boîtes mail"
#: build/lib/mailbox_manager/models.py:331 mailbox_manager/models.py:331
#: build/lib/mailbox_manager/models.py:351 mailbox_manager/models.py:351
msgid "You can't create or update a mailbox for a disabled domain."
msgstr "Vous ne pouvez pas créer ou mettre à jour une boîte mail pour un domaine désactivé."
#: build/lib/mailbox_manager/models.py:369 mailbox_manager/models.py:369
#: build/lib/mailbox_manager/models.py:408 mailbox_manager/models.py:408
msgid "Mail domain invitation"
msgstr "Invitation au domaine de messagerie"
#: build/lib/mailbox_manager/models.py:370 mailbox_manager/models.py:370
#: build/lib/mailbox_manager/models.py:409 mailbox_manager/models.py:409
msgid "Mail domain invitations"
msgstr "Invitations au domaine de messagerie"
#: build/lib/mailbox_manager/models.py:382 mailbox_manager/models.py:382
#: build/lib/mailbox_manager/models.py:421 mailbox_manager/models.py:421
msgid "[La Suite] You have been invited to join La Régie"
msgstr "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
#: build/lib/mailbox_manager/utils/dimail.py:260
#: mailbox_manager/utils/dimail.py:260
#: build/lib/mailbox_manager/models.py:465 mailbox_manager/models.py:465
msgid "destination address"
msgstr "adresse de destination"
#: build/lib/mailbox_manager/models.py:483 mailbox_manager/models.py:483
msgid "Alias"
msgstr "Alias"
#: build/lib/mailbox_manager/models.py:484 mailbox_manager/models.py:484
msgid "Aliases"
msgstr "Alias"
#: build/lib/mailbox_manager/utils/dimail.py:296
#: mailbox_manager/utils/dimail.py:296
msgid "Your new mailbox information"
msgstr "Informations sur votre nouvelle boîte mail"
#: build/lib/people/settings.py:158 people/settings.py:158
#: build/lib/mailbox_manager/utils/dimail.py:307
#: mailbox_manager/utils/dimail.py:307
msgid "Your password has been updated"
msgstr "Votre mot de passe a été mis à jour"
#: build/lib/people/settings.py:159 people/settings.py:159
msgid "English"
msgstr "Anglais"
#: build/lib/people/settings.py:159 people/settings.py:159
#: build/lib/people/settings.py:160 people/settings.py:160
msgid "French"
msgstr "Français"
@@ -603,12 +658,14 @@ msgstr "Cordialement,"
#: core/templates/mail/html/maildomain_failed.html:233
#: core/templates/mail/html/maildomain_invitation.html:256
#: core/templates/mail/html/new_mailbox.html:273
#: core/templates/mail/html/reset_password.html:268
#: core/templates/mail/html/team_invitation.html:269
#: core/templates/mail/text/maildomain_action_required.txt:14
#: core/templates/mail/text/maildomain_enabled.txt:14
#: core/templates/mail/text/maildomain_failed.txt:15
#: core/templates/mail/text/maildomain_invitation.txt:24
#: core/templates/mail/text/new_mailbox.txt:17
#: core/templates/mail/text/reset_password.txt:16
#: core/templates/mail/text/team_invitation.txt:22
msgid "La Suite Team"
msgstr "L'équipe de La Suite"
@@ -715,7 +772,9 @@ msgid "Welcome aboard!"
msgstr "Bienvenue à bord !"
#: core/templates/mail/html/new_mailbox.html:159
#: core/templates/mail/html/reset_password.html:159
#: core/templates/mail/text/new_mailbox.txt:3
#: core/templates/mail/text/reset_password.txt:3
msgid "La Messagerie"
msgstr "La Messagerie"
@@ -740,25 +799,48 @@ msgid "Please find below your login info: "
msgstr "Voici vos identifiants de connexion : "
#: core/templates/mail/html/new_mailbox.html:228
#: core/templates/mail/html/reset_password.html:223
#: core/templates/mail/text/new_mailbox.txt:10
#: core/templates/mail/text/reset_password.txt:9
msgid "Email address: "
msgstr "Adresse email : "
#: core/templates/mail/html/new_mailbox.html:233
#: core/templates/mail/html/reset_password.html:228
#: core/templates/mail/text/new_mailbox.txt:11
msgid "Temporary password (to be modify on first login): "
#: core/templates/mail/text/reset_password.txt:10
msgid "Temporary password (to be modified on first login): "
msgstr "Mot de passe temporaire (à modifier à la première connexion) : "
#: core/templates/mail/html/new_mailbox.html:261
#: core/templates/mail/html/reset_password.html:256
#: core/templates/mail/text/new_mailbox.txt:13
#: core/templates/mail/text/reset_password.txt:12
msgid "Go to La Messagerie"
msgstr "Accéder à La Messagerie"
#: core/templates/mail/html/new_mailbox.html:272
#: core/templates/mail/html/reset_password.html:267
#: core/templates/mail/text/new_mailbox.txt:15
#: core/templates/mail/text/reset_password.txt:14
msgid "Sincerely,"
msgstr "Cordialement,"
#: core/templates/mail/html/reset_password.html:188
#: core/templates/mail/text/reset_password.txt:5
msgid "Your password has been reset"
msgstr "Votre mot de passe a été réinitialisé"
#: core/templates/mail/html/reset_password.html:194
#: core/templates/mail/text/reset_password.txt:6
msgid "Your password has been reset."
msgstr "Votre mot de passe a été réinitialisé."
#: core/templates/mail/html/reset_password.html:199
#: core/templates/mail/text/reset_password.txt:7
msgid "Please find below your new login information: "
msgstr "Veuillez trouver ci-dessous vos nouvelles informations de connexion : "
#: core/templates/mail/html/team_invitation.html:206
#: core/templates/mail/text/team_invitation.txt:7
#, python-format
+92 -5
View File
@@ -10,9 +10,6 @@ from requests import exceptions
from mailbox_manager import enums, models
from mailbox_manager.utils.dimail import DimailAPIClient
# Prevent Ruff complaining about mark_safe below
# ruff: noqa: S308
@admin.action(description=_("Import emails from dimail"))
def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
@@ -51,6 +48,51 @@ def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disabl
)
@admin.action(description=_("Import aliases from dimail"))
def sync_aliases_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
"""
Admin action to import existing aliases from dimail.
Checks alias is not a duplicate and that usernames don't clash with existing mailboxes.
"""
excluded_domains = []
client = DimailAPIClient()
for domain in queryset:
if domain.status != enums.MailDomainStatusChoices.ENABLED:
excluded_domains.append(domain.name)
continue
try:
imported_aliases = client.import_aliases(domain)
except exceptions.HTTPError as err:
messages.error(
request,
_("Synchronisation failed for %(domain)s with message: %(err)s")
% {"domain": domain.name, "err": err},
)
else:
messages.success(
request,
_(
"Synchronisation succeed for %(domain)s.\
Imported %(count_imported)s aliases: %(aliases)s"
)
% {
"domain": domain.name,
"count_imported": len(imported_aliases),
"aliases": ", ".join(imported_aliases),
},
)
if excluded_domains:
messages.warning(
request,
_("Sync require enabled domains. Excluded domains: %(domains)s")
% {"domains": ", ".join(excluded_domains)},
)
@admin.action(description=_("Check and update status from dimail"))
def fetch_domain_status_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Admin action to check domain health with dimail and update domain status."""
@@ -135,6 +177,39 @@ def fetch_domain_expected_config_from_dimail(modeladmin, request, queryset): #
)
@admin.action(description=_("Send pending mailboxes to dimail"))
def send_pending_mailboxes(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Send pending mailboxes"""
client = DimailAPIClient()
excluded_domains = []
for domain in queryset:
# do not check disabled domains
if domain.status != enums.MailDomainStatusChoices.ENABLED:
excluded_domains.append(domain.name)
continue
results = client.send_pending_mailboxes(domain)
if failed_mailboxes := results["failed_mailboxes"]:
messages.error(
request,
_("Failed to send the following mailboxes : %(mailboxes)s.")
% {"mailboxes": ", ".join(failed_mailboxes)},
)
else:
messages.success(
request,
_("Pending mailboxes successfully sent for %(domain)s.")
% {"domain": domain.name},
)
if excluded_domains:
messages.warning(
request,
_("Domains disabled are excluded from : %(domains)s")
% {"domains": ", ".join(excluded_domains)},
)
class UserMailDomainAccessInline(admin.TabularInline):
"""Inline admin class for mail domain accesses."""
@@ -161,8 +236,10 @@ class MailDomainAdmin(admin.ModelAdmin):
inlines = (UserMailDomainAccessInline,)
actions = (
sync_mailboxes_from_dimail,
sync_aliases_from_dimail,
fetch_domain_status_from_dimail,
fetch_domain_expected_config_from_dimail,
send_pending_mailboxes,
)
autocomplete_fields = ["organization"]
@@ -173,8 +250,8 @@ class MailboxAdmin(UserAdmin):
list_display = ("__str__", "domain", "status", "updated_at")
list_filter = ("status",)
search_fields = ("local_part", "domain__name")
readonly_fields = ["updated_at", "local_part", "domain"]
search_fields = ("local_part", "domain__name", "first_name", "last_name")
readonly_fields = ["updated_at"]
fieldsets = None
add_fieldsets = (
@@ -233,3 +310,13 @@ class MailDomainInvitationAdmin(admin.ModelAdmin):
def is_expired(self, obj):
"""Return the expiration date of the invitation."""
return obj.is_expired
@admin.register(models.Alias)
class AliasAdmin(admin.ModelAdmin):
"""Admin for alias model."""
list_display = ("local_part", "domain", "destination", "updated_at")
list_filter = ("domain",)
search_fields = ("local_part", "domain__name", "destination")
readonly_fields = ["updated_at"]
@@ -3,6 +3,8 @@
from logging import getLogger
from django.contrib.auth.hashers import make_password
from django.core import exceptions as django_exceptions
from django.shortcuts import get_object_or_404
from requests.exceptions import HTTPError
from rest_framework import exceptions, serializers
@@ -29,7 +31,6 @@ class MailboxSerializer(serializers.ModelSerializer):
"secondary_email",
"status",
]
# everything is actually read-only as we do not allow update for now
read_only_fields = ["id", "status"]
def create(self, validated_data):
@@ -45,12 +46,21 @@ class MailboxSerializer(serializers.ModelSerializer):
"password": make_password(None), # generate an unusable password
}
)
if mailbox.domain.status == enums.MailDomainStatusChoices.ENABLED:
client = DimailAPIClient()
if validated_data["domain"].status == enums.MailDomainStatusChoices.ENABLED:
# send new mailbox request to dimail
response = client.create_mailbox(mailbox, self.context["request"].user.sub)
client = DimailAPIClient()
try:
response = client.create_mailbox(
mailbox, self.context["request"].user.sub
)
except django_exceptions.ValidationError as exc:
mailbox.delete()
raise exc
mailbox.status = enums.MailDomainStatusChoices.ENABLED
mailbox_data = response.json()
mailbox.set_password(mailbox_data["password"])
mailbox.save()
if mailbox.secondary_email:
@@ -67,10 +77,25 @@ class MailboxSerializer(serializers.ModelSerializer):
mailbox,
)
# actually save mailbox on our database
return mailbox
class MailboxUpdateSerializer(MailboxSerializer):
"""A more restrictive serializer when updating mailboxes"""
class Meta:
model = models.Mailbox
fields = [
"id",
"first_name",
"last_name",
"local_part",
"secondary_email",
"status",
]
read_only_fields = ("id", "local_part", "status")
class MailDomainSerializer(serializers.ModelSerializer):
"""Serialize mail domain."""
@@ -203,23 +228,10 @@ class MailDomainAccessSerializer(serializers.ModelSerializer):
"You must set a domain slug in kwargs to create a new domain access."
) from exc
try:
access = authenticated_user.mail_domain_accesses.get(
domain__slug=domain_slug
)
except models.MailDomainAccess.DoesNotExist as exc:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this domain."
) from exc
# Authenticated user must be owner or admin of current domain to set new roles
if access.role not in [
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
]:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this domain."
)
# we're sure that an access exists. otherwise it would have failed at permissions.
access = authenticated_user.mail_domain_accesses.get(
domain__slug=domain_slug
)
# only an owner can set an owner role to another user
if (
@@ -277,11 +289,41 @@ class MailDomainInvitationSerializer(serializers.ModelSerializer):
) from exc
domain = models.MailDomain.objects.get(slug=domain_slug)
if not domain.get_abilities(user)["manage_accesses"]:
raise exceptions.PermissionDenied(
"You are not allowed to manage invitations for this domain."
)
attrs["domain"] = domain
attrs["issuer"] = user
return attrs
class AliasSerializer(serializers.ModelSerializer):
"""Serialize aliases."""
domain = MailDomainSerializer(default="")
class Meta:
model = models.Alias
fields = [
"id",
"local_part",
"destination",
"domain",
]
read_only_fields = ["id", "domain"]
def validate_domain(self, value): # pylint: disable=unused-argument
"""Forcefully set domain field to url domain."""
return get_object_or_404(models.MailDomain, slug=self.context["domain_slug"])
def create(self, validated_data):
"""
Override create function to fire a request to dimail on alias creation.
"""
if validated_data["domain"].status == enums.MailDomainStatusChoices.ENABLED:
alias = models.Alias(**validated_data)
# send new alias request to dimail
client = DimailAPIClient()
client.create_alias(alias, self.context["request"].user.sub)
return super().create(validated_data)
return None
@@ -1,8 +1,9 @@
"""API endpoints"""
from django.db.models import Q, Subquery
from django.http import Http404
from rest_framework import exceptions, filters, mixins, viewsets
from rest_framework import exceptions, filters, mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
@@ -40,7 +41,10 @@ class MailDomainViewSet(
Fetch domain status and expected config from dimail.
"""
permission_classes = [permissions.AccessPermission]
permission_classes = [
permissions.DomainPermission,
permissions.DomainResourcePermission,
]
serializer_class = serializers.MailDomainSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "name"]
@@ -112,7 +116,7 @@ class MailDomainAccessViewSet(
Delete targeted domain access
"""
permission_classes = [permissions.MailDomainAccessRolePermission]
permission_classes = [permissions.DomainResourcePermission]
serializer_class = serializers.MailDomainAccessSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["role", "user__email", "user__name"]
@@ -228,9 +232,11 @@ class MailDomainAccessViewSet(
class MailBoxViewSet(
viewsets.GenericViewSet,
mixins.CreateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
mixins.UpdateModelMixin,
mixins.RetrieveModelMixin,
):
"""MailBox ViewSet
@@ -249,9 +255,18 @@ class MailBoxViewSet(
POST /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/enable/
Send a request to dimail to enable mailbox and change status of the mailbox in our DB
POST /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/reset/
Send a request to mail-provider to reset password.
PUT /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/
Send a request to update mailbox. Cannot modify domain or local_part.
PATCH /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/
Send a request to partially update mailbox. Cannot modify domain or local_part.
"""
permission_classes = [permissions.MailBoxPermission]
permission_classes = [permissions.DomainResourcePermission]
serializer_class = serializers.MailboxSerializer
filter_backends = [filters.OrderingFilter]
ordering = ["-created_at"]
@@ -264,6 +279,32 @@ class MailBoxViewSet(
return self.queryset.filter(domain__slug=domain_slug)
return self.queryset
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["domain_slug"] = self.kwargs["domain_slug"]
return context
def get_permissions(self):
"""Add a specific permission for domain viewers to update their own mailbox."""
if self.action in ["list", "retrieve"]:
permission_classes = [permissions.DomainPermission]
elif self.action in ["update", "partial_update"]:
permission_classes = [
permissions.DomainResourcePermission
| permissions.IsMailboxOwnerPermission
]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def get_serializer_class(self):
"""Chooses list or detail serializer according to the action."""
if self.action in {"update", "partial_update"}:
return serializers.MailboxUpdateSerializer
return self.serializer_class
def perform_create(self, serializer):
"""Create new mailbox."""
domain_slug = self.kwargs.get("domain_slug", "")
@@ -293,11 +334,21 @@ class MailBoxViewSet(
mailbox.save()
return Response(serializers.MailboxSerializer(mailbox).data)
@action(detail=True, methods=["post"])
def reset_password(self, request, domain_slug, pk=None): # pylint: disable=unused-argument
"""Send a request to dimail to change password
and email new password to mailbox's secondary email."""
mailbox = self.get_object()
dimail = DimailAPIClient()
dimail.reset_password(mailbox)
return Response(serializers.MailboxSerializer(mailbox).data)
class MailDomainInvitationViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to domain management.
@@ -315,10 +366,13 @@ class MailDomainInvitationViewset(
PUT / PATCH : Not permitted. Instead of updating your invitation,
delete and create a new one.
DELETE /api/<version>/mail-domains/<domain_slug>/invitations/:<invitation_id>/
Delete targeted invitation
"""
lookup_field = "id"
permission_classes = [permissions.AccessPermission]
permission_classes = [permissions.DomainResourcePermission]
queryset = (
models.MailDomainInvitation.objects.all()
.select_related("domain")
@@ -355,3 +409,102 @@ class MailDomainInvitationViewset(
)
return queryset
class AliasViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for aliases.
GET /api/<version>/mail-domains/<domain_slug>/aliases/
Return list of aliases related to that domain
POST /api/<version>/mail-domains/<domain_slug>/aliases/ with expected data:
- local_part: str
- destination: str
Return a newly created alias
DELETE /api/<version>/mail-domains/<domain_slug>/aliases/<alias_pk>/
Delete targeted alias
DELETE /api/<version>/mail-domains/<domain_slug>/aliases/?local_part=<local_part>/
Delete all aliases of targeted local_part
"""
lookup_field = "pk"
permission_classes = [permissions.DomainResourcePermission]
serializer_class = serializers.AliasSerializer
queryset = models.Alias.objects.all().select_related("domain")
filter_backends = [filters.OrderingFilter]
ordering_fields = ["local_part"]
ordering = ["local_part"]
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["domain_slug"] = self.kwargs["domain_slug"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(domain__slug=self.kwargs["domain_slug"])
if self.action == "list":
# Determine which role the logged-in user has in the domain
user_role_query = models.MailDomainAccess.objects.filter(
user=self.request.user, domain__slug=self.kwargs["domain_slug"]
).values("role")
queryset = (
queryset
# Abilities are computed based on logged-in user's role and
# the user role on each domain access
.annotate(user_role=Subquery(user_role_query)).distinct()
)
return queryset
def destroy(self, request, *args, **kwargs):
"""
Override destroy method to delete specific alias and send request to dimail.
"""
instance = self.get_object()
self.perform_destroy(instance)
client = DimailAPIClient()
dimail_response = client.delete_alias(instance)
if dimail_response.status_code == status.HTTP_404_NOT_FOUND:
return Response(
"Domain out of sync with mailbox provider, please contact our support.",
status=status.HTTP_200_OK,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=["DELETE"], detail=False)
def delete(self, request, *args, **kwargs):
"""Bulk delete aliases. Filtering is required and accepted filter is local_part."""
if "local_part" not in self.request.query_params:
return Response(status=status.HTTP_400_BAD_REQUEST)
local_part = self.request.query_params["local_part"]
queryset = self.get_queryset().filter(
local_part=local_part
) # Manually call get_queryset to filter by domain and role
if not queryset:
raise Http404("No Alias matches the given query.")
# view is bounded to a domain, fetch is from the queryset to spare a dedicated DB request"
domain_name = queryset[0].domain.name
queryset.delete()
client = DimailAPIClient()
client.delete_multiple_alias(local_part, domain_name)
return Response(status=status.HTTP_204_NO_CONTENT)
+37 -13
View File
@@ -1,11 +1,40 @@
"""Permission handlers for the People mailbox manager app."""
from core.api import permissions as core_permissions
from django.shortcuts import get_object_or_404
from rest_framework import permissions
from core.api.permissions import IsAuthenticated
from mailbox_manager import models
class AccessPermission(core_permissions.IsAuthenticated):
class DomainPermission(IsAuthenticated):
"""Permission class to manage mailboxes and aliases for a mail domain"""
def has_permission(self, request, view):
"""Check permission based on domain."""
if not super().has_permission(request, view):
return False
if not view.kwargs.get("domain_slug"):
return True
domain = get_object_or_404(
models.MailDomain,
slug=view.kwargs.get("domain_slug", ""),
accesses__user=request.user,
)
abilities = domain.get_abilities(request.user)
if request.method.lower() == "delete":
return abilities.get("manage_accesses", False)
return abilities.get(request.method.lower(), False)
class DomainResourcePermission(DomainPermission):
"""Permission class for access objects."""
def has_object_permission(self, request, view, obj):
@@ -14,20 +43,15 @@ class AccessPermission(core_permissions.IsAuthenticated):
return abilities.get(request.method.lower(), False)
class MailBoxPermission(core_permissions.IsAuthenticated):
"""Permission class to manage mailboxes for a mail domain"""
class IsMailboxOwnerPermission(permissions.BasePermission):
"""Authorize update for domain viewers on their own mailbox."""
def has_permission(self, request, view):
"""Check permission based on domain."""
"""This permission is specifically about updates"""
domain = models.MailDomain.objects.get(slug=view.kwargs.get("domain_slug", ""))
abilities = domain.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
class MailDomainAccessRolePermission(core_permissions.IsAuthenticated):
"""Permission class to manage mailboxes for a mail domain"""
return abilities["get"]
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
"""If the user is trying to update their own mailbox."""
return obj.get_email() == request.user.email
+2 -1
View File
@@ -15,4 +15,5 @@ class MailboxManagerConfig(AppConfig):
"""
Import signals when the app is ready.
"""
import mailbox_manager.signals # pylint: disable=import-outside-toplevel, unused-import
# pylint: disable=import-outside-toplevel, unused-import
import mailbox_manager.signals # noqa: PLC0415
+11
View File
@@ -98,3 +98,14 @@ class MailDomainInvitationFactory(factory.django.DjangoModelFactory):
[role[0] for role in enums.MailDomainRoleChoices.choices]
)
issuer = factory.SubFactory(core_factories.UserFactory)
class AliasFactory(factory.django.DjangoModelFactory):
"""A factory to create aliases."""
class Meta:
model = models.Alias
domain = factory.SubFactory(MailDomainEnabledFactory)
local_part = factory.Faker("word")
destination = factory.Faker("email")
@@ -47,9 +47,9 @@ class Command(BaseCommand):
f"This command is not meant to run in {settings.CONFIGURATION} environment."
)
# Create a first superuser for dimail-api container. User creation is usually
# protected behind admin rights but dimail allows to create a first user
# when database is empty
# Create a first superuser for dimail-api container.
# User creation is usually protected behind admin rights
# but dimail allows to create a first user when database is empty
self.create_user(
auth=("", ""),
name=admin["username"],
@@ -57,8 +57,8 @@ class Command(BaseCommand):
perms=[],
)
# Create Regie user, auth for all remaining requests
# and your own dev
# Create Regie user,
# auth for all remaining requests and your own local setup
self.create_user(
auth=(admin["username"], admin["password"]),
name=regie["username"],
@@ -66,28 +66,22 @@ class Command(BaseCommand):
perms=["new_domain", "create_users", "manage_users"],
)
# we create a domain and add John Doe to it
domain_name = "test.domain.com"
domain = MailDomain.objects.get_or_create(
name=domain_name,
defaults={
"status": enums.MailDomainStatusChoices.ENABLED,
"support_email": f"support@{domain_name}",
},
)[0]
self.create_domain(domain_name)
# we create a dimail user for keycloak+regie user John Doe
# This way, la Régie will be able to make request in the name of
# this user
# Create a test domain for local development
try:
people_base_user = User.objects.get(email="people@people.world")
except User.DoesNotExist:
self.stdout.write("people@people.world user not found", ending="\n")
else:
domain_name = "test.domain.com"
domain = MailDomain.objects.get_or_create(
name=domain_name,
defaults={
"status": enums.MailDomainStatusChoices.ENABLED,
"support_email": f"support@{domain_name}",
},
)[0]
self.create_domain(domain_name)
# create accesses for john doe
self.create_user(name=people_base_user.sub)
self.create_allow(people_base_user.sub, domain_name)
MailDomainAccess.objects.get_or_create(
user=people_base_user,
domain=domain,
@@ -97,7 +91,7 @@ class Command(BaseCommand):
if options["populate_from_people"]:
self._populate_dimail_from_people()
self.stdout.write("DONE", ending="\n")
self.stdout.write("DONE 🎉", ending="\n")
def create_user(
self,
@@ -148,41 +142,12 @@ class Command(BaseCommand):
)
)
def create_allow(self, user, domain):
"""
Send a request to create a new allows between user and domain using DimailAPIClient.
"""
response = self.client.create_allow(user, domain)
if response.status_code == status.HTTP_201_CREATED:
self.stdout.write(
self.style.SUCCESS(
f"Creating permissions for {user} on {domain} ........ OK"
)
)
else:
self.stdout.write(
self.style.ERROR(
f"Creating permissions for {user} on {domain}\
........ failed: {response.json()['detail']}"
)
)
def _populate_dimail_from_people(self):
self.stdout.write("Creating accounts from people database", ending="\n")
user_to_create = set()
domain_to_create = set()
access_to_create = set()
for mail_access in MailDomainAccess.objects.select_related(
"domain", "user"
).all():
user_to_create.add(mail_access.user)
domain_to_create.add(mail_access.domain)
access_to_create.add(mail_access)
"""Populate dimail so that it reflects people's domains."""
self.stdout.write("Creating domain from people database", ending="\n")
# create missing domains
for domain in domain_to_create:
for domain in MailDomain.objects.all():
# enforce domain status
if domain.status != enums.MailDomainStatusChoices.ENABLED:
self.stdout.write(
@@ -191,15 +156,3 @@ class Command(BaseCommand):
domain.status = enums.MailDomainStatusChoices.ENABLED
domain.save()
self.create_domain(domain.name)
# create missing users
for user in user_to_create:
self.create_user(
auth=(admin["username"], admin["password"]),
name=user.sub,
perms=[], # no permission needed for "classic" users
)
# create missing accesses
for access in access_to_create:
self.create_allow(access.user.sub, access.domain.name)
@@ -0,0 +1,25 @@
# Generated by Django 5.2.5 on 2025-09-08 12:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0025_alter_mailbox_secondary_email'),
]
operations = [
migrations.AlterUniqueTogether(
name='mailbox',
unique_together=set(),
),
migrations.AddConstraint(
model_name='mailbox',
constraint=models.UniqueConstraint(fields=('local_part', 'domain'), name='unique_username'),
),
migrations.AddConstraint(
model_name='mailbox',
constraint=models.UniqueConstraint(fields=('first_name', 'last_name', 'domain'), name='unique_ox_display_name'),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2025-10-21 15:49
import django.db.models.functions.text
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0026_alter_mailbox_unique_together_and_more'),
]
operations = [
migrations.RemoveConstraint(
model_name='mailbox',
name='unique_ox_display_name',
),
migrations.AddConstraint(
model_name='mailbox',
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('first_name'), django.db.models.functions.text.Lower('last_name'), models.F('domain'), name='unique_ox_display_name', violation_error_message='Mailbox with this First name, Last name and Domain already exists.'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.2.6 on 2025-10-13 12:55
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0027_remove_mailbox_unique_ox_display_name_and_more'),
]
operations = [
migrations.CreateModel(
name='Alias',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('local_part', models.CharField(max_length=100)),
('destination', models.CharField(max_length=254, validators=[django.core.validators.EmailValidator(allowlist=['localhost', 'devnull'])], verbose_name='destination address')),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aliases', to='mailbox_manager.maildomain')),
],
options={
'verbose_name': 'Alias',
'verbose_name_plural': 'Aliases',
'db_table': 'people_aliases',
'ordering': ['-created_at'],
'unique_together': {('local_part', 'destination')},
},
),
]
@@ -0,0 +1,21 @@
# Generated by Django 5.2.9 on 2025-12-17 17:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0028_alias'),
]
operations = [
migrations.AlterUniqueTogether(
name='alias',
unique_together=set(),
),
migrations.AddConstraint(
model_name='alias',
constraint=models.UniqueConstraint(fields=('domain', 'local_part', 'destination'), name='no_duplicate'),
),
]
+99 -1
View File
@@ -9,7 +9,9 @@ from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core.validators import EmailValidator
from django.db import models
from django.db.models.functions import Lower
from django.template.loader import render_to_string
from django.utils.text import slugify
from django.utils.translation import get_language, gettext, override
@@ -88,6 +90,8 @@ class MailDomain(BaseModel):
def __str__(self):
return self.name
objects = models.Manager()
def save(self, *args, **kwargs):
"""Override save function to compute the slug."""
self.slug = self.get_slug()
@@ -303,7 +307,23 @@ class Mailbox(AbstractBaseUser, BaseModel):
db_table = "people_mail_box"
verbose_name = _("Mailbox")
verbose_name_plural = _("Mailboxes")
unique_together = ("local_part", "domain")
constraints = [
models.UniqueConstraint(
fields=["local_part", "domain"], name="unique_username"
),
models.UniqueConstraint(
Lower("first_name"),
Lower("last_name"),
"domain",
name="unique_ox_display_name",
violation_error_message="Mailbox with this First name, \
Last name and Domain already exists.",
),
# Display name in OpenXChange must be unique
# To avoid sending failing requests to dimail,
# we impose uniqueness here too
# And compare to lowercase to enforce case-insensitive uniqueness
]
ordering = ["-created_at"]
def __str__(self):
@@ -341,6 +361,25 @@ class Mailbox(AbstractBaseUser, BaseModel):
"""Return the email address of the mailbox."""
return f"{self.local_part}@{self.domain.name}"
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
role = user.mail_domain_accesses.get(domain=self.domain).role
is_owner_or_admin = role in [
MailDomainRoleChoices.OWNER,
MailDomainRoleChoices.ADMIN,
]
is_self = self.get_email() == user.email
return {
"get": bool(role),
"post": is_owner_or_admin,
"patch": is_owner_or_admin or is_self,
"put": is_owner_or_admin or is_self,
"delete": False,
}
class MailDomainInvitation(BaseInvitation):
"""User invitation to mail domains."""
@@ -416,3 +455,62 @@ class MailDomainInvitation(BaseInvitation):
"patch": False,
"put": False,
}
class Alias(BaseModel):
"""Model for aliases."""
local_part = models.CharField(max_length=100, blank=False)
destination = models.CharField(
_("destination address"),
max_length=254,
null=False,
blank=False,
validators=[
EmailValidator(allowlist=["localhost", "devnull"]),
],
)
domain = models.ForeignKey(
MailDomain,
on_delete=models.CASCADE,
related_name="aliases",
null=False,
blank=False,
)
class Meta:
db_table = "people_aliases"
verbose_name = _("Alias")
verbose_name_plural = _("Aliases")
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["domain", "local_part", "destination"], name="no_duplicate"
)
]
def __str__(self):
return f"{self.local_part} to {self.destination}"
def get_abilities(self, user):
"""Compute and return abilities for a given user. Admin and owners can
edit aliases, but also viewer if the alias points to their email."""
try:
role = user.mail_domain_accesses.get(domain=self.domain).role
except (MailDomainAccess.DoesNotExist, IndexError):
role = None
is_owner_or_admin = role in [
MailDomainRoleChoices.OWNER,
MailDomainRoleChoices.ADMIN,
]
is_self = self.destination == user.email
return {
"get": bool(role),
"post": is_owner_or_admin,
"patch": False,
"put": False,
"delete": is_owner_or_admin or is_self,
}
+5 -2
View File
@@ -23,7 +23,6 @@ def convert_domain_invitations(sender, created, instance, **kwargs): # pylint:
Convert valid domain invitations to domain accesses for a given user.
Expired invitations are ignored.
"""
logger.info("Convert domain invitations for user %s", instance)
if created:
valid_domain_invitations = MailDomainInvitation.objects.filter(
email=instance.email,
@@ -36,6 +35,11 @@ def convert_domain_invitations(sender, created, instance, **kwargs): # pylint:
if not valid_domain_invitations.exists():
return
logger.info(
"Converting %s domain invitations for new user %s",
len(valid_domain_invitations),
instance,
)
MailDomainAccess.objects.bulk_create(
[
MailDomainAccess(
@@ -46,4 +50,3 @@ def convert_domain_invitations(sender, created, instance, **kwargs): # pylint:
)
valid_domain_invitations.delete()
logger.info("Invitations converted to domain accesses for user %s", instance)
@@ -0,0 +1,129 @@
"""
Tests for aliases API endpoint in People's app mailbox_manager.
Focus on "bulk delete" action.
"""
# pylint: disable=W0613
import re
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_aliases_bulk_delete__anonymous_get_401():
"""Anonymous user should not be able to bulk delete."""
mail_domain = factories.MailDomainFactory()
alias_, _, _ = factories.AliasFactory.create_batch(3, domain=mail_domain)
client = APIClient()
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert models.Alias.objects.count() == 3
def test_api_aliases_bulk_delete__no_access_get_404():
"""User with no access to domain should not be able to bulk delete."""
mail_domain = factories.MailDomainFactory()
alias_, _, _ = factories.AliasFactory.create_batch(3, domain=mail_domain)
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.Alias.objects.count() == 3
def test_api_aliases_bulk_delete__viewer_get_403():
"""Viewer user should not be able to bulk delete."""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.VIEWER)
alias_, _, _ = factories.AliasFactory.create_batch(3, domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/?local_part={alias_.local_part}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Alias.objects.count() == 3
@responses.activate
def test_api_aliases_bulk_delete__administrators_allowed_all_destination(
dimail_token_ok,
):
"""
Administrators of a domain should be allowed to bulk delete all aliases
of a given local_part.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
factories.AliasFactory.create_batch(
2, domain=mail_domain, local_part=alias_.local_part
)
# additional aliases that shouldn't be affected
factories.AliasFactory.create_batch(
2, domain=mail_domain, destination=alias_.destination
)
factories.AliasFactory(
local_part=alias_.local_part,
destination=alias_.destination,
)
# Mock dimail response
responses.delete(
re.compile(r".*/aliases/"),
status=status.HTTP_204_NO_CONTENT,
content_type="application/json",
)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert models.Alias.objects.count() == 3
assert not models.Alias.objects.filter(
domain=mail_domain, local_part=alias_.local_part
).exists()
def test_api_aliases_bulk_delete__no_local_part_bad_request():
"""Filtering by local part is mandatory when bulk deleting aliases."""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
factories.AliasFactory.create_batch(
2, domain=mail_domain, local_part=alias_.local_part
)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert models.Alias.objects.count() == 3
@@ -0,0 +1,212 @@
"""
Tests for aliases API endpoint.
Focus on "create" action.
"""
# pylint: disable=W0613
import re
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_aliases_create__anonymous():
"""Anonymous user should not create aliases"""
domain = factories.MailDomainEnabledFactory()
response = APIClient().post(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
{"whatever": "this should not be updated"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert not models.Alias.objects.exists()
def test_api_aliases_create__no_access_forbidden_not_found():
"""Unrelated user not having domain permission should not create aliases."""
domain = factories.MailDomainEnabledFactory()
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
{"local_part": "intrusive", "destination": "intrusive@mail.com"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert not models.Alias.objects.exists()
def test_api_aliases_create__viewer_forbidden():
"""Domain viewers should not create aliases."""
domain = factories.MailDomainEnabledFactory()
access = factories.MailDomainAccessFactory(role="viewer", domain=domain)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
{"local_part": "intrusive", "destination": "intrusive@mail.com"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert not models.Alias.objects.exists()
@responses.activate
def test_api_aliases_create__duplicate_forbidden():
"""Cannot create alias if existing alias same domain + local part + destination."""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
existing_alias = factories.AliasFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{
"local_part": existing_alias.local_part,
"destination": existing_alias.destination,
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert models.Alias.objects.filter(domain=access.domain).count() == 1
@responses.activate
def test_api_aliases_create__async_alias_bad_request(dimail_token_ok):
"""
If People fall out of sync with dimail, return a clear error if alias cannot be created
because it already exists on dimail.
"""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
client = APIClient()
client.force_login(access.user)
# Mock dimail response
responses.post(
re.compile(r".*/aliases/"),
json={"detail": "Alias already exists"},
status=status.HTTP_409_CONFLICT,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{
"local_part": "already_existing_alias",
"destination": "someone@outsidedomain.com",
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == {
"NON_FIELD_ERRORS": [
"Alias already exists. Your domain is out of sync, please contact our support."
]
}
assert not models.Alias.objects.exists()
@responses.activate
@pytest.mark.parametrize(
"role",
[enums.MailDomainRoleChoices.OWNER, enums.MailDomainRoleChoices.ADMIN],
)
def test_api_aliases_create__admins_ok(role, dimail_token_ok):
"""Domain admins should be able to create aliases."""
access = factories.MailDomainAccessFactory(role=role)
client = APIClient()
client.force_login(access.user)
# Prepare responses
# token response in fixtures
responses.post(
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
json={
"username": "contact",
"domain": access.domain.name,
"destination": "someone@outsidedomain.com",
"allow_to_send": True,
},
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{"local_part": "contact", "destination": "someone@outsidedomain.com"},
)
assert response.status_code == status.HTTP_201_CREATED
alias = models.Alias.objects.get()
assert alias.local_part == "contact"
assert alias.destination == "someone@outsidedomain.com"
@responses.activate
def test_api_aliases_create__existing_mailbox_ok(dimail_token_ok):
"""Can create alias even if local_part is already used by a mailbox."""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
mailbox = factories.MailboxFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
responses.post(
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
json={
"username": mailbox.local_part,
"domain": access.domain.name,
"destination": "someone@outsidedomain.com",
"allow_to_send": False,
},
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{"local_part": mailbox.local_part, "destination": "someone@outsidedomain.com"},
)
assert response.status_code == status.HTTP_201_CREATED
assert models.Alias.objects.exists()
@responses.activate
def test_api_aliases_create__devnull_destination_ok(dimail_token_ok):
"""Can create alias where destination is devnull@devnull."""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
client = APIClient()
client.force_login(access.user)
responses.post(
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
json={
"username": "spammy-address",
"domain": access.domain.name,
"destination": "devnull@devnull",
"allow_to_send": False,
},
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{"local_part": "spammy-address", "destination": "devnull@devnull"},
)
assert response.status_code == status.HTTP_201_CREATED
assert models.Alias.objects.exists()
@@ -0,0 +1,139 @@
"""
Tests for aliases API endpoint in People's app mailbox_manager.
Focus on "delete" action.
"""
# pylint: disable=W0613
import re
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_aliases_delete__anonymous():
"""Anonymous user should not be able to delete aliases."""
alias_ = factories.AliasFactory()
response = APIClient().delete(
f"/api/v1.0/mail-domains/{alias_.domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert models.Alias.objects.count() == 1
def test_api_aliases_delete__no_access_forbidden_not_found():
"""
Authenticated users should not be allowed to delete an alias in a
mail domain to which they are not related.
"""
authenticated_user = core_factories.UserFactory()
alias_ = factories.AliasFactory()
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{alias_.domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.Alias.objects.count() == 1
def test_api_aliases_delete__viewer_forbidden():
"""
Authenticated users should not be allowed to delete aliases for a
mail domain in which they are a simple viewer.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Alias.objects.count() == 1
@responses.activate
def test_api_aliases_delete__administrators_allowed(dimail_token_ok):
"""
Administrators of a mail domain should be allowed to delete aliases.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
factories.AliasFactory.create_batch(
2, domain=mail_domain, local_part=alias_.local_part
)
# additional aliases that shouldn't be affected
factories.AliasFactory.create_batch(
2, domain=mail_domain, destination=alias_.destination
)
factories.AliasFactory(
local_part=alias_.local_part,
destination=alias_.destination,
)
# Mock dimail response
responses.delete(
re.compile(r".*/aliases/"),
status=status.HTTP_204_NO_CONTENT,
content_type="application/json",
)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert models.Alias.objects.count() == 5
@responses.activate
def test_api_aliases_delete__404_out_of_sync(dimail_token_ok):
"""
Should return a clear message when dimail returns 404 not found.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
# Mock dimail response
responses.delete(
re.compile(r".*/aliases/"),
json={"detail": "Not found"},
status=status.HTTP_404_NOT_FOUND,
content_type="application/json",
)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_200_OK
assert (
response.json()
== "Domain out of sync with mailbox provider, please contact our support."
)
assert not models.Alias.objects.exists()
@@ -0,0 +1,65 @@
"""
Tests for aliases API endpoint in People's app mailbox_manager.
Focus on "list" action.
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
def test_api_aliases_list__anonymous():
"""Anonymous user should not be able to list aliases"""
domain = factories.MailDomainEnabledFactory()
factories.AliasFactory.create_batch(3, domain=domain)
response = APIClient().get(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_aliases_list__no_access_forbidden_not_found():
"""User authenticated but not having domain permission should not list aliases."""
factories.MailDomainAccessFactory() # access to another domain
domain = factories.MailDomainEnabledFactory()
factories.AliasFactory.create_batch(3, domain=domain)
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.get(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
"role",
[
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
],
)
def test_api_aliases_list__authorized_ok(role):
"""Domain viewers and admins should be able to list aliases."""
access = factories.MailDomainAccessFactory(role=role)
factories.AliasFactory.create_batch(2, local_part="support", domain=access.domain)
factories.AliasFactory.create_batch(3, domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.get(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["results"] == sorted(
response.json()["results"], key=lambda x: x["local_part"]
)
assert response.json()["count"] == 5
@@ -35,7 +35,7 @@ def test_api_domain_invitations__create__anonymous():
}
def test_api_domain_invitations__create__authenticated_outsider():
def test_api_domain_invitations__create__no_access_forbidden_not_found():
"""Users should not be permitted to send domain management invitations
for a domain they don't manage."""
user = core_factories.UserFactory()
@@ -52,7 +52,7 @@ def test_api_domain_invitations__create__authenticated_outsider():
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
@@ -86,7 +86,7 @@ def test_api_domain_invitations__admin_should_create_invites(role):
assert email.subject == "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
def test_api_domain_invitations__viewers_should_not_invite_to_manage_domains():
def test_api_domain_invitations__no_access_forbidden_not_found():
"""
Domain viewers should not be able to invite new domain managers.
"""
@@ -110,7 +110,7 @@ def test_api_domain_invitations__viewers_should_not_invite_to_manage_domains():
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You are not allowed to manage invitations for this domain."
"detail": "You do not have permission to perform this action."
}
@@ -141,3 +141,26 @@ def test_api_domain_invitations__should_not_create_duplicate_invitations():
assert response.json()["__all__"] == [
"Mail domain invitation with this Email address and Domain already exists."
]
def test_api_domain_invitations__should_not_invite_when_user_already_exists():
"""Already existing users should not be invited but given access directly."""
existing_user = core_factories.UserFactory()
# Grant privileged role on the domain to the user
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build(email=existing_user.email)
).data
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
@@ -0,0 +1,76 @@
"""
Tests for MailDomainInvitation API endpoint in People's app mailbox_manager.
Focus on "delete" action.
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from mailbox_manager import factories, models
pytestmark = pytest.mark.django_db
def test_api_domain_invitations__delete__anonymous():
"""Anonymous users should not be able to delete invitations."""
domain = factories.MailDomainEnabledFactory()
invitation = factories.MailDomainInvitationFactory()
response = APIClient().delete(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.MailDomainInvitation.objects.count() == 1
def test_api_domain_invitations__delete__no_access_not_found():
"""Users should not be permitted to delete invitations
on domains they don't manage."""
domain = factories.MailDomainEnabledFactory()
invitation = factories.MailDomainInvitationFactory()
other_access = factories.MailDomainAccessFactory(role="owner") # unrelated access
client = APIClient()
client.force_login(other_access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.MailDomainInvitation.objects.count() == 1
def test_api_domain_invitations__delete__viewers_forbidden():
"""Domain viewers should not be permitted to delete invitations."""
access = factories.MailDomainAccessFactory(role="viewer")
invitation = factories.MailDomainInvitationFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.MailDomainInvitation.objects.count() == 1
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_domain_invitations__delete_admins_ok(role):
"""Domain owners and admins should be able to delete invitations."""
access = factories.MailDomainAccessFactory(role=role)
invitation = factories.MailDomainInvitationFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not models.MailDomainInvitation.objects.exists()
@@ -26,7 +26,7 @@ def test_api_domain_invitations__anonymous_user_should_not_retrieve_invitations(
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_domain_invitations__unrelated_user_should_not_retrieve_invitations():
def test_api_domain_invitations__no_access_forbidden_not_found():
"""
Authenticated unrelated users should not be able to retrieve invitations.
"""
@@ -40,8 +40,8 @@ def test_api_domain_invitations__unrelated_user_should_not_retrieve_invitations(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 0
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomain matches the given query."}
def test_api_domain_invitations__domain_managers_should_list_invitations():
@@ -41,17 +41,14 @@ def test_api_mail_domains__create_name_unique():
"""
Creating domain should raise an error if already existing name.
"""
factories.MailDomainFactory(name="existing_domain.com")
user = core_factories.UserFactory()
existing_domain = factories.MailDomainFactory()
client = APIClient()
client.force_login(user)
client.force_login(core_factories.UserFactory())
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": "existing_domain.com",
},
{"name": existing_domain.name},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -71,8 +68,7 @@ def test_api_mail_domains__create_authenticated():
domain_name = "test.domain.fr"
responses.add(
responses.POST,
responses.post(
re.compile(r".*/domains/"),
body=str(
{
@@ -84,17 +80,15 @@ def test_api_mail_domains__create_authenticated():
)
body_content_domain1 = CHECK_DOMAIN_BROKEN.copy()
body_content_domain1["name"] = domain_name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain_name}/check/"),
body=json.dumps(body_content_domain1),
json=body_content_domain1,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain_name}/spec/"),
body=json.dumps(DOMAIN_SPEC),
json=DOMAIN_SPEC,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -158,30 +152,25 @@ def test_api_mail_domains__create_dimail_domain(caplog):
client.force_login(user)
domain_name = "test.fr"
responses.add(
responses.POST,
responses.post(
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
json={
"name": domain_name,
}
status=status.HTTP_201_CREATED,
content_type="application/json",
)
body_content_domain1 = CHECK_DOMAIN_OK.copy()
body_content_domain1["name"] = domain_name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain_name}/check/"),
body=json.dumps(body_content_domain1),
json=body_content_domain1,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain_name}/spec/"),
body=json.dumps(DOMAIN_SPEC),
json=DOMAIN_SPEC,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -216,8 +205,7 @@ def test_api_mail_domains__no_creation_when_dimail_duplicate(caplog):
"status_code": status.HTTP_409_CONFLICT,
"detail": "Domain already exists",
}
responses.add(
responses.POST,
responses.post(
re.compile(r".*/domains/"),
body=str({"detail": dimail_error["detail"]}),
status=dimail_error["status_code"],
@@ -2,7 +2,6 @@
Tests for MailDomains API endpoint in People's mailbox manager app. Focus on "fetch" action.
"""
import json
import re
import pytest
@@ -82,7 +81,7 @@ def test_api_mail_domains__fetch_from_dimail__viewer():
],
)
@responses.activate
def test_api_mail_domains__fetch_from_dimail(role):
def test_api_mail_domains__fetch_from_dimail_admin_successful(role):
"""
Authenticated users should be allowed to fetch a domain
from dimail if they are an owner or admin.
@@ -105,14 +104,12 @@ def test_api_mail_domains__fetch_from_dimail(role):
assert domain.expected_config is None
assert domain.last_check_details is None
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
json=dimail_fixtures.CHECK_DOMAIN_OK,
status=200,
)
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/spec/"),
json=dimail_fixtures.DOMAIN_SPEC,
status=200,
@@ -69,7 +69,7 @@ def test_api_mail_domains__retrieve_authenticated_unrelated():
@responses.activate
def test_api_mail_domains__retrieve_authenticated_related():
def test_api_mail_domains__retrieve_authenticated_related_successful():
"""
Authenticated users should be allowed to retrieve a domain
to which they have access.
@@ -35,7 +35,7 @@ def test_api_mail_domain__accesses_create_anonymous():
assert models.MailDomainAccess.objects.exists() is False
def test_api_mail_domain__accesses_create_authenticated_unrelated():
def test_api_mail_domain__accesses_create_no_access_forbidden_not_found():
"""
Authenticated users should not be allowed to create domain accesses for a domain to
which they are not related.
@@ -56,14 +56,14 @@ def test_api_mail_domain__accesses_create_authenticated_unrelated():
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"detail": "You are not allowed to manage accesses for this domain."
"detail": "No MailDomain matches the given query.",
}
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
def test_api_mail_domain__accesses_create_authenticated_viewer():
def test_api_mail_domain__accesses_create_viewer_forbidden():
"""Viewer of a mail domain should not be allowed to create mail domain accesses."""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
@@ -85,7 +85,7 @@ def test_api_mail_domain__accesses_create_authenticated_viewer():
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You are not allowed to manage accesses for this domain."
"detail": "You do not have permission to perform this action.",
}
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
@@ -13,7 +13,7 @@ from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_delete_anonymous():
def test_api_domain_accesses_delete__anonymous_forbidden():
"""Anonymous users should not be allowed to destroy a mail domain access."""
access = factories.MailDomainAccessFactory()
@@ -25,7 +25,7 @@ def test_api_mail_domain__accesses_delete_anonymous():
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_authenticated():
def test_api_domain_accesses_delete__unrelated_notfound():
"""
Authenticated users should not be allowed to delete a mail domain access for a
mail domain to which they are not related.
@@ -39,11 +39,11 @@ def test_api_mail_domain__accesses_delete_authenticated():
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_viewer():
def test_api_domain_accesses_delete__viewer_forbidden():
"""
Authenticated users should not be allowed to delete a mail domain access for a
mail domain in which they are a simple viewer.
@@ -65,7 +65,7 @@ def test_api_mail_domain__accesses_delete_viewer():
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
def test_api_mail_domain__accesses_delete_administrators():
def test_api_domain_accesses_delete__administrators_successful():
"""
Administrators of a mail domain should be allowed to delete accesses excepted owner accesses.
"""
@@ -89,7 +89,7 @@ def test_api_mail_domain__accesses_delete_administrators():
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_owners():
def test_api_domain_accesses_delete__owners_successful():
"""
An owner should be able to delete the mail domain access of another user including
a owner access.
@@ -114,7 +114,7 @@ def test_api_mail_domain__accesses_delete_owners():
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_owners_last_owner():
def test_api_domain_accesses_delete__last_owner_forbidden():
"""
It should not be possible to delete the last owner access from a mail domain
"""
@@ -27,8 +27,7 @@ def test_api_mail_domain__accesses_list_anonymous():
def test_api_mail_domain__accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list mail_domain accesses for a mail_domain
to which they are not related.
User with no access should not be allowed to list domain accesses.
"""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
@@ -43,12 +42,9 @@ def test_api_mail_domain__accesses_list_authenticated_unrelated():
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
"detail": "No MailDomain matches the given query.",
}
@@ -178,8 +174,12 @@ def test_api_mail_domain__accesses_list_authenticated_constant_numqueries(
# related users :
# - query retrieving logged-in user for user_role annotation
# - count from pagination
# - filter on slug and domain on permission (to return 404 instead of 403 when no access)
# - count ?
# - distinct from viewset
with django_assert_num_queries(3):
with django_assert_num_queries(5):
client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
@@ -189,7 +189,7 @@ def test_api_mail_domain__accesses_list_authenticated_constant_numqueries(
factories.MailDomainAccessFactory(domain=mail_domain)
# num queries should still be the same
with django_assert_num_queries(3):
with django_assert_num_queries(5):
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
@@ -43,7 +43,7 @@ def test_api_mail_domain__accesses_retrieve_authenticated_unrelated():
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomainAccess matches the given query."}
assert response.json() == {"detail": "No MailDomain matches the given query."}
# Accesses related to another mail_domain should be excluded even if the user is related to it
for other_access in [
@@ -55,9 +55,7 @@ def test_api_mail_domain__accesses_retrieve_authenticated_unrelated():
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"detail": "No MailDomainAccess matches the given query."
}
assert response.json() == {"detail": "No MailDomain matches the given query."}
def test_api_mail_domain__accesses_retrieve_authenticated_related():
@@ -27,7 +27,7 @@ def test_api_mail_domain__accesses_update_anonymous():
assert access.role == enums.MailDomainRoleChoices.VIEWER
def test_api_mail_domain__accesses_update_authenticated_unrelated():
def test_api_mail_domain__accesses_update_no_access_forbidden_not_found():
"""
An authenticated user should not be allowed to update a mail domain access
for a mail_domain to which they are not related.
@@ -42,7 +42,7 @@ def test_api_mail_domain__accesses_update_authenticated_unrelated():
{"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.VIEWER
@@ -30,9 +30,7 @@ def test_api_mail_domain__available_users_anonymous():
def test_api_mail_domain__available_users_forbidden():
"""Authenticated user without accesses on maildomain should not be able to see available
users.
"""
"""Users with no access should not be able to list available users."""
authenticated_user = core_factories.UserFactory()
client = APIClient()
client.force_login(authenticated_user)
@@ -40,7 +38,7 @@ def test_api_mail_domain__available_users_forbidden():
response = client.get(f"/api/v1.0/mail-domains/{maildomain.slug}/accesses/users/")
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
File diff suppressed because it is too large Load Diff
@@ -26,7 +26,7 @@ def test_api_mailboxes__disable_anonymous_forbidden():
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.ENABLED
def test_api_mailboxes__disable_authenticated_failure():
def test_api_mailboxes__disable_no_access_forbidden_not_found():
"""Authenticated users should not be able to disable mailbox
without specific role on mail domain."""
user = core_factories.UserFactory()
@@ -39,7 +39,7 @@ def test_api_mailboxes__disable_authenticated_failure():
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/disable/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.ENABLED
@@ -26,9 +26,8 @@ def test_api_mailboxes__enable_anonymous_forbidden():
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.DISABLED
def test_api_mailboxes__enable_authenticated_failure():
"""Authenticated users should not be able to enable mailbox
without specific role on mail domain."""
def test_api_mailboxes__enable_unrelated_not_found():
"""Unrelated users should not be able to enable mailbox."""
user = core_factories.UserFactory()
client = APIClient()
@@ -39,7 +38,7 @@ def test_api_mailboxes__enable_authenticated_failure():
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/enable/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.DISABLED
@@ -25,8 +25,8 @@ def test_api_mailboxes__list_anonymous():
}
def test_api_mailboxes__list_authenticated():
"""Authenticated users should not be able to list mailboxes"""
def test_api_mailboxes__list_unrelated_not_found():
"""Unrelated users should not be able to list mailboxes"""
user = core_factories.UserFactory()
client = APIClient()
@@ -36,10 +36,8 @@ def test_api_mailboxes__list_authenticated():
factories.MailboxFactory.create_batch(2, domain=mail_domain)
response = client.get(f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/")
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomain matches the given query."}
@pytest.mark.parametrize(
@@ -0,0 +1,190 @@
"""
Unit tests for the mailbox API
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
def test_api_mailboxes_patch__anonymous_forbidden():
"""Anonymous users should not be able to update a mailbox."""
mailbox = factories.MailboxFactory()
saved_secondary = mailbox.secondary_email
response = APIClient().patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{"secondary_email": "updated@example.com"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
mailbox.refresh_from_db()
assert mailbox.secondary_email == saved_secondary
def test_api_mailboxes_patch__no_access_forbidden_not_found():
"""Authenticated but unauthoriezd users should not be able to update mailboxes."""
mailbox = factories.MailboxFactory()
saved_secondary = mailbox.secondary_email
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.local_part}/",
{"secondary_email": "updated@example.com"},
format="json",
)
# permission denied at domain level
assert response.status_code == status.HTTP_404_NOT_FOUND
mailbox.refresh_from_db()
assert mailbox.secondary_email == saved_secondary
def test_api_mailboxes_patch__viewer_forbidden():
"""User having viewer access on a domain should not be able to update
anything on a mailbox that's not theirs."""
viewer = core_factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailDomainAccessFactory(
user=viewer,
domain=mailbox.domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(viewer)
# should not be able to update any field
# we only try one field as 403 is global in our implementation
old_value = mailbox.secondary_email
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{"secondary_email": "updated@example.com"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mailbox.refresh_from_db()
assert mailbox.secondary_email == old_value
def test_api_mailboxes_patch__unauthorized_no_mailbox():
"""No mailbox returns a 404."""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.ADMIN)
client = APIClient()
client.force_login(access.user)
response = client.patch(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/nonexistent_mailbox_pk/",
{"secondary_email": "updated@example.com"},
format="json",
)
# permission denied at domain level
# the existence of the mailbox is not checked
assert response.status_code == status.HTTP_404_NOT_FOUND
# UPDATABLE FIELDS : SECONDARY EMAIL FIRST NAME AND LAST NAME
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_mailboxes_patch__admins_can_update_mailboxes(role):
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory()
factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=role,
)
client = APIClient()
client.force_login(user)
valid_new_values = {
"secondary_email": "updated_mail@validformat.com",
"first_name": "Marsha",
"last_name": "Johnson",
}
for field_name in ["first_name", "last_name", "secondary_email"]:
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{field_name: valid_new_values[field_name]},
format="json",
)
assert response.status_code == status.HTTP_200_OK
mailbox.refresh_from_db()
assert getattr(mailbox, field_name) == valid_new_values[field_name]
def test_api_mailboxes_patch__viewer_can_update_own_mailbox():
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory(email=f"{mailbox.local_part}@{mailbox.domain}")
factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(user)
valid_new_values = {
"secondary_email": "updated_mail@validformat.com",
"first_name": "Marsha",
"last_name": "Johnson",
}
for field_name in ["first_name", "last_name", "secondary_email"]:
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{field_name: valid_new_values[field_name]},
format="json",
)
assert response.status_code == status.HTTP_200_OK
mailbox.refresh_from_db()
assert getattr(mailbox, field_name) == valid_new_values[field_name]
# DOMAIN AND LOCAL PART
@pytest.mark.parametrize(
"role",
["viewer", "owner", "administrator"],
)
def test_api_mailboxes_patch__no_updating_domain_or_local_part(role):
"""Domain and local parts should not be updated."""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory()
if role == "viewer":
# viewer has similar privileges as admins on own mailbox
user = core_factories.UserFactory(
email=f"{mailbox.local_part}@{mailbox.domain}"
)
access = factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=role,
)
client = APIClient()
client.force_login(user)
other_domain = factories.MailDomainEnabledFactory()
local_part = mailbox.local_part
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{"local_part": "new.local.part", "domain": other_domain.name},
format="json",
)
assert response.status_code == status.HTTP_200_OK # a 400 would be better
mailbox.refresh_from_db()
assert mailbox.local_part == local_part
assert mailbox.domain == access.domain
@@ -0,0 +1,192 @@
"""
Unit tests for the mailbox API
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
# PUT expects all the object's updatable fields.
# We'll use this dict on all the following tests
NEW_VALUES = {
"secondary_email": "marsha.p@social.us",
"first_name": "Marsha",
"last_name": "Johnson",
}
def test_api_mailboxes_put__anonymous_forbidden():
"""Anonymous users should not be able to update a mailbox."""
mailbox = factories.MailboxFactory()
saved_secondary = mailbox.secondary_email
response = APIClient().put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
mailbox.refresh_from_db()
assert mailbox.secondary_email == saved_secondary
def test_api_mailboxes_put__no_access_forbidden_not_found():
"""Authenticated but unauthoriezd users should not be able to update mailboxes."""
client = APIClient()
client.force_login(core_factories.UserFactory())
mailbox = factories.MailboxFactory()
saved_secondary = mailbox.secondary_email
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
format="json",
)
# permission denied at domain level
assert response.status_code == status.HTTP_404_NOT_FOUND
mailbox.refresh_from_db()
assert mailbox.secondary_email == saved_secondary
def test_api_mailboxes_put__unauthorized_no_mailbox():
"""Authenticated but unauthoriezd users should not be able to update mailboxes."""
client = APIClient()
client.force_login(core_factories.UserFactory())
domain = factories.MailDomainEnabledFactory()
response = client.put(
f"/api/v1.0/mail-domains/{domain.slug}/mailboxes/nonexistent_mailbox_pk/",
NEW_VALUES,
format="json",
)
# permission denied at domain level
# the existence of the mailbox is not checked
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_mailboxes_put__viewer_forbidden():
"""User having viewer access on a domain should not be able to update
anything on a mailbox that's not theirs."""
viewer = core_factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailDomainAccessFactory(
user=viewer,
domain=mailbox.domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(viewer)
# should not be able to update any field
# we only try one field as 403 is global in our implementation
old_value = mailbox.secondary_email
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mailbox.refresh_from_db()
assert mailbox.secondary_email == old_value
# UPDATABLE FIELDS : SECONDARY EMAIL, FIRST NAME AND LAST NAME
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_mailboxes_put__admins_can_update_mailboxes(role):
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory()
factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=role,
)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
format="json",
)
result = response.json()
assert response.status_code == status.HTTP_200_OK
mailbox.refresh_from_db()
assert result["first_name"] == NEW_VALUES["first_name"]
assert result["last_name"] == NEW_VALUES["last_name"]
assert result["secondary_email"] == NEW_VALUES["secondary_email"]
def test_api_mailboxes_put__viewer_can_update_own_mailbox():
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory(email=f"{mailbox.local_part}@{mailbox.domain}")
factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
format="json",
)
result = response.json()
assert response.status_code == status.HTTP_200_OK
mailbox.refresh_from_db()
assert result["first_name"] == NEW_VALUES["first_name"]
assert result["last_name"] == NEW_VALUES["last_name"]
assert result["secondary_email"] == NEW_VALUES["secondary_email"]
# DOMAIN AND LOCAL PART
@pytest.mark.parametrize(
"role",
["viewer", "owner", "administrator"],
)
def test_api_mailboxes_put__no_updating_domain_or_local_part(role):
"""Domain and local parts should not be updated."""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory()
if role == "viewer":
# viewer has similar privileges as admins on own mailbox
user = core_factories.UserFactory(
email=f"{mailbox.local_part}@{mailbox.domain}"
)
access = factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=role,
)
client = APIClient()
client.force_login(user)
other_domain = factories.MailDomainEnabledFactory()
old_local_part = mailbox.local_part
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{**NEW_VALUES, "local_part": "new.local.part", "domain": other_domain.name},
format="json",
)
assert response.status_code == status.HTTP_200_OK # This should not be a 200
mailbox.refresh_from_db()
assert mailbox.local_part == old_local_part
assert mailbox.domain == access.domain
@@ -0,0 +1,179 @@
"""
Unit tests for the reset password mailbox API
"""
from unittest import mock
from django.conf import settings
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
from mailbox_manager.tests.fixtures import dimail
pytestmark = pytest.mark.django_db
def test_api_mailboxes__reset_password_anonymous_unauthorized():
"""Anonymous users should not be able to reset mailboxes password."""
mailbox = factories.MailboxFactory(status=enums.MailboxStatusChoices.ENABLED)
response = APIClient().post(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/reset_password/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_mailboxes__reset_password_no_access_forbidden_not_found():
"""Authenticated users not managing the domain
should not be able to reset its mailboxes password."""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
mailbox = factories.MailboxFactory(status=enums.MailboxStatusChoices.ENABLED)
response = client.post(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"detail": "No MailDomain matches the given query.",
}
def test_api_mailboxes__reset_password_viewer_forbidden():
"""Domain viewers should not be able to reset passwords on mailboxes."""
mailbox = factories.MailboxEnabledFactory()
viewer_access = factories.MailDomainAccessFactory(
role=enums.MailDomainRoleChoices.VIEWER, domain=mailbox.domain
)
client = APIClient()
client.force_login(viewer_access.user)
response = client.post(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_mailboxes__reset_password_no_secondary_email():
"""Should not try to reset password if no secondary email is specified."""
mail_domain = factories.MailDomainEnabledFactory()
access = factories.MailDomainAccessFactory(
role=enums.MailDomainRoleChoices.OWNER, domain=mail_domain
)
client = APIClient()
client.force_login(access.user)
error = "Password reset requires a secondary email address. \
Please add a valid secondary email before trying again."
# Mailbox with no secondary email
mailbox = factories.MailboxEnabledFactory(domain=mail_domain, secondary_email=None)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == [error]
# Mailbox with empty secondary email
mailbox = factories.MailboxEnabledFactory(domain=mail_domain, secondary_email="")
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == [error]
# Mailbox with primary email as secondary email
mailbox = factories.MailboxEnabledFactory(domain=mail_domain)
mailbox.secondary_email = str(mailbox)
mailbox.save()
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == [error]
@pytest.mark.parametrize(
"role",
[
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
],
)
@responses.activate
def test_api_mailboxes__reset_password_admin_successful(role, dimail_token_ok):
"""Owner and admin users should be able to reset password on mailboxes.
New password should be sent to secondary email."""
mail_domain = factories.MailDomainEnabledFactory()
mailbox = factories.MailboxEnabledFactory(domain=mail_domain)
access = factories.MailDomainAccessFactory(role=role, domain=mail_domain)
client = APIClient()
client.force_login(access.user)
dimail_url = settings.MAIL_PROVISIONING_API_URL
responses.post(
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
body=dimail.response_mailbox_created(str(mailbox)),
status=200,
)
with mock.patch("django.core.mail.send_mail") as mock_send:
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert mock_send.call_count == 1
assert "Your password has been updated" in mock_send.mock_calls[0][1][1]
assert mock_send.mock_calls[0][1][3][0] == mailbox.secondary_email
assert response.status_code == status.HTTP_200_OK
def test_api_mailboxes__reset_password_non_existing():
"""
User gets a 404 when trying to reset password of mailbox which does not exist.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/mail-domains/nonexistent.domain/mailboxes/")
assert response.status_code == status.HTTP_404_NOT_FOUND
@responses.activate
def test_api_mailboxes__reset_password_connexion_failed(dimail_token_ok):
"""
No mail is sent when password reset failed because of connexion error.
"""
mail_domain = factories.MailDomainEnabledFactory()
mailbox = factories.MailboxEnabledFactory(domain=mail_domain)
access = factories.MailDomainAccessFactory(
role=enums.MailDomainRoleChoices.OWNER, domain=mail_domain
)
client = APIClient()
client.force_login(access.user)
dimail_url = settings.MAIL_PROVISIONING_API_URL
responses.post(
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
body=ConnectionError(),
)
with pytest.raises(ConnectionError):
with mock.patch("django.core.mail.send_mail") as mock_send:
client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert mock_send.call_count == 0
@@ -0,0 +1,68 @@
"""
Unit tests for the mailbox API
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import factories
pytestmark = pytest.mark.django_db
def test_api_mailboxes__retrieve_anonymous_forbidden():
"""Anonymous users should not be able to retrieve a new mailbox via the API."""
mailbox = factories.MailboxFactory()
response = APIClient().get(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_mailboxes__retrieve_no_access_forbidden_not_found():
"""Authenticated but unauthorized users should not be able to
retrieve mailbox. Response should be indistinguisable from normal 404"""
client = APIClient()
client.force_login(core_factories.UserFactory())
mailbox = factories.MailboxFactory()
response = client.get(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
# even if they have a mailbox on this domain
mailbox = factories.MailboxFactory()
client.force_login(core_factories.UserFactory(email=str(mailbox)))
response = client.get(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_mailboxes__retrieve_authorized_ok():
"""Authorized users should be able to retrieve mailboxes."""
access = factories.MailDomainAccessFactory()
mailbox = factories.MailboxFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.get(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(mailbox.id),
"first_name": mailbox.first_name,
"last_name": mailbox.last_name,
"local_part": mailbox.local_part,
"secondary_email": mailbox.secondary_email,
"status": mailbox.status,
}
@@ -6,8 +6,6 @@ from django.core.management import call_command
import pytest
import responses
from core import factories
from mailbox_manager import factories as mailbox_factories
from mailbox_manager.management.commands.setup_dimail_db import DIMAIL_URL, admin
@@ -22,23 +20,12 @@ def test_commands_setup_dimail_db(settings):
"""The create_demo management command should create objects as expected."""
settings.DEBUG = True # required to run the command
john_doe = factories.UserFactory(
name="John Doe", email="people@people.world", sub="sub.john.doe"
)
# mock dimail API
responses.add(responses.POST, f"{DIMAIL_URL}/users/", status=201)
responses.add(responses.POST, f"{DIMAIL_URL}/domains/", status=201)
responses.add(responses.POST, f"{DIMAIL_URL}/allows/", status=201)
responses.add(
responses.GET,
responses.post(f"{DIMAIL_URL}/users/", status=201)
responses.post(f"{DIMAIL_URL}/domains/", status=201)
responses.get(
f"{DIMAIL_URL}/users/",
json=[
{
"is_admin": False,
"name": john_doe.sub,
"perms": [],
},
{
"is_admin": True,
"name": "admin",
@@ -59,7 +46,7 @@ def test_commands_setup_dimail_db(settings):
call_command("setup_dimail_db")
# check dimail API received the expected requests
assert len(responses.calls) == 5
assert len(responses.calls) == 2
assert responses.calls[0].request.url == f"{DIMAIL_URL}/users/"
assert (
responses.calls[0].request.body
@@ -72,25 +59,6 @@ def test_commands_setup_dimail_db(settings):
b'"perms": ["new_domain", "create_users", "manage_users"]}'
)
assert responses.calls[2].request.url == f"{DIMAIL_URL}/domains/"
assert responses.calls[2].request.body == (
b'{"name": "test.domain.com", "context_name": "test.domain.com", '
b'"features": ["webmail", "mailbox", "alias"], '
b'"delivery": "virtual"}'
)
assert responses.calls[3].request.url == f"{DIMAIL_URL}/users/"
assert (
responses.calls[3].request.body
== b'{"name": "sub.john.doe", "password": "no", "is_admin": false, "perms": []}'
)
assert responses.calls[4].request.url == f"{DIMAIL_URL}/allows/"
assert (
responses.calls[4].request.body
== b'{"user": "sub.john.doe", "domain": "test.domain.com"}'
)
# reset the responses counter
responses.calls.reset() # pylint: disable=no-member
@@ -102,29 +70,14 @@ def test_commands_setup_dimail_db(settings):
call_command("setup_dimail_db", "--populate-from-people")
# check dimail API received the expected requests
assert (
len(responses.calls) == 5 + 3 + 3
) # calls for some.domain.com and test.domain.com
assert len(responses.calls) == 3 # calls for some.domain.com and test.domain.com
dimail_calls = []
for call in responses.calls[5:]:
for call in responses.calls[3:]:
dimail_calls.append((call.request.url, call.request.body))
assert (
f"{DIMAIL_URL}/domains/",
(
b'{"name": "some.domain.com", "context_name": "some.domain.com", '
b'"features": ["webmail", "mailbox", "alias"], '
b'"delivery": "virtual"}'
),
) in dimail_calls
assert (
f"{DIMAIL_URL}/users/",
(b'{"name": "sub.toto.123", "password": "no", "is_admin": false, "perms": []}'),
) in dimail_calls
assert (
f"{DIMAIL_URL}/allows/",
b'{"user": "sub.toto.123", "domain": "some.domain.com"}',
) in dimail_calls
assert responses.calls[2].request.body == (
b'{"name": "some.domain.com", "context_name": "some.domain.com", '
b'"features": ["webmail", "mailbox", "alias"], '
b'"delivery": "virtual"}'
)
@@ -48,8 +48,7 @@ def test_fetch_domain_status():
(domain_failed, body_content_ok3),
]:
# mock dimail API
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
@@ -0,0 +1,35 @@
"""
Fixtures for mailbox manager tests
"""
import re
import pytest
import responses
from rest_framework import status
from mailbox_manager import factories
## DIMAIL RESPONSES
@pytest.fixture(name="dimail_token_ok")
def fixture_dimail_token_ok():
"""Mock dimail response when /token/ endpoit is given valid credentials."""
responses.get(
re.compile(r".*/token/"),
json={"access_token": "token", "token_type": "bearer"},
status=status.HTTP_200_OK,
content_type="application/json",
)
@pytest.fixture(name="mailbox_data")
def fixture_mailbox_data():
"""Provides valid mailbox data for tests."""
example = factories.MailboxFactory.build()
return {
"first_name": example.first_name,
"last_name": example.last_name,
"local_part": example.local_part,
"secondary_email": example.secondary_email,
}
+4 -14
View File
@@ -3,11 +3,10 @@
import json
## USERS
def response_user_created(user_sub):
"""mimic dimail response upon succesfull user creation."""
"""mimic dimail response upon successful user creation."""
return json.dumps(
{
"name": user_sub,
@@ -19,7 +18,6 @@ def response_user_created(user_sub):
## DOMAINS
CHECK_DOMAIN_BROKEN = {
"name": "example.fr",
"state": "broken",
@@ -289,22 +287,14 @@ DOMAIN_SPEC = [
]
## TOKEN
TOKEN_OK = json.dumps({"access_token": "token", "token_type": "bearer"})
## ALLOWS
def response_allows_created(user_name, domain_name):
"""mimic dimail response upon succesfull allows creation.
"""mimic dimail response upon successful allows creation.
Dimail expects a name but our names are ProConnect's uuids."""
return json.dumps({"user": user_name, "domain": domain_name})
## MAILBOXES
def response_mailbox_created(email_address):
"""mimic dimail response upon succesfull mailbox creation."""
"""mimic dimail response upon successful mailbox creation."""
return json.dumps({"email": email_address, "password": "password"})
@@ -0,0 +1,19 @@
"""
Unit tests for the Alias model
"""
import pytest
from mailbox_manager import factories, models
pytestmark = pytest.mark.django_db
def test_models_aliases__devnull_destination_ok():
"""Can create alias where destination is devnull@devnull."""
models.Alias.objects.create(
local_part="spam",
domain=factories.MailDomainEnabledFactory(),
destination="devnull@devnull",
)
@@ -38,11 +38,15 @@ def test_models_domain_invitations__is_expired():
assert expired_invitation.is_expired is True
def test_models_domain_invitation__should_convert_invitations_to_accesses_upon_joining():
def test_models_domain_invitation__should_convert_invitations_to_accesses_upon_joining(
caplog,
):
"""
Upon creating a new user, domain invitations linked to that email
should be converted to accesses and then deleted.
"""
caplog.set_level("INFO")
# Two invitations to the same mail but to different domains
email = "future_admin@example.com"
invitation_to_domain1 = factories.MailDomainInvitationFactory(
@@ -81,3 +85,8 @@ def test_models_domain_invitation__should_convert_invitations_to_accesses_upon_j
assert models.MailDomainInvitation.objects.filter(
domain=invitation_to_domain2.domain, email=other_invitation.email
).exists() # the other invitation remains
log_messages = [msg.message for msg in caplog.records]
assert (
f"Converting 2 domain invitations for new user {str(new_user)}" in log_messages
)
@@ -2,7 +2,6 @@
Unit tests for admin actions
"""
import json
import re
from django.urls import reverse
@@ -19,7 +18,6 @@ from .fixtures.dimail import (
CHECK_DOMAIN_BROKEN,
CHECK_DOMAIN_OK,
DOMAIN_SPEC,
TOKEN_OK,
response_mailbox_created,
)
@@ -76,17 +74,15 @@ def test_fetch_domain_status__should_switch_to_failed_when_domain_broken(client)
body_content_domain1["name"] = domain1.name
body_content_domain2 = CHECK_DOMAIN_BROKEN.copy()
body_content_domain2["name"] = domain2.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain1.name}/check/"),
body=json.dumps(body_content_domain1),
json=body_content_domain1,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain2.name}/check/"),
body=json.dumps(body_content_domain2),
json=body_content_domain2,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -101,7 +97,9 @@ def test_fetch_domain_status__should_switch_to_failed_when_domain_broken(client)
@responses.activate
@pytest.mark.django_db
def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(client):
def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(
client, dimail_token_ok
):
"""Test admin action should switch domain state to ENABLED
when dimail's response is "ok". It should also activate any pending mailbox."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
@@ -119,22 +117,14 @@ def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(client):
body_content_domain1 = CHECK_DOMAIN_OK.copy()
body_content_domain1["name"] = domain1.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain1.name}/check/"),
body=json.dumps(body_content_domain1),
json=body_content_domain1,
status=status.HTTP_200_OK,
content_type="application/json",
)
# we need to get a token to create mailboxes
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
# token call in fixtures
responses.post(
responses.POST,
re.compile(rf".*/domains/{domain1.name}/mailboxes/"),
body=response_mailbox_created(f"truc@{domain1.name}"),
@@ -172,10 +162,9 @@ def test_fetch_domain_expected_config(client, domain_status):
"action": "fetch_domain_expected_config_from_dimail",
"_selected_action": [domain.id],
}
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/spec/"),
body=json.dumps(DOMAIN_SPEC),
json=DOMAIN_SPEC,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -204,3 +193,69 @@ def test_fetch_domain_expected_config__should_not_fetch_for_disabled_domain(clie
assert "Domains disabled are excluded from fetch" in response.content.decode(
"utf-8"
)
@responses.activate
@pytest.mark.django_db
def test_send_pending_mailboxes(client, dimail_token_ok):
"""Test admin action to send pending mailboxes to dimail."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
domain = factories.MailDomainFactory(status=enums.MailDomainStatusChoices.ENABLED)
mailboxes = factories.MailboxFactory.create_batch(
3, status=enums.MailboxStatusChoices.PENDING, domain=domain
)
data = {
"action": "send_pending_mailboxes",
"_selected_action": [domain.id],
}
url = reverse("admin:mailbox_manager_maildomain_changelist")
for mailbox in mailboxes:
# token call in fixture
responses.post(
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(f"{mailbox.local_part}@{domain.name}"),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(url, data, follow=True)
assert response.status_code == status.HTTP_200_OK
for mailbox in mailboxes:
mailbox.refresh_from_db()
assert mailbox.status == enums.MailboxStatusChoices.ENABLED
@responses.activate
@pytest.mark.django_db
def test_send_pending_mailboxes__listing_failed_mailboxes(client, dimail_token_ok):
"""Test admin action to send pending mailboxes to dimail."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
domain = factories.MailDomainFactory(status=enums.MailDomainStatusChoices.ENABLED)
mailbox = factories.MailboxFactory(
status=enums.MailboxStatusChoices.PENDING, domain=domain
)
data = {
"action": "send_pending_mailboxes",
"_selected_action": [domain.id],
}
url = reverse("admin:mailbox_manager_maildomain_changelist")
# token call in fixtures
responses.post(
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(f"{mailbox.local_part}@{domain.name}"),
status=status.HTTP_409_CONFLICT,
content_type="application/json",
)
response = client.post(url, data, follow=True)
assert response.status_code == status.HTTP_200_OK
assert (
f"Failed to send the following mailboxes : {str(mailbox)}"
in response.content.decode("utf-8")
)
mailbox.refresh_from_db()
assert mailbox.status == enums.MailboxStatusChoices.PENDING
@@ -2,7 +2,6 @@
Unit tests for mailbox manager tasks.
"""
import json
import re
from unittest import mock
@@ -63,18 +62,16 @@ def test_fetch_domain_status_task_success(): # pylint: disable=too-many-locals
(domain_failed, body_content_ok3),
]:
# Mock dimail API with success response
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
json=body_content,
status=200,
content_type="application/json",
)
# domain_enabled2 is broken with internal error, we try to fix it
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain_enabled2.name}/fix/"),
body=json.dumps(body_content_broken_internal),
json=body_content_broken_internal,
status=200,
content_type="application/json",
)
@@ -170,10 +167,9 @@ def test_fetch_domains_status_error_handling(caplog):
domain = factories.MailDomainEnabledFactory()
# Mock dimail API with error response
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps({"error": "Internal Server Error"}),
json={"error": "Internal Server Error"},
status=500,
content_type="application/json",
)
@@ -2,12 +2,10 @@
Unit tests for dimail client
"""
import json
# pylint: disable=W0613
import logging
import re
from email.errors import HeaderParseError, NonASCIILocalPartDefect
from logging import Logger
from unittest import mock
import pytest
import responses
@@ -21,14 +19,14 @@ from .fixtures.dimail import (
CHECK_DOMAIN_BROKEN_EXTERNAL,
CHECK_DOMAIN_BROKEN_INTERNAL,
CHECK_DOMAIN_OK,
TOKEN_OK,
response_mailbox_created,
)
pytestmark = pytest.mark.django_db
def test_dimail_synchronization__already_sync():
@responses.activate
def test_dimail_synchronization__already_sync(dimail_token_ok):
"""
No mailbox should be created when everything is already synced.
"""
@@ -39,35 +37,26 @@ def test_dimail_synchronization__already_sync():
assert pre_sync_mailboxes.count() == 3
dimail_client = DimailAPIClient()
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "dimail_people_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.GET,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=str(
[
{
"type": "mailbox",
"status": "broken",
"email": f"{mailbox.local_part}@{domain.name}",
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
}
for mailbox in pre_sync_mailboxes
]
),
status=status.HTTP_200_OK,
content_type="application/json",
)
imported_mailboxes = dimail_client.import_mailboxes(domain)
# Ensure successful response using "responses":
# token response in fixtures
responses.get(
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
json=[
{
"type": "mailbox",
"status": "broken",
"email": f"{mailbox.local_part}@{domain.name}",
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
}
for mailbox in pre_sync_mailboxes
],
status=status.HTTP_200_OK,
content_type="application/json",
)
imported_mailboxes = dimail_client.import_mailboxes(domain)
post_sync_mailboxes = models.Mailbox.objects.filter(domain=domain)
assert post_sync_mailboxes.count() == 3
@@ -75,97 +64,159 @@ def test_dimail_synchronization__already_sync():
assert set(models.Mailbox.objects.filter(domain=domain)) == set(pre_sync_mailboxes)
@mock.patch.object(Logger, "warning")
def test_dimail_synchronization__synchronize_mailboxes(mock_warning):
"""A mailbox existing solely on dimail should be synchronized
upon calling sync function on its domain"""
@responses.activate
def test_dimail_synchronization__synchronize_mailboxes(caplog, dimail_token_ok): # pylint: disable=W0613, R0914
"""Importing mailboxes from dimail should synchronize valid mailboxes
and log errors for invalid ones."""
caplog.set_level(logging.INFO)
domain = factories.MailDomainEnabledFactory()
assert not models.Mailbox.objects.exists()
existing_alias = factories.AliasFactory(domain=domain)
dimail_client = DimailAPIClient()
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "dimail_people_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
# Ensure successful response using "responses":
# successful token in fixtures
mailbox_valid = {
"type": "mailbox",
"status": "ok",
"email": f"validmailbox@{domain.name}",
"givenName": "Michael",
"surName": "Roch",
"displayName": "Michael Roch",
}
mailbox_oxadmin = {
"type": "mailbox",
"status": "broken",
"email": f"oxadmin@{domain.name}",
"givenName": "Admin",
"surName": "Context",
"displayName": "Context Admin",
}
mailbox_with_wrong_domain = {
"type": "mailbox",
"status": "broken",
"email": "johndoe@wrongdomain.com",
"givenName": "John",
"surName": "Doe",
"displayName": "John Doe",
}
mailbox_with_invalid_domain = {
"type": "mailbox",
"status": "broken",
"email": f"naw@ake@{domain.name}",
"givenName": "Joe",
"surName": "Doe",
"displayName": "Joe Doe",
}
mailbox_with_invalid_local_part = {
"type": "mailbox",
"status": "broken",
"email": f"obalmaské@{domain.name}",
"givenName": "Jean",
"surName": "Vang",
"displayName": "Jean Vang",
}
mailbox_existing_alias = {
"type": "mailbox",
"status": "broken",
"email": f"{existing_alias.local_part}@{domain.name}",
"givenName": "Support",
"surName": "email",
"displayName": "Support email",
}
mailbox_valid = {
"type": "mailbox",
"status": "broken",
"email": f"oxadmin@{domain.name}",
"givenName": "Admin",
"surName": "Context",
"displayName": "Context Admin",
}
mailbox_with_wrong_domain = {
"type": "mailbox",
"status": "broken",
"email": "johndoe@wrongdomain.com",
"givenName": "John",
"surName": "Doe",
"displayName": "John Doe",
}
mailbox_with_invalid_domain = {
"type": "mailbox",
"status": "broken",
"email": f"naw@ake@{domain.name}",
"givenName": "Joe",
"surName": "Doe",
"displayName": "Joe Doe",
}
mailbox_with_invalid_local_part = {
"type": "mailbox",
"status": "broken",
"email": f"obalmaské@{domain.name}",
"givenName": "Jean",
"surName": "Vang",
"displayName": "Jean Vang",
}
responses.get(
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
json=[
mailbox_valid,
mailbox_oxadmin,
mailbox_with_wrong_domain,
mailbox_with_invalid_domain,
mailbox_with_invalid_local_part,
mailbox_existing_alias,
],
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.GET,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=str(
[
mailbox_valid,
mailbox_with_wrong_domain,
mailbox_with_invalid_domain,
mailbox_with_invalid_local_part,
]
),
status=status.HTTP_200_OK,
content_type="application/json",
)
imported_mailboxes = dimail_client.import_mailboxes(domain)
imported_mailboxes = dimail_client.import_mailboxes(domain)
# 4 imports failed: oxadmin, wrong domain, HeaderParseError, NonASCIILocalPartDefect
assert len(caplog.records) == 5
log_messages = [record.message for record in caplog.records]
# 3 imports failed: wrong domain, HeaderParseError, NonASCIILocalPartDefect
assert mock_warning.call_count == 3
expected_messages = [
f"Not importing OX technical address: oxadmin@{domain.name}",
f"Import of email {mailbox_with_wrong_domain['email']} failed because of a wrong domain",
f"Import of email {mailbox_with_invalid_domain['email']} failed with error Invalid Domain",
f"Import of email {mailbox_with_invalid_local_part['email']} failed with error local-part \
contains non-ASCII characters)",
]
for message in expected_messages:
assert message in log_messages
# first we try to import email with a wrong domain
assert mock_warning.call_args_list[0][0] == (
"Import of email %s failed because of a wrong domain",
mailbox_with_wrong_domain["email"],
)
assert models.Mailbox.objects.count() == 2
assert imported_mailboxes == [
mailbox_valid["email"],
mailbox_existing_alias["email"],
]
# then we try to import email with invalid domain
invalid_mailbox_log = mock_warning.call_args_list[1][0]
assert invalid_mailbox_log[1] == mailbox_with_invalid_domain["email"]
assert isinstance(invalid_mailbox_log[2], HeaderParseError)
# finally we try to import email with non ascii local part
non_ascii_mailbox_log = mock_warning.call_args_list[2][0]
assert non_ascii_mailbox_log[1] == mailbox_with_invalid_local_part["email"]
assert isinstance(non_ascii_mailbox_log[2], NonASCIILocalPartDefect)
@responses.activate
def test_dimail_synchronization__synchronize_aliases(dimail_token_ok): # pylint: disable=unused-argument
"""Importing aliases from dimail should synchronize valid aliases
and log errors for invalid ones."""
alias = factories.AliasFactory()
dimail_client = DimailAPIClient()
mailbox = models.Mailbox.objects.get()
assert mailbox.local_part == "oxadmin"
assert mailbox.status == enums.MailboxStatusChoices.ENABLED
assert imported_mailboxes == [mailbox_valid["email"]]
existing_mailbox = factories.MailboxFactory(domain=alias.domain)
# Ensure successful response using "responses":
# token response in fixtures
incoming_aliases = [
{
"username": "contact",
"domain": alias.domain.name,
"destination": alias.destination, # same destination = ok
"allow_to_send": False,
},
{
"username": alias.local_part, # same username = ok
"domain": alias.domain.name,
"destination": "maheius.endorecles@somethingelse.com",
"allow_to_send": False,
},
{ # same username + same destination = big nono
"username": alias.local_part,
"domain": alias.domain.name,
"destination": alias.destination,
"allow_to_send": False,
},
{ # mailbox with same username = ok
"username": existing_mailbox.local_part,
"domain": alias.domain.name,
"destination": existing_mailbox.secondary_email,
"allow_to_send": False,
},
{ # alias to devnull@devnull
"username": "spam",
"domain": alias.domain.name,
"destination": "devnull@devnull",
"allow_to_send": False,
},
]
responses.get(
re.compile(rf".*/domains/{alias.domain.name}/aliases/"),
json=incoming_aliases,
status=status.HTTP_200_OK,
content_type="application/json",
)
imported_aliases = dimail_client.import_aliases(alias.domain)
assert len(imported_aliases) == 4
assert models.Alias.objects.count() == 5
@pytest.mark.parametrize(
@@ -183,10 +234,9 @@ def test_dimail__fetch_domain_status__switch_to_enabled(domain_status):
domain = factories.MailDomainFactory(status=domain_status)
body_content = CHECK_DOMAIN_OK.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
json=body_content,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -221,10 +271,9 @@ def test_dimail__fetch_domain_status__switch_to_action_required(
domain = factories.MailDomainFactory(status=domain_status)
body_domain_broken = CHECK_DOMAIN_BROKEN_EXTERNAL.copy()
body_domain_broken["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_broken),
json=body_domain_broken,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -238,10 +287,9 @@ def test_dimail__fetch_domain_status__switch_to_action_required(
# Now domain is OK again
body_domain_ok = CHECK_DOMAIN_OK.copy()
body_domain_ok["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_ok),
json=body_domain_ok,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -267,18 +315,16 @@ def test_dimail__fetch_domain_status__switch_to_failed(domain_status):
# nothing can be done by support team, domain should be in failed
body_domain_broken = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_domain_broken["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_broken),
json=body_domain_broken,
status=status.HTTP_200_OK,
content_type="application/json",
)
# the endpoint fix is called and still returns KO for internal checks
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/fix/"),
body=json.dumps(body_domain_broken),
json=body_domain_broken,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -305,10 +351,9 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
# with all checks KO, domain should be in action required
body_domain_broken = CHECK_DOMAIN_BROKEN.copy()
body_domain_broken["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_broken),
json=body_domain_broken,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -324,20 +369,18 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
# the fetch_domain_status call
body_domain_broken_internal = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_domain_broken_internal["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_broken_internal),
json=body_domain_broken_internal,
status=status.HTTP_200_OK,
content_type="application/json",
)
# the endpoint fix is called and returns OK. Hooray!
body_domain_ok = CHECK_DOMAIN_OK.copy()
body_domain_ok["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/fix/"),
body=json.dumps(body_domain_ok),
json=body_domain_ok,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -348,9 +391,10 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
assert domain.last_check_details == body_domain_ok
def test_dimail__enable_pending_mailboxes(caplog):
@responses.activate
def test_dimail__send_pending_mailboxes(caplog, dimail_token_ok):
"""Status of pending mailboxes should switch to "enabled"
when calling enable_pending_mailboxes."""
when calling send_pending_mailboxes."""
caplog.set_level(logging.INFO)
domain = factories.MailDomainFactory()
@@ -365,22 +409,16 @@ def test_dimail__enable_pending_mailboxes(caplog):
)
dimail_client = DimailAPIClient()
with responses.RequestsMock() as rsps:
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(f"mock@{domain.name}"),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_client.enable_pending_mailboxes(domain=domain)
# Ensure successful response using "responses":
# token response in fixtures
responses.post(
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(f"mock@{domain.name}"),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_client.send_pending_mailboxes(domain=domain)
mailbox1.refresh_from_db()
mailbox2.refresh_from_db()
@@ -388,7 +426,7 @@ def test_dimail__enable_pending_mailboxes(caplog):
assert mailbox2.status == enums.MailboxStatusChoices.ENABLED
log_messages = [msg.message for msg in caplog.records]
assert "Token succesfully granted by mail-provisioning API." in log_messages
assert "Token successfully granted by mail-provisioning API." in log_messages
assert (
f"Mailbox successfully created on domain {domain.name} by user None"
in log_messages

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