Compare commits

..

150 Commits

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

closes #293
2024-08-08 13:18:31 +02:00
daproclaima 14deca13f4 🚸(frontend) improve screen reading navigation
- add aria-hidden and empty alt attributes for screen readers
to ignore decorative svg and images.
- remove icon from input field used to name a group
- update translations
- update related e2e and components tests
2024-08-08 12:16:20 +02:00
daproclaima 72340db74c 💬(frontend) update texts
- change message showed in mailbox list when none exist
- change CTA button text for group management modals
- fix 404 page title
- update translations
- update related e2e tests
2024-08-08 12:16:20 +02:00
renovate[bot] d812197381 ⬆️(dependencies) update django to v5.0.8 [SECURITY] 2024-08-08 11:41:14 +02:00
Sabrina Demagny c00c59b301 (mail) add first_name and last_name for Mailbox
Mail provisioning API needs a full name to create a new mailbox.
2024-08-07 15:03:09 +02:00
Marie PUPO JEAMMET 402e73582c (tests) improve tests for mailbox api
Regroup mailbox-related tests
+ add test 404 when trying to retrive a domain that doesn't exist
+ use enabled domains on tests
2024-08-07 10:23:49 +02:00
Sabrina Demagny b637774179 (mail) manage mailboxes permissions
Manage create and list permissions for all roles.
2024-08-06 16:00:00 +02:00
Marie PUPO JEAMMET 87e7d3e0b1 🚚(swagger) move swagger under /api/
Swagger was under /v1.0/swagger.
I just wanna move it under /api/ where the rest of the API is.
2024-08-05 16:49:27 +02:00
Sabrina Demagny eba9fb2d09 🚸(models) use a viewer role for MailDomainAccess
Rename member role to viewer role for MailDomainAccess.
A viewer is only allowed to see list of mailboxes
created for a domain. It makes more sense to name it viewer.
2024-08-05 15:05:15 +02:00
daproclaima e03ecb2d77 ️(frontend) change panel text color
- change text color displayed when panel of teams or mail domains is empty
to create more contrast between text and background colors
2024-08-05 12:42:13 +02:00
daproclaima 7e03d33be0 🚸(frontend) improve keyboard navigation
- add css rules to highlight focused-visible navigable elements
- update drop down components to make it keyboard navigable
- add e2e keyboard navigation tests asserting it navigates through
all focusable elements from top to bottom on groups index view
when one group exists
2024-08-05 12:42:13 +02:00
renovate[bot] 779c7d1e0e ⬆️(dependencies) update python dependencies 2024-08-05 11:05:18 +02:00
Sabrina Demagny b1e1de0269 🧑‍💻(backend) do not allow to delete a domain
At the moment a domain cannot be deleted.
We will be able to delete only pending domains and
simply turn to disabled an enabled domain.
2024-08-02 17:43:16 +02:00
Sabrina Demagny 63dee08be5 🧑‍💻(backend) add missing test on domain
Check the mail domain status after creation;
2024-08-02 17:43:16 +02:00
Anthony LC 582e3f5a05 (github) fix e2e workflow
An update to ubuntu made the command "docker-compose" not
working anymore. This commit fixes the issue.
2024-08-02 17:23:02 +02:00
Marie PUPO JEAMMET df59bfd1ee ⚰️(dead) remove obsolete file
remove obsolete file ... i think ?
It seems setup.cfg has been replaced by pyproject.toml
2024-08-02 12:34:02 +02:00
Marie PUPO JEAMMET c26786a107 🩺(coverage) add config and make rule to compute coverage
Configure pytest-cov settings in pyproject.toml and add make rule
to compute test coverage.
2024-08-02 12:34:02 +02:00
Anthony LC ca179b8811 ⬇️(frontend) downgrade @typescript-eslint/eslint-plugin to 7.13.1
@typescript-eslint/eslint-plugin released the
version 8, but it is causing some issues
(@typescript-eslint/no-duplicate-enum-values).
We downgrade it to 7.13.1 in waiting for a fix.
2024-08-01 10:22:10 +02:00
Anthony LC b413d8a915 ⬇️(frontend) downgrading fetch-mock to 9.11.0
fetch-mock released the version 10, but it is
causing some issues with jest because of
esm support.
2024-08-01 10:22:10 +02:00
renovate[bot] a07be7ecd7 ⬆️(dependencies) update js dependencies 2024-08-01 10:22:10 +02:00
Sabrina Demagny ab54d5af8f (backend) allow to filter member on team access endpoint
Filter member by name...
2024-07-31 16:01:32 +02:00
renovate[bot] 958f48f9e8 ⬆️(dependencies) update python dependencies 2024-07-31 12:22:42 +02:00
Marie PUPO JEAMMET 94b0c27775 📝(readme) update admin credentials in README
README was not correctly updated after last major modif to the admin auth,
resulting in confusion for users trying to log-in with email instead of sub.
2024-07-31 11:41:33 +02:00
renovate[bot] 953eefcb66 ⬆️(dependencies) update python dependencies 2024-07-22 14:34:47 +02:00
renovate[bot] 4f173c65d5 ⬆️(dependencies) update sentry-sdk to v2.8.0 [SECURITY] 2024-07-19 15:49:47 +02:00
renovate[bot] 99d3de6833 ⬆️(dependencies) update requests to v2.32.2 [SECURITY] 2024-07-19 15:15:58 +02:00
daproclaima e0000ca89c 💄(frontend) change elements positions in mailbox creation form
- remove icon on top of mailbox creation form
- change some text positions
- fix fields error style on hover
- update form field names translations
- update related e2e tests
2024-07-16 15:26:59 +02:00
daproclaima 32e6602dda 🚸(frontend) improve mailbox creation validations and error handling
- replace known error causes returned by the API on unsuccessful mailbox
creation requests by meaningful interpolated message shown above the
form
- strengthen form validation rules to be identical as those of
the api endpoint to prevent emitting invalid requests
- designate which form fields are mandatory for accessiblity
- update texts for better ux writting, and their translations
- fix css style input errors
- update related e2e tests.
2024-07-16 15:26:59 +02:00
renovate[bot] cda4373544 ⬆️(dependencies) update django to v5.0.7 [SECURITY] 2024-07-12 19:23:55 +02:00
Sabrina Demagny 8d7614c512 (models) add TeamAccess models on admin view
Declare TeamAccessAdmin
2024-07-10 16:40:48 +02:00
Sabrina Demagny 955a3dd226 (models) add MailDomain status
Add some status to allow to create mailboxes only for an active domain
2024-07-10 16:17:42 +02:00
daproclaima d69861c148 🔥(frontend) remove use of phone number data for mailbox creation
Remove phone number field from mailbox creation form and requests
to comply with GPDR as it is not an information we need to
collect for creating a mailbox. Deletes translations about phone
number field in mailbox creation form. Updates related e2e tests.
2024-07-04 11:31:52 +02:00
Marie PUPO JEAMMET 66300aca66 🧑‍💻(models) improve user str representation
Improve user model str representation to display name or email
if provided. Otherwise, returns sub as last resort.
2024-07-03 17:16:22 +02:00
daproclaima 2598f3649a (e2e) check default lang attribut of html tag
Asserts that default lang attribute of html tag
matches with the language used by default in
browser for supported languages.
2024-07-02 09:19:34 +02:00
daproclaima d3589dfca1 (e2e) check lang attribute on html tag updates
Asserts that lang attribute of html tag updates according to
language chosen by user.
2024-07-02 09:19:34 +02:00
daproclaima dfb5394310 ️(frontend) update html lang on language change
Performs lang attribute update on html tag on every language change.
By default the attribute value uses the i18n.language value.
2024-07-02 09:19:34 +02:00
Anthony LC bea23cc6e9 💬(frontend) change app name to Regie
The app was named Equipes, it is now Régie.
2024-07-01 17:16:47 +02:00
Anthony LC 7c36f1e775 💬(frontend) change text logo Marianne
Change the text of the logo Marianne.
It was "Devise" and now it's "République Francaise".
2024-07-01 17:16:47 +02:00
Anthony LC 8dcd6d5bb8 💄(frontend) fix flickering on datagrid sorting member
The datagrid was flickering when sorting the member list.
This was due to the fact that the list was getting
empty and then filled again with the sorted list
causing the flickering.
2024-07-01 16:56:34 +02:00
Anthony LC 5c87bd74cd 🏷️(frontend) adapt ordering name
Some ordering names changed with a prefix "user__".
We adapt the ordering name in the
frontend to match the backend.
2024-07-01 16:56:34 +02:00
Anthony LC a400c12037 🚚(frontend) change teams feature architecture
Changes the teams feature architecture, centralizing
all the teams-related features inside a teams folder.
2024-07-01 15:18:18 +02:00
Anthony LC 4d1aafe0d9 🐛(helm) fix createsuperuser command
The createsuperuser command changes recently.
We update the command to reflect the changes.
2024-06-28 11:37:56 +02:00
Anthony LC 0e5cbb1fc8 🐛(app-desk) force redirect on mail-domains/[slug]
When we use the static mode, Next.js want that we
build our mail-domains pages at build time.
But we can't do that because users can create a
mail at runtime.

So we redirect thanks to ngnix when we see that
a mail page is not found.
2024-06-28 10:06:26 +02:00
Anthony LC 56df81ef84 🐛(app-desk) force redirect on teams/[id]
When we use the static mode, Next.js want that we
build our team pages at build time. But we can't do that
because users can create a team at runtime.

So we redirect thanks to ngnix when we see that
a team page is not found.
2024-06-28 10:06:26 +02:00
Anthony LC 5c10ed32d5 🔧(app-desk) trailingSlash options when build
Add trailingSlash options to next.config.js
to be able to reach directly a page without adding
".html" at the end.
2024-06-28 10:06:26 +02:00
renovate[bot] ed24beb0f6 ⬆️(dependencies) update djangorestframework to v3.15.2 [SECURITY] 2024-06-27 18:17:46 +02:00
renovate[bot] 928fed9ac7 ⬆️(dependencies) update python dependencies 2024-06-27 18:08:39 +02:00
Samuel Paccoud - DINUM 2ec292bb91 ♻️(models) remove multiple identities
Multiple identities were complicating this project's code.
We moved the management of multiple identities to our
OIDC provider.
2024-06-27 17:45:23 +02:00
Anthony LC 79330015e0 ✏️(frontend) change "mention légale" to "mentions légales"
Change "mention légale" to "mentions légales".
2024-06-27 14:02:44 +02:00
Anthony LC d05bc60f5e 🧑‍💻(i18n) run prettier on format-deploy
The command format-deploy import a new json
from crowdin, but it is not formatted. This
commit run prettier on the file after the import
to format it.
2024-06-26 17:26:41 +02:00
Anthony LC c7861c6ad4 🐛(i18n) fix key that contain colon
Keys that contains colon where not being
translated correctly. This was due to the
colon being used as a separator for the
key and the value. This was fixed by
replacing the colon with a different
character that is not used in the key
or the value.
2024-06-26 17:26:41 +02:00
Anthony LC 952bea369e 🌐(frontend) add french translations
- Add french translations for the footer
- Add french translations for the mail part
2024-06-26 17:26:41 +02:00
Anthony LC 980c6ee1a4 🚸(app-desk) footer legales pages
- Add legales pages.
- Add links to the footer for the new pages.

Legales pages are based from
https://lasuite.numerique.gouv.fr/
2024-06-26 17:26:41 +02:00
Anthony LC 51c4a29751 ♻️(app-desk) change header html structure
Change a bit the html structure of the header
component to be more aligned.
Logo linked to homepage.
2024-06-26 17:26:41 +02:00
Anthony LC 01d72466a6 (app-desk) add footer
Add footer to the desk app.
2024-06-26 17:26:41 +02:00
Marie PUPO JEAMMET 19c36eafde (tests) fix tests after adding slugs to domains
- slug readonly on admin
- fix test to expect slug in payload, when retrieving a domain
2024-06-25 12:37:34 +02:00
daproclaima 93d4abee58 (e2e) update mail domains tests
Updates tests to use mail domain slug instead of id.
2024-06-25 12:37:34 +02:00
Sabrina Demagny bb3403f10f (mailbox_manager) add slug to MailDomain serializer
add missing field to MailDomain serializer after commit b4bafb6
2024-06-25 12:37:34 +02:00
daproclaima 0da30eb2e1 🚧(app-regie) use mail domain slug in mail domains feature
Stop using mailDomain.id in frontend navigation and mail-domains/
requests. Instead, uses mailDomain slug.
2024-06-25 12:37:34 +02:00
Anthony LC f70604df72 🚚(helm) move secret to desk/templates
With the recent changes to the helm chart,
the secrets.yaml file was not found by
Tilt anymore. This commit moves the file
to the correct location.
2024-06-25 11:36:09 +02:00
Anthony LC e75c8d49b3 👷(CI) add production tag to deploy workflow
Add `production` tag to deploy workflow.
Every tag production will trigger
the deploy workflow to production environment.
2024-06-25 11:36:09 +02:00
Anthony LC 4823c8d9dc 👷(helm) prod configuration
Add the prod configuration to the helm chart.
2024-06-25 11:36:09 +02:00
Jacques ROUSSEL 6d0dbf0d13 💚(CI) upgrade submodule
- fix secrets for staging
2024-06-20 14:17:22 +02:00
Jacques ROUSSEL 4048855b1b 💚(CI) upgrade submodule
- upgrade submodule to use new preprod secret
2024-06-19 17:26:52 +02:00
Anthony LC e2a682cae4 ⬇️(frontend) downgrading fetch-mock to 9.11.0
fetch-mock relesed the version 10, but it is
causing some issues in the frontend tests for
the moment. The adoption is still low, the
documentation is not updated, so let's
downgrade it to 9.11.0 for now.
2024-06-19 15:41:20 +02:00
Anthony LC 366f10689b (frontend) add "@testing-library/dom
Recent update of @testing-library/react requires
@testing-library/dom to be installed as well.
2024-06-19 15:41:20 +02:00
renovate[bot] 922719b13e ⬆️(dependencies) update js dependencies 2024-06-19 15:41:20 +02:00
Anthony LC 3c481e75bb 👷(helm) command createsuperuser
We need a superuser in the Django
application, to have access to the admin part.
This commit create a superuser on the pods.
2024-06-19 13:34:15 +02:00
Anthony LC 9a7a8e4a34 🔥(helm) remove uneeded file
secrets.yaml was duplicated in the helm chart,
we can remove this one.
2024-06-18 15:40:33 +02:00
Anthony LC 905b673413 💚(CI) upgrade submodule
- Change submodule ref to get preprod secret
2024-06-18 15:40:33 +02:00
Anthony LC 21981c6478 💚(CI) remove trigger workflow on push tags
We were starting the workflow on push tags,
it is needed for the docker-hub workflow,
but the other workflows does not need to
be triggered on push tags.
2024-06-18 15:40:33 +02:00
Anthony LC e56c63676e 👷(CI) add deploy workflow
Add the deploy workflow, this workflow will deploy
the application to the selected tag.
2024-06-18 15:40:33 +02:00
Anthony LC 187005d441 👷(helm) preprod configuration
Add the preprod configuration to the helm chart.
2024-06-18 15:40:33 +02:00
Anthony LC 54b7a637fe 🔧(backend) activate https on oidc redirection
mozilla-django-oidc didn't add the `https`
prefix to the redirect_uri.
We set the option SECURE_PROXY_SSL_HEADER to
('HTTP_X_FORWARDED_PROTO', 'https') in the
settings.py file to force the https prefix.
2024-06-18 15:40:33 +02:00
renovate[bot] 35a897fa60 ⬆️(dependencies) update python dependencies 2024-06-16 23:55:07 +02:00
Sabrina Demagny b4bafb6efb (mailbox_manager) modify API to get maildomain
Access to maildomain by slug name
2024-06-13 15:10:04 +02:00
Jacques ROUSSEL 23778fda0d 💚(ci) improve submodule usage
- remove deplucate declaration
- simplify helmfile
- use symlink
2024-06-11 10:46:40 +02:00
daproclaima 0a8c488649 🧑‍💻(frontend) run automatically prettier on translations.json
The frontend translations.json could be validated by
the eslint scripts without running manually
prettier. This commit fixes this by running prettier
script automatically once the frontend translations
are extracted.
2024-06-10 12:38:19 +02:00
Anthony LC e6b5f32a61 🐛(i18n) comma in keys
Having a comma in the keys broke the parser.
This commit allows the comma in keys.
2024-06-10 12:38:19 +02:00
daproclaima 8af47283c8 💬(app-desk) update translations of teams feature
This commit is part of the replacement of "teams" by "groups" wording task.
It changes words in the "teams" feature and add more meaningful wording in
English and French, and removes the icon used in the title of the group
member deletion modal as it rather lets think we are deleting a group.
Updates of related e2e and rendering tests come along with these changes.
2024-06-10 12:38:19 +02:00
Jacques ROUSSEL 8a44718e6b 💚(ci) fix
- fix broken front push docker image
2024-06-07 17:09:55 +02:00
Jacques ROUSSEL 6e7f20eda9 💚(ci) remove secret from repository
- Remove *.enc.*
- Adapt helmfile
- Adapt CI
2024-06-07 16:30:14 +02:00
Sabrina Demagny b3779b5979 🧑‍💻(makefile) improve django migration commands
Add showmigrations command.
Allow to pass args to migrate and makemigrations commands.
2024-06-06 10:30:26 +02:00
Anthony LC 4b80b288f9 ♻️(mails) link email from current site
The link in the email was pointing on the
staging website. We now use a variable to
target the current site setup in the database.
2024-06-05 09:50:09 +02:00
Anthony LC fbec4af261 🧑‍💻(mail) make commands windows friendly
Make the commands windows friendly.
2024-06-05 09:50:09 +02:00
Anthony LC b8499a539e 🐛(frontend) fix duplicate call on getMe
Fix duplicate call on getMe.
The logout process was refactored recently,
the hook dependency is not necessary anymore and
was creating a duplicate call on getMe.
2024-06-04 11:52:36 +02:00
Anthony LC c7d1312f89 ♻️(frontend) frontend environment free
Until now, the front had to know at build time
the url of the backend and the webrtc server
to be able to communicate with them.
It is not optimal because it means that we need
multiple docker image (1 per environment) to have
the app working, it is not very flexible.

This commit will make the frontend "environment free"
by determining these urls at runtime.
2024-06-04 11:52:36 +02:00
Anthony LC 4636c611c6 🔧(helm) add namespace to the templates
The goal of adding a namespace in the templates
is to ensure that resources are deployed
in a specific, possibly isolated part of the Kubernetes cluster.
This helps in organizing resources, managing
permissions, and applying configurations or
limits appropriately within the cluster.
2024-06-04 10:52:17 +02:00
Anthony LC 211d89cae0 🔨(CI) add Tilt
Tilt is a tool for local Kubernetes development.
It makes it easy to see your changes as you
make them, and it rebuilds and redeploys
your app as you change it.
2024-06-04 10:52:17 +02:00
Anthony LC 915731e218 💚(ci) improve secrets for k8s deployment
Avoid secrets to be visible from running deployments
2024-06-04 10:52:17 +02:00
lebaudantoine c534048e97 🔧(compose) stop forcing platform for Keycloak PostgreSQL image
Forcing `platform: linux/amd64` for the PostgreSQL image causes compatibility
issues and performance degradation on Mac ARM chips (M1/M2). Removing the
platform specification allows Docker to select the appropriate architecture
automatically, ensuring better performance and compatibility.
2024-06-03 13:56:28 +02:00
renovate[bot] 5d1e2bd39d ⬆️(dependencies) update python dependencies 2024-06-03 09:49:51 +02:00
Jacques ROUSSEL 67d3e58c82 🐛(ci) improve docker-hub
Avoid to notify argocd for nothing
2024-05-31 17:08:59 +02:00
antoine lebaud e0739689e6 🚨(backend) handle new checks introduced in Pylint v3.2.0
Pylint 3.2.0 introduced a new check `possibly-used-before-assignment`, which
ensures variables are defined regardless of conditional statements.

Some if/else branches were missing defaults. These have been fixed.
2024-05-31 12:53:11 +02:00
renovate[bot] 04717fd629 ⬆️(dependencies) update python dependencies 2024-05-31 12:53:11 +02:00
Lebaud Antoine 087bbf74f6 🔧(helm) setup logout flow from Agent Connect
Add the relevant environment configurations to make sure the backend
in dev and staging environments log out the user from Agent Connect.
2024-05-31 12:14:58 +02:00
Lebaud Antoine 63a875bd5b ♻️(frontend) redirect the user agent to the logout endpoint
Recent updates in the backend views now requires the user agent to be
redirected to the logout endpoint.

The logout endpoint should initiate the logout flow with the OIDC provider,
by redirecting the user to the OIDC provider domain.

Thus, OIDC provider session cookie should be cleared.

E2E tests should be improved later on, when the CI and the development env
use Agent Connect integration environment. The current logout is not working
with the Keycloack configuration.
2024-05-31 12:14:58 +02:00
Lebaud Antoine 7a26f377e3 (backend) support Agent Connect Logout flow
The default Logout view provided by Mozilla Django OIDC is not suitable
for the Agent Connect Logout flow.

Previously, when a user was logging-out, only its Django session was ended.
However, its session in the OIDC provider was still active.

Agent Connect implements a 'session/end' endpoint, that allows services to
end user session when they logout.

Agent Connect logout triggers cannot work with the default views implemented
by the dependency Mozilla Django OIDC. In their implementation, they decided
to end Django Session before redirecting to the OIDC provider.

The Django session needs to be retained during the logout process.

An OIDC state is saved to the request session, pass to Agent Connect Logout
endpoint, and verified when the backend receives the Logout callback from Agent
Connect. It seems to follow OIDC specifications.

If for any reason, the Logout flow cannot be initiated with Agent Connect,
(missing ID token in cache, unauthenticated user, etc), the user is redirected
to the final URL, without interacting with Agent Connect.
2024-05-31 12:14:58 +02:00
Lebaud Antoine 05d9a09d63 🚚(backend) create a dedicated authentication package
Prepare adding advanced authentication features. Create a dedicated
authentication Python package within the core app.

This code organization will be more extensible.
2024-05-31 12:14:58 +02:00
daproclaima 735db606f6 🚚(app-desk) rename useMailDomainMailboxes into useMailboxes
Shortens the hook name and update its imports and exports.
2024-05-29 16:11:59 +02:00
daproclaima 2bf85539f1 (e2e) add mailbox creation tests
Checks user can create a mailbox for a mail domain
and that form fields are visually interactive
according to the form validation state.
2024-05-29 16:11:59 +02:00
daproclaima 6981ef17df (app-desk) create mailbox for a mail domain
Add form to create a mailbox for a mail domain. It sends a http
POST request mail-domains/<mail-domain-id>/mailboxes/ on form
submit. The form appears inside a modal.
Installs react-hook-form, zod, and @hookform/resolvers for form
manipulation and field validation.
2024-05-29 16:11:59 +02:00
daproclaima 37d32888f5 (app-desk) displays specific mail domain mailboxes
Fetches a mail domain by id and displays
its mailboxes as a list in a table.
Associated with e2e tests.
2024-05-24 12:10:44 +02:00
daproclaima d4e0f74d30 (app-desk) add /mail-domains/<id> page
Adds a page in charge of finding the mail domain
matching the id provided in url query params.
In the future the domain name will be used.
2024-05-24 12:10:44 +02:00
Anthony LC d2c7eaaa4b 🐛(app-desk) fix fetchPriority warning
The upgrade to react@18.3.1 has a compatibility
issue with next@14.2.3. It creates a error warning
about the fetchPriority prop. This commit fixes the
issue by downgrading react to 18.2.0 as it was
before the last upgrade.
2024-05-13 17:10:37 +02:00
Anthony LC 46aaf7351d ♻️(app-desk) adapt La Gaufre
Adapt La Gaufre with the new configuration.
2024-05-13 17:10:37 +02:00
daproclaima 76e9d58b6c (app-desk) add sorting to mail domains panel
Adds a button to sort items in the mail domains panel
by creation date. Also adds an e2e test.
2024-05-13 16:23:14 +02:00
daproclaima 9b198d0bab (app-desk) add panel for mail domains
Adds a panel based on teams' one. It fetches all mail-domains
the connected user has relationships with and displays them
as a list of links redirecting to
/mail-domains/<my-domain-name>. Updates e2e tests accordingly.
2024-05-13 16:23:14 +02:00
daproclaima 3f1b446e8e 🚚(app-desk) application mail renamed into mail-domains
Rename any folder or file containing mails into mail-domains.
Update all url redirection links accordingly.
2024-05-13 16:23:14 +02:00
Anthony LC 5049c9b732 ️(frontend) clean yarn.lock
Some compatibility issues with dependencies were
fixed by cleaning the yarn.lock.
2024-05-13 14:54:31 +02:00
renovate[bot] 7f2adb8d2f ⬆️(dependencies) update js dependencies 2024-05-13 14:54:31 +02:00
renovate[bot] b12992f125 ⬆️(dependencies) update python dependencies 2024-05-09 23:15:12 +02:00
Anthony LC 001673f973 💬(app-desk) change some texts
Change some texts on the team page.
2024-05-06 17:00:39 +02:00
Anthony LC 4b4bbc4c0a 🐛(app-desk) fix fetchPriority warning
The upgrade to react@18.3.1 has a compatibility
issue with next@14.2.3. It creates a error warning
about the fetchPriority prop. This commit fixes the
issue by downgrading react to 18.2.0 as it was
before the last upgrade.
2024-05-06 16:50:17 +02:00
Anthony LC b7b90d1bf3 💄(app-desk) keep highlighting menu when sub menu
When sub menu was open, the parent menu was not
highlighted.
This commit fixes this issue.
2024-05-06 12:09:17 +02:00
Anthony LC c599757d7a 🗑️(app-desk) clean the menu
- remove unused icons
- remove unused pages
- remove menu items
2024-05-06 12:09:17 +02:00
renovate[bot] a0992b6ba9 ⬆️(dependencies) update js dependencies 2024-05-06 10:27:25 +02:00
Anthony LC d88f6a5a51 ♻️(app-desk) replace classname spacings
Replace the classname spacings with the new
spacing system based on props.
2024-04-30 11:42:26 +02:00
Anthony LC a45408c93c 🎨(app-desk) add margin and padding to Box
Add margin and padding system to Box component.
It proposes the autocompletion.
It is bind with the Cunninghams spacing system.
2024-04-30 11:42:26 +02:00
Anthony LC c94888fe09 ⬇️(frontend) react 18.3.1 -> 18.2.0
Downgrade react to 18.2.0.
It seems to have a compatibility issue
with @openfun/cunningham-react.
2024-04-29 10:27:15 +02:00
Anthony LC 4551d20d67 🔥(eslint) remove uneeded yarn.lock file
The yarn.lock doesn't seem necessary for this
package, so we're removing it.
2024-04-29 10:27:15 +02:00
Anthony LC c7f257daa0 ⬇️(eslint-config-people) eslint 9.0.0 -> 8.57.0
Downgrade eslint to 8.57.0.
9.0.0 has breaking changes, the adoption
is still very low, better to wait.
Add it in the renovate.json file.
2024-04-29 10:27:15 +02:00
renovate[bot] 32e6996b68 ⬆️(dependencies) update js dependencies 2024-04-29 10:27:15 +02:00
Jacques ROUSSEL 8fbc4e936e 💚(ci) improve secrets for k8s deployment
Avoid secrets to be visible from running deployments
2024-04-23 22:19:25 +02:00
renovate[bot] cda59fecec ⬆️(dependencies) update python dependencies 2024-04-22 13:46:27 +02:00
Marie PUPO JEAMMET c5ba87e422 📝(doc) copy demo credentials to README.md
Copy demo admin credentials for newcomers to log in to django admin
without having to find it in makefile.
2024-04-19 18:45:50 +02:00
Marie PUPO JEAMMET df24c24da1 (api) add CRUD for mailbox manager MailDomain models
Add create,list,retrieve and delete actions for MailDomain model.
2024-04-19 18:45:50 +02:00
Marie PUPO JEAMMET ac81e86c88 🧑‍💻(admin) add mailbox-related models to django admin
Register MailDomain, MailDomainAccess and Mailbox to django admin.
2024-04-18 10:42:13 +02:00
Sabrina Demagny 082fb99bd5 (api) allow to list and create Mailboxes
Simply display all Mailboxes create for a MailDomain.
LDAP connection is not yet available, it will be implemented soon.
Read and create permissions will be refined soon too.
2024-04-17 16:51:54 +02:00
renovate[bot] 1704ba1707 ⬆️(dependencies) update gunicorn to v22 [SECURITY] 2024-04-17 11:23:11 +02:00
Marie PUPO JEAMMET cca6c77f00 🗃️(models) add MailDomain, MailDomainAccess and Mailbox models
Additional app and models to handle email addresses creation in Desk.
2024-04-16 15:47:33 +02:00
renovate[bot] a1f9cf0854 ⬆️(dependencies) update python dependencies 2024-04-16 10:27:16 +02:00
Lebaud Antoine 2f1805b721 🩹(backend) address linter flakiness on Email tests
Pylint was randomly failing due to a warning while unpacking emails.
The W0632 (Possible unbalanced tuple unpacking) was triggered.

Replace tuple unpacking by an explicitly accessing the first element of
the array using index.
2024-04-08 15:35:12 +02:00
renovate[bot] 711abcb49f ⬆️(dependencies) update python dependencies 2024-04-08 15:35:12 +02:00
Anthony LC e68370bfcd ⬇️(eslint-config-people) eslint 9.0.0 -> 8.57.0
Downgrade eslint to 8.57.0.
9.0.0 has breaking changes, the adoption
is still very low (1%), better to wait.
2024-04-08 15:18:17 +02:00
renovate[bot] ae1acd8840 ⬆️(dependencies) update js dependencies 2024-04-08 15:18:17 +02:00
Lebaud Antoine 54386fcdd3 🩹(backend) address test flakiness while sorting Team accesses
Previously, there was a difference between Django's `order_by`
behavior and Python's `sorted` function, leading to test failures
under specific conditions. For example, entries such as 'Jose Smith'
and 'Joseph Walker' were not consistently sorted in the same order
between the two methods.

To resolve this issue, we've ensured that sorting the expected
results in the TeamAccess tests are both case-insensitive and
space-insensitive. This adjustment fix tests flakiness
2024-04-08 15:07:58 +02:00
Anthony LC 45a3e7936d (app-desk) create mails feature
Create the archi to handle the mails feature.
It has a different layout than the other features,
we don't display the sidebar to keep the
user focused on the mail content.
2024-04-08 14:42:56 +02:00
Marie PUPO JEAMMET ebf58f42c9 (webhook) add webhook logic and synchronization utils
adding webhooks logic to send serialized team memberships data
to a designated serie of webhooks.
2024-04-05 16:06:09 +02:00
Samuel Paccoud - DINUM 7ea6342a01 ♻️(models) refactor user email fields
The email field on the user is renamed to "admin_email" for clarity. The
"email" and "name" fields of user's main identity are made available on
the user model so it is easier to access it.
2024-04-05 16:06:09 +02:00
Anthony LC 6d807113bc 🔧(sops) update secrets
Access to anthony's new key
2024-04-05 12:21:13 +02:00
Jacques ROUSSEL 5455c589ef 🔧(sops) update secrets
Decrypt and reencrypt secrets to grant access to anthony's new key
2024-04-05 09:48:19 +02:00
Lebaud Antoine 9ec7eddaed (frontend) add logout button
Rework the header based on latest Johann's design, which introduced a
dropdown menu to mange user account.

In this menu, you can find a logout button, which ends up the backend
session by calling the logout endpoint. Please that automatic redirection
when receiving the backend response were disabled. We handle it in our
custom hook, which reload the page.

Has the session cookie have been cleared, on reloading the page, a new
loggin flow is initiated, and the user is redirected to the OIDC provider.

Please note, the homepage design/organization is still under discussion, I
prefered to ship a first increment. The logout feature will be quite useful
in staging to play and test our UI.
2024-04-04 14:52:53 +02:00
Lebaud Antoine 7db2faa072 🍱(frontend) rename Desk to Equipes
Update all assets related to the previous naming.
I gave some asset a more generic name, so if the app's name chang again
we won't have to rename the logo.

Linked but not assets, meta title and description were updated.
Next.js favicon was replaced by the Equipes' one.
2024-04-04 14:52:53 +02:00
Anthony LC 6e0b329b09 🐛(app-desk) add next-env.d.ts
next-env.d.ts lot of types for the next.js.
next.js boilerplate don't version the
next-env.d.ts file, so it was being ignored by git.
This was causing the CI to fail with the ts linter.
2024-04-04 12:13:00 +02:00
Lebaud Antoine c7aae51d25 🩹(test) update MemberAction props
TeamId prop was refactored to a Team object.
Update MemberAction component's tests.
2024-04-04 12:13:00 +02:00
Anthony LC db40efb360 🚨(app-desk) improve linter
The linter was passing near the ts errors in
the tests, we improve the linter by adding
a ts checkup.
2024-04-04 12:13:00 +02:00
Anthony LC 3ddc519d9e (e2e) fix job flakinness
Fix a flaky jobs
- searched username could be hidden in the options
 depends the dummy data generated.
- remove first place assertion when create a new team,
  multiple workers make this assertion flaky
2024-04-04 12:13:00 +02:00
Lebaud Antoine e20960e3e1 💚(ci) update Github Actions using Node.js 16
Github Actions are transitioning from Node 16 to Node 20. Make sure we use
latest Github Actions versions to clean any deprecation warnings.

The migration is upcoming.
2024-04-04 10:33:20 +02:00
293 changed files with 11504 additions and 7166 deletions
+52
View File
@@ -0,0 +1,52 @@
name: Deploy
on:
push:
tags:
- 'preprod'
- 'production'
jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL
start-test-on-preprod:
needs:
- notify-argocd
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/preprod')
steps:
-
name: Debug
run: |
echo "Start test when preprod is ready"
+43 -9
View File
@@ -19,8 +19,19 @@ jobs:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v4
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Docker meta
id: meta
@@ -31,7 +42,7 @@ jobs:
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: .github/workflows/secrets.enc.env
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Login to DockerHub
@@ -52,8 +63,19 @@ jobs:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v4
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Docker meta
id: meta
@@ -64,7 +86,7 @@ jobs:
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: .github/workflows/secrets.enc.env
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Login to DockerHub
@@ -86,15 +108,27 @@ jobs:
- build-and-push-frontend
- build-and-push-backend
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
-
name: Checkout
uses: actions/checkout@v4
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: .github/workflows/secrets.enc.env
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
+27 -17
View File
@@ -4,8 +4,6 @@ on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- '*'
@@ -16,7 +14,7 @@ jobs:
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: show
@@ -39,7 +37,7 @@ jobs:
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check that the CHANGELOG has been modified in the current branch
@@ -49,7 +47,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -183,7 +181,7 @@ jobs:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
run: |
docker-compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
docker compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
make run
- name: Apply DRF migrations
@@ -200,7 +198,7 @@ jobs:
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
@@ -214,7 +212,7 @@ jobs:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
@@ -238,9 +236,9 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install development dependencies
@@ -284,7 +282,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Create writable /data
run: |
sudo mkdir -p /data/media && \
@@ -295,7 +293,7 @@ jobs:
name: mails-templates
path: src/backend/core/templates/mail
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install development dependencies
@@ -312,16 +310,27 @@ jobs:
i18n-crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
- name: Install gettext (required to make messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: '3.10'
@@ -332,10 +341,11 @@ jobs:
- name: Generate the translation base file
run: ~/.local/bin/django-admin makemessages --keep-pot --all
- name: Load sops secrets
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: .github/workflows/secrets.enc.env
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
- name: Setup Node.js
-24
View File
@@ -1,24 +0,0 @@
SOPS_PRIVATE=ENC[AES256_GCM,data:FAgeMw3bbPVNZEuyuJ4rMeknOH4UZ1vC7k3S0qe6aaeGG3HHfEIpWHckCjHLDTz6hNDUxWSr/WPytntbGjqcvzKwYZBeehGx9hY=,iv:O64EPSvyYSxjHOIIrdjMQYIJR9HW2CxE+6Ay0e0dvoc=,tag:64G0A4kIJ8AzNx3+CluW5Q==,type:str]
CROWDIN_API_TOKEN=ENC[AES256_GCM,data:IlmqbPlM6s9paCAxn40v4LNEGbzYlm9SVplXfP8PI8Ei/K5oGtLPquoDokK33ySEfEh5b5xLAZjlmDyICdv89ETdd7RLB/63f2LduMn8n0A=,iv:gdcCrv58wkLC6wufFvHGrHB/A6aLXAApJ036BYjlN8g=,tag:wReGIj4izqzBHY7X0rqOAg==,type:str]
CROWDIN_BASE_PATH=ENC[AES256_GCM,data:bepqdi4eaMM=,iv:nF7FSUiuRu4kTYeEQB3cN/Fwrpt0fB0dC/dm4poec7w=,tag:kbxhwe1SQYkaddKyY1UQDg==,type:str]
CROWDIN_PROJECT_ID=ENC[AES256_GCM,data:z8ZkbbR9,iv:Uc2aGQ9dAkiUcTpBqcIqD2fXt4+/ObJJz/3D8BhN9/A=,tag:ZDqDpsD7IN7iyFvKmxoI2w==,type:str]
DOCKER_HUB_PASSWORD=ENC[AES256_GCM,data:3E/4G2zOBNZqkGc=,iv:zbQzrgLYgNp99zRUZ3ICG47OkLPV1HnhcNoIu3cSmpc=,tag:CEvKG2HVd2ijjGfzkM005Q==,type:str]
DOCKER_HUB_USER=ENC[AES256_GCM,data:Qc/HR/O8nw==,iv:nvXUmLHgNFQwm6aIpBcPvOdITOkw1YT7hIyk4ptuOOw=,tag:qoqSA9NlGCbor+7OyGArfw==,type:str]
ARGOCD_WEBHOOK_URL=ENC[AES256_GCM,data:WhW6TQQV/IQteW/jQukjlTaTLeNUzEzadp+HJEC7bqrWR88ZBUlk8sfF5KV7PAAAZtvMhe4mi/yl,iv:CInpRuJpKFpxozdzHQkfDxbhQYyy+VByzrDr2ZSmJ7E=,tag:RNmIoYtA5DSi1W7TDsJHxw==,type:str]
ARGOCD_WEBHOOK_SECRET=ENC[AES256_GCM,data:KTT5ubYdm5oVzXL0sM9HdNObVKhSD1spB3KYgM6CskEpQutb3R4Lu5mImft9C9tRKIkZetxV9YyLawCPaZm5P1U=,iv:Xp+xE43a6/GODhhujnSrTfcQJ2P9u4YrRakmxLwEtEc=,tag:jYAk+4X7x44edb0vcbnKWA==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHaHpGT0lLR21qRi8wZCt2\nc0t5NGlDR29lK2JscE4rc3VZOU5ic3FBdkhnCnJYdDdvMlBROWhPc2pjbDJTbmo0\ncENxV0hDYzZKSFh5RTNnUTRyaUdGLzgKLS0tIENIeDQyQ1NzMkF6cTZVRmU1VC9D\nNy9BTElMQlBEUWhCQWxpdVI1enJNblEKbGEkN7JPlj+OqBHYhZhZwfjpdxGlkjti\nBPsDHIklHYufk9+TjnxBYL1Oe6zLn4zlG0r/w8AHrTo6oke5dUjvKg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x
sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArOG1XL1NFc29xVWFFdW9Q\nM0FrY3BEazJRRXNHMjVpS2hCVVBNSllUU1dJCkRIT00xSVAzeEozZndwTzlmY1Bn\nc3VJYmQrVzRXZTF1TjR4QXQrYXpIaG8KLS0tIEtXNTkvb2tRcjR0YWdneTI2dlBu\nY2tkYVU4UGtZdEIxQWMxQ1h6aHQ1RjQKpPQRQZtMDMkmSUhm7PQXpBHYK45Fm7Gx\nNL4gpLq1bJ6tNlnvP2zj2OOJ7NsiSeH8Vey6NON6E5wDKckBkpZUdg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_1__map_recipient=age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUUEM5ZzNST2RHTzRKUUxH\nbGxsOVoxTkVQWnFBa3lweExjSG5tZW02ZTBVCmhmLzRkbEVUUFNOZXdZRG5QUUJL\nL0lvMDArRHBGbmsrR1Y0NE9DcmtwbWcKLS0tIDljdmdGaTVVdnBxdjlYM0IrWTZ1\nYU03M1dHZm5nMDY0ZE9TSzl2Mk9YcG8KZ6vkG1KUIpS3BnIWcluA0nc5YX5AB3RH\nPJqFTdNwUfeB8omOyifF4jxU3Jqm53d3kVmiSPXTlaOYwqrhbT1oAw==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_2__map_recipient=age1g5keveae6zhn059e7cxkjqdz4v3u47ypejv9ujld65nwh6d5pd9qfm0ecv
sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0c2x0YVB4cW1jVFRZVlBP\nNFJiSHIrWk9HWGgreVRSZWhkd3cveDBDZ1RZCkQzYnFxMEhUb2s2U3drZzQwTW5D\nbU9iL0RoN2Ztb0M2UEluOUQ5M3RtMEEKLS0tIEhJRnZGekxWR3dRejV1SkNDQkda\nQkY5MGZyZW1QNzdveXFTbGd0WjRZaTAKbv+2BPThEjNn7yjK4aX4C7kvnXQgf1Qb\n6J72BKD8AtHOJVdp/d1+I41ejwXdfBbSKN5O6rnPozH60GDWFlQRwA==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_3__map_recipient=age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4bzBsVUdlV1dzNVZoWGs2\nR3k0ZGh4cTlNTWdqNXlzc29KeTNTcmd6MHlZCi9wa29CNzcwWmtyT2U2bVArQXlU\nOGJXY3VaT2J5SEd1clY2WFVLa0s2a0EKLS0tIGlYZzRFT29ld2t6SU5tUUhIeUpG\nRnQwcTN0OFZJNGJicUNmeVE0eVcrU3MK96xiB4AkQSXLnP5XvVlQhvjQEQKhTZuO\nYbO1uP0EGSQ+f6uP3ENw4BFAgdB3y0YFcVm5VtTxN4Tpw3ZUVWtqew==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_4__map_recipient=age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBoWkEzQlRLNVd3U3hrU2dh\nY0l1OGVlYjJNZEIzK1R5NWFCK1lnK2pMVnlBCksrR1JKZTFmdzJCekZkQStvejBL\nd05wejByZjdnN2xweVhBemNiSDUwUEUKLS0tIEJIVGYzbWNBN2hrNEtoY2xMSk9v\naWw1THRBbmpDbGc2eE1WV0VSVUtoNm8KyCP6BtJRNUfvlAiuzQ1xhpjm6YFUA66G\n/qk9WLm07qHfFD5r2dGQBksN61Nk+akF0BGa56qnAEJp63lP8u1xbg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_5__map_recipient=age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
sops_lastmodified=2024-03-26T10:02:48Z
sops_mac=ENC[AES256_GCM,data:p7Br4MRgpr26vSS79QBwYRXQWdb20lbrSxOdcrj385EhDVaqZM9aaycf09FItuBrQDuNO3NVSoGnJvWhekwRXXJlxXDENmjP9YBnNt25WXCa2+PLQGzvGbZjo/KHky5zqwVV5XaJ++UuO7VJZgB6m5m7RPZEBCo4cwy7rvNeyEs=,iv:XaavC9VEDckO1QpsPjUwf5BpJzFcEdxycSZvuFX8D7A=,tag:S82ex6+X4MUkkR8m9UAgqA==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.8.1
+1
View File
@@ -66,6 +66,7 @@ src/frontend/tsclient
# Test & lint
.coverage
coverage.json
.pylint.d
.pytest_cache
db.sqlite3
+3
View File
@@ -0,0 +1,3 @@
[submodule "secrets"]
path = secrets
url = ../secrets
-15
View File
@@ -1,15 +0,0 @@
creation_rules:
# Here we have
# - Jacques key-id: age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x
# - github-repo key-id: age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7
# - Anthony Le-Courric key-id: age1g5keveae6zhn059e7cxkjqdz4v3u47ypejv9ujld65nwh6d5pd9qfm0ecv
# - Antoine Lebaud key-id: age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
# - Marie Pupo Jeammet key-id: age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
# - argocd key-id: age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
- age:
age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x,
age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7,
age1g5keveae6zhn059e7cxkjqdz4v3u47ypejv9ujld65nwh6d5pd9qfm0ecv,
age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3,
age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa,
age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
+5 -3
View File
@@ -24,8 +24,8 @@ COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint
RUN yarn --frozen-lockfile
### ---- Front-end builder image ----
FROM node:20 as frontend-builder
### ---- Front-end builder dev image ----
FROM node:20 as frontend-builder-dev
WORKDIR /builder
@@ -34,8 +34,10 @@ COPY ./src/frontend .
WORKDIR ./apps/desk
RUN yarn build
### ---- Front-end builder image ----
FROM frontend-builder-dev as frontend-builder
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
+23 -3
View File
@@ -166,24 +166,35 @@ test-back-parallel: ## run all back-end tests in parallel
bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel
test-coverage: ## compute, display and save test coverage
bin/pytest --cov=. --cov-report json .
.PHONY: test-coverage
makemigrations: ## run django makemigrations for the people project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) makemigrations
@$(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
@$(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
superuser: ## Create an admin superuser with password "admin"
@echo "$(BOLD)Creating a Django superuser$(RESET)"
@$(MANAGE) createsuperuser --email admin@example.com --password admin
$(MANAGE) createsuperuser --username admin --password admin
.PHONY: superuser
back-i18n-compile: ## compile the gettext files
@@ -323,3 +334,12 @@ frontend-i18n-generate: \
frontend-i18n-compile: ## Format the crowin json files used deploy to the apps
cd $(PATH_FRONT) && yarn i18n:deploy
.PHONY: frontend-i18n-compile
# -- K8S
start-kind: ## Create the kubernetes cluster
./bin/start-kind.sh
.PHONY: start-kind
tilt-up: ## start tilt - k8s local development
tilt up -f ./bin/Tiltfile
.PHONY: tilt-up
+2
View File
@@ -69,6 +69,8 @@ You first need to create a superuser account:
$ make superuser
```
You can then login with sub `admin` and password `admin`.
### Run frontend
Run the front with:
+58
View File
@@ -0,0 +1,58 @@
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject')
namespace_create('desk')
docker_build(
'localhost:5001/people-backend:latest',
context='..',
dockerfile='../Dockerfile',
only=['./src/backend', './src/mail', './docker'],
target = 'backend-production',
live_update=[
sync('../src/backend', '/app'),
run(
'pip install -r /app/requirements.txt',
trigger=['./api/requirements.txt']
)
]
)
docker_build(
'localhost:5001/people-frontend:latest',
context='..',
dockerfile='../Dockerfile',
build_args={'ENV': 'dev'},
only=['./src/frontend', './src/mail', './docker'],
target = 'frontend-builder-dev',
live_update=[
sync('../src/frontend', '/builder'),
]
)
k8s_yaml(local('cd ../src/helm && helmfile -n desk -e dev template .'))
migration = '''
set -eu
# get k8s pod name from tilt resource name
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
kubectl -n desk exec "$POD_NAME" -- python manage.py makemigrations
'''
cmd_button('Make migration',
argv=['sh', '-c', migration],
resource='desk-backend',
icon_name='developer_board',
text='Run makemigration',
)
pod_migrate = '''
set -eu
# get k8s pod name from tilt resource name
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
kubectl -n desk exec "$POD_NAME" -- python manage.py migrate --no-input
'''
cmd_button('Migrate db',
argv=['sh', '-c', pod_migrate],
resource='desk-backend',
icon_name='developer_board',
text='Run database migration',
)
+102
View File
@@ -0,0 +1,102 @@
#!/bin/sh
set -o errexit
CURRENT_DIR=$(pwd)
# 0. Create ca
echo "0. Create ca"
mkcert -install
cd /tmp
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
cd $CURRENT_DIR
# 1. Create registry container unless it already exists
echo "1. Create registry container unless it already exists"
reg_name='kind-registry'
reg_port='5001'
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
docker run \
-d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
registry:2
fi
# 2. Create kind cluster with containerd registry config dir enabled
echo "2. Create kind cluster with containerd registry config dir enabled"
# TODO: kind will eventually enable this by default and this patch will
# be unnecessary.
#
# See:
# https://github.com/kubernetes-sigs/kind/issues/2875
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
cat <<EOF | kind create cluster --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
nodes:
- role: control-plane
image: kindest/node:v1.27.3
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
- role: worker
image: kindest/node:v1.27.3
- role: worker
image: kindest/node:v1.27.3
EOF
# 3. Add the registry config to the nodes
echo "3. Add the registry config to the nodes"
#
# This is necessary because localhost resolves to loopback addresses that are
# network-namespace local.
# In other words: localhost in the container is not localhost on the host.
#
# We want a consistent name that works from both ends, so we tell containerd to
# alias localhost:${reg_port} to the registry container when pulling images
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
for node in $(kind get nodes); do
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
[host."http://${reg_name}:5000"]
EOF
done
# 4. Connect the registry to the cluster network if not already connected
echo "4. Connect the registry to the cluster network if not already connected"
# This allows kind to bootstrap the network but ensures they're on the same network
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
docker network connect "kind" "${reg_name}"
fi
# 5. Document the local registry
echo "5. Document the local registry"
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: local-registry-hosting
namespace: kube-public
data:
localRegistryHosting.v1: |
host: "localhost:${reg_port}"
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
EOF
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
-1
View File
@@ -136,7 +136,6 @@ services:
kc_postgresql:
image: postgres:14.3
platform: linux/amd64
ports:
- "5433:5432"
env_file:
+7 -1
View File
@@ -13,7 +13,13 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": ["node", "node-fetch", "i18next-parser"]
"matchPackageNames": [
"fetch-mock",
"node",
"node-fetch",
"i18next-parser",
"eslint"
]
}
]
}
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
git submodule update --init --recursive
git submodule foreach 'git fetch origin; git checkout $(git rev-parse --abbrev-ref HEAD); git reset --hard origin/$(git rev-parse --abbrev-ref HEAD); git submodule update --recursive; git clean -dfx'
Submodule
+1
Submodule secrets added at d7cfe7bcdc
+35 -42
View File
@@ -1,6 +1,5 @@
"""Admin classes and registrations for People's core app."""
from django import forms
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
@@ -8,42 +7,6 @@ from django.utils.translation import gettext_lazy as _
from . import models
class IdentityFormSet(forms.BaseInlineFormSet):
"""
Make the "is_main" field readonly when it is True so that declaring another identity
works in the admin.
"""
def add_fields(self, form, index):
"""Disable the "is_main" field when it is set to True"""
super().add_fields(form, index)
is_main_value = form.instance.is_main if form.instance else False
form.fields["is_main"].disabled = is_main_value
class IdentityInline(admin.TabularInline):
"""Inline admin class for user identities."""
fields = (
"sub",
"email",
"is_main",
"created_at",
"updated_at",
)
formset = IdentityFormSet
model = models.Identity
extra = 0
readonly_fields = ("email", "created_at", "sub", "updated_at")
def has_add_permission(self, request, obj):
"""
Identities are automatically created on successful OIDC logins.
Disable creating identities via the admin.
"""
return False
class TeamAccessInline(admin.TabularInline):
"""Inline admin class for team accesses."""
@@ -53,6 +16,15 @@ class TeamAccessInline(admin.TabularInline):
readonly_fields = ("created_at", "updated_at")
class TeamWebhookInline(admin.TabularInline):
"""Inline admin class for team webhooks."""
extra = 0
autocomplete_fields = ["team"]
model = models.TeamWebhook
readonly_fields = ("created_at", "updated_at")
@admin.register(models.User)
class UserAdmin(auth_admin.UserAdmin):
"""Admin class for the User model"""
@@ -63,6 +35,7 @@ class UserAdmin(auth_admin.UserAdmin):
{
"fields": (
"id",
"sub",
"password",
)
},
@@ -88,12 +61,13 @@ class UserAdmin(auth_admin.UserAdmin):
None,
{
"classes": ("wide",),
"fields": ("email", "password1", "password2"),
"fields": ("sub", "email", "password1", "password2"),
},
),
)
inlines = (IdentityInline, TeamAccessInline)
inlines = (TeamAccessInline,)
list_display = (
"sub",
"email",
"created_at",
"updated_at",
@@ -104,15 +78,21 @@ class UserAdmin(auth_admin.UserAdmin):
)
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
readonly_fields = ("id", "created_at", "updated_at")
search_fields = ("id", "email", "identities__sub", "identities__email")
readonly_fields = ["id", "created_at", "updated_at"]
search_fields = ("id", "email", "sub")
def get_readonly_fields(self, request, obj=None):
"""The sub should only be editable for a create, not for updates."""
if obj:
return self.readonly_fields + ["sub"]
return self.readonly_fields
@admin.register(models.Team)
class TeamAdmin(admin.ModelAdmin):
"""Team admin interface declaration."""
inlines = (TeamAccessInline,)
inlines = (TeamAccessInline, TeamWebhookInline)
list_display = (
"name",
"slug",
@@ -122,6 +102,19 @@ class TeamAdmin(admin.ModelAdmin):
search_fields = ("name",)
@admin.register(models.TeamAccess)
class TeamAccessAdmin(admin.ModelAdmin):
"""Team access admin interface declaration."""
list_display = (
"user",
"team",
"role",
"created_at",
"updated_at",
)
@admin.register(models.Invitation)
class InvitationAdmin(admin.ModelAdmin):
"""Admin interface to handle invitations."""
+2
View File
@@ -23,6 +23,8 @@ def exception_handler(exc, context):
detail = exc.message
elif hasattr(exc, "messages"):
detail = exc.messages
else:
detail = ""
exc = drf_exceptions.ValidationError(detail=detail)
+3 -23
View File
@@ -52,42 +52,22 @@ class UserSerializer(DynamicFieldsModelSerializer):
"""Serialize users."""
timezone = TimeZoneSerializerField(use_pytz=False, required=True)
name = serializers.SerializerMethodField(read_only=True)
email = serializers.SerializerMethodField(read_only=True)
email = serializers.ReadOnlyField()
name = serializers.ReadOnlyField()
class Meta:
model = models.User
fields = [
"id",
"name",
"email",
"language",
"name",
"timezone",
"is_device",
"is_staff",
]
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
def _get_main_identity_attr(self, obj, attribute_name):
"""Return the specified attribute of the main identity."""
try:
return getattr(obj.main_identity[0], attribute_name)
except TypeError:
return getattr(obj.main_identity, attribute_name)
except IndexError:
main_identity = obj.identities.filter(is_main=True).first()
return getattr(obj.main_identity, attribute_name) if main_identity else None
except AttributeError:
return None
def get_name(self, obj):
"""Return main identity's name."""
return self._get_main_identity_attr(obj, "name")
def get_email(self, obj):
"""Return main identity's email."""
return self._get_main_identity_attr(obj, "email")
class TeamAccessSerializer(serializers.ModelSerializer):
"""Serialize team accesses."""
+22 -30
View File
@@ -1,7 +1,7 @@
"""API endpoints"""
from django.contrib.postgres.search import TrigramSimilarity
from django.db.models import Func, Max, OuterRef, Prefetch, Q, Subquery, Value
from django.db.models import Func, Max, OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce
from rest_framework import (
@@ -198,12 +198,6 @@ class UserViewSet(
# Exclude inactive contacts
queryset = queryset.filter(
is_active=True,
).prefetch_related(
Prefetch(
"identities",
queryset=models.Identity.objects.filter(is_main=True),
to_attr="main_identity",
)
)
# Exclude all users already in the given team
@@ -214,15 +208,11 @@ class UserViewSet(
if query := self.request.GET.get("q", ""):
similarity = Max(
TrigramSimilarity(
Coalesce(
Func("identities__email", function="unaccent"), Value("")
),
Coalesce(Func("email", function="unaccent"), Value("")),
Func(Value(query), function="unaccent"),
)
+ TrigramSimilarity(
Coalesce(
Func("identities__name", function="unaccent"), Value("")
),
Coalesce(Func("name", function="unaccent"), Value("")),
Func(Value(query), function="unaccent"),
)
)
@@ -245,9 +235,6 @@ class UserViewSet(
Return information on currently logged user
"""
user = request.user
user.main_identity = models.Identity.objects.filter(
user=user, is_main=True
).first()
return response.Response(
self.serializer_class(user, context={"request": request}).data
)
@@ -332,7 +319,7 @@ class TeamAccessViewSet(
filter_backends = [filters.OrderingFilter]
ordering = ["role"]
ordering_fields = ["role", "email", "name"]
ordering_fields = ["role", "user__email", "user__name"]
def get_permissions(self):
"""User only needs to be authenticated to list team accesses"""
@@ -360,34 +347,39 @@ class TeamAccessViewSet(
queryset = queryset.filter(team=self.kwargs["team_id"])
if self.action in {"list", "retrieve"}:
if query := self.request.GET.get("q", ""):
similarity = Max(
TrigramSimilarity(
Coalesce(Func("user__email", function="unaccent"), Value("")),
Func(Value(query), function="unaccent"),
)
+ TrigramSimilarity(
Coalesce(Func("user__name", function="unaccent"), Value("")),
Func(Value(query), function="unaccent"),
)
)
queryset = (
queryset.annotate(similarity=similarity)
.filter(similarity__gte=SIMILARITY_THRESHOLD)
.order_by("-similarity")
)
# Determine which role the logged-in user has in the team
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=self.kwargs["team_id"]
).values("role")[:1]
user_main_identity_query = models.Identity.objects.filter(
user=OuterRef("user_id"), is_main=True
)
queryset = (
# The logged-in user should be part of a team to see its accesses
queryset.filter(
team__accesses__user=self.request.user,
)
.prefetch_related(
Prefetch(
"user__identities",
queryset=models.Identity.objects.filter(is_main=True),
to_attr="main_identity",
)
)
# Abilities are computed based on logged-in user's role and
# the user role on each team access
.annotate(
user_role=Subquery(user_role_query),
email=Subquery(user_main_identity_query.values("email")[:1]),
name=Subquery(user_main_identity_query.values("name")[:1]),
)
.select_related("user")
.distinct()
)
return queryset
@@ -1,8 +1,7 @@
"""Authentication for the People core app."""
"""Authentication Backends for the People core app."""
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.db import models
from django.utils.translation import gettext_lazy as _
import requests
@@ -10,14 +9,12 @@ from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
from .models import Identity
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
This class overrides the default OIDC Authentication Backend to accommodate differences
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
in the User model, and handles signed and/or encrypted UserInfo response.
"""
def get_userinfo(self, access_token, id_token, payload):
@@ -81,28 +78,16 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
_("User info contained no recognizable user identification")
)
user = (
self.UserModel.objects.filter(identities__sub=sub)
.annotate(
identity_email=models.F("identities__email"),
identity_name=models.F("identities__name"),
)
.distinct()
.first()
)
if user:
try:
user = self.UserModel.objects.get(sub=sub)
except self.UserModel.DoesNotExist:
if self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(user_info)
else:
email = user_info.get("email")
name = user_info.get("name")
if (
email
and email != user.identity_email
or name
and name != user.identity_name
):
Identity.objects.filter(sub=sub).update(email=email, name=name)
elif self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(user_info)
if email and email != user.email or name and name != user.name:
self.UserModel.objects.filter(sub=sub).update(email=email, name=name)
return user
@@ -114,9 +99,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
_("Claims contained no recognizable user identification")
)
user = self.UserModel.objects.create(password="!") # noqa: S106
Identity.objects.create(
user=user, sub=sub, email=claims.get("email"), name=claims.get("name")
return self.UserModel.objects.create(
password="!", # noqa: S106
sub=sub,
email=claims.get("email"),
name=claims.get("name"),
)
return user
+18
View File
@@ -0,0 +1,18 @@
"""Authentication URLs for the People core app."""
from django.urls import path
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
from .views import OIDCLogoutCallbackView, OIDCLogoutView
urlpatterns = [
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
path(
"logout-callback/",
OIDCLogoutCallbackView.as_view(),
name="oidc_logout_callback",
),
*mozzila_oidc_urls,
]
+137
View File
@@ -0,0 +1,137 @@
"""Authentication Views for the People core app."""
from urllib.parse import urlencode
from django.contrib import auth
from django.core.exceptions import SuspiciousOperation
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils import crypto
from mozilla_django_oidc.utils import (
absolutify,
)
from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
Adds support for handling logout callbacks from the identity provider (OP)
by initiating the logout flow if the user has an active session.
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
This parameter is crucial for maintaining the integrity of the logout flow between this call
and the subsequent callback.
"""
@staticmethod
def persist_state(request, state):
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
This method is used to store the OIDC state parameter in the session, according to the
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
utility function.
"""
if "oidc_states" not in request.session or not isinstance(
request.session["oidc_states"], dict
):
request.session["oidc_states"] = {}
request.session["oidc_states"][state] = {}
request.session.save()
def construct_oidc_logout_url(self, request):
"""Create the redirect URL for interfacing with the OIDC provider.
Retrieves the necessary parameters from the session and constructs the URL
required to initiate logout with the OpenID Connect provider.
If no ID token is found in the session, the logout flow will not be initiated,
and the method will return the default redirect URL.
The 'state' parameter is generated randomly and persisted in the session to ensure
its integrity during the subsequent callback.
"""
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
if not oidc_logout_endpoint:
return self.redirect_url
reverse_url = reverse("oidc_logout_callback")
id_token = request.session.get("oidc_id_token", None)
if not id_token:
return self.redirect_url
query = {
"id_token_hint": id_token,
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
"post_logout_redirect_uri": absolutify(request, reverse_url),
}
self.persist_state(request, query["state"])
return f"{oidc_logout_endpoint}?{urlencode(query)}"
def post(self, request):
"""Handle user logout.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, constructs the OIDC logout URL and redirects the user to start
the logout process.
If the user is redirected to the default logout URL, ensure her Django session
is terminated.
"""
logout_url = self.redirect_url
if request.user.is_authenticated:
logout_url = self.construct_oidc_logout_url(request)
# If the user is not redirected to the OIDC provider, ensure logout
if logout_url == self.redirect_url:
auth.logout(request)
return HttpResponseRedirect(logout_url)
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
Handles the callback after logout from the identity provider (OP).
Verifies the state parameter and performs necessary logout actions.
The Django session is maintained during the logout process to ensure the integrity
of the logout flow initiated in the previous step.
"""
http_method_names = ["get"]
def get(self, request):
"""Handle the logout callback.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, verifies the state parameter and performs necessary logout actions.
"""
if not request.user.is_authenticated:
return HttpResponseRedirect(self.redirect_url)
state = request.GET.get("state")
if state not in request.session.get("oidc_states", {}):
msg = "OIDC callback state not found in session `oidc_states`!"
raise SuspiciousOperation(msg)
del request.session["oidc_states"][state]
request.session.save()
auth.logout(request)
return HttpResponseRedirect(self.redirect_url)
+9
View File
@@ -3,6 +3,7 @@ Core application enums declaration
"""
from django.conf import global_settings, settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
@@ -14,3 +15,11 @@ ALL_LANGUAGES = getattr(
"ALL_LANGUAGES",
[(language, _(name)) for language, name in global_settings.LANGUAGES],
)
class WebhookStatusChoices(models.TextChoices):
"""Defines the possible statuses in which a webhook can be."""
FAILURE = "failure", _("Failure")
PENDING = "pending", _("Pending")
SUCCESS = "success", _("Success")
+13 -14
View File
@@ -120,28 +120,17 @@ class ContactFactory(BaseContactFactory):
class UserFactory(factory.django.DjangoModelFactory):
"""A factory to random users for testing purposes."""
"""A factory to create random users for testing purposes."""
class Meta:
model = models.User
django_get_or_create = ("email",)
email = factory.Faker("email")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
class IdentityFactory(factory.django.DjangoModelFactory):
"""A factory to create identities for a user"""
class Meta:
model = models.Identity
django_get_or_create = ("sub",)
user = factory.SubFactory(UserFactory)
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
name = factory.Faker("name")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
class TeamFactory(factory.django.DjangoModelFactory):
@@ -177,6 +166,16 @@ class TeamAccessFactory(factory.django.DjangoModelFactory):
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class TeamWebhookFactory(factory.django.DjangoModelFactory):
"""Create fake team webhooks for testing."""
class Meta:
model = models.TeamWebhook
team = factory.SubFactory(TeamFactory)
url = factory.Sequence(lambda n: f"https://example.com/Groups/{n!s}")
class InvitationFactory(factory.django.DjangoModelFactory):
"""A factory to create invitations for a user"""
+21 -25
View File
@@ -1,4 +1,4 @@
# Generated by Django 5.0.2 on 2024-03-05 17:09
# Generated by Django 5.0.3 on 2024-06-08 09:25
import django.contrib.auth.models
import django.core.validators
@@ -45,7 +45,9 @@ class Migration(migrations.Migration):
('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')),
('email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='email address')),
('sub', models.CharField(help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='email address')),
('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='name')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
@@ -87,25 +89,6 @@ class Migration(migrations.Migration):
name='profile_contact',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user', to='core.contact'),
),
migrations.CreateModel(
name='Identity',
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')),
('sub', models.CharField(help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='email address')),
('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='name')),
('is_main', models.BooleanField(default=False, help_text='Designates whether the email is the main one.', verbose_name='main')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identities', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'identity',
'verbose_name_plural': 'identities',
'db_table': 'people_identity',
'ordering': ('-is_main', 'email'),
},
),
migrations.CreateModel(
name='Invitation',
fields=[
@@ -144,6 +127,23 @@ class Migration(migrations.Migration):
name='users',
field=models.ManyToManyField(related_name='teams', through='core.TeamAccess', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='TeamWebhook',
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')),
('url', models.URLField(verbose_name='url')),
('secret', models.CharField(blank=True, max_length=255, null=True, verbose_name='secret')),
('status', models.CharField(choices=[('failure', 'Failure'), ('pending', 'Pending'), ('success', 'Success')], default='pending', max_length=10)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhooks', to='core.team')),
],
options={
'verbose_name': 'Team webhook',
'verbose_name_plural': 'Team webhooks',
'db_table': 'people_team_webhook',
},
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(check=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
@@ -156,10 +156,6 @@ class Migration(migrations.Migration):
name='contact',
unique_together={('owner', 'base')},
),
migrations.AddConstraint(
model_name='identity',
constraint=models.UniqueConstraint(fields=('user', 'email'), name='unique_user_email', violation_error_message='This email address is already declared for this user.'),
),
migrations.AddConstraint(
model_name='invitation',
constraint=models.UniqueConstraint(fields=('email', 'team'), name='email_and_team_unique_together'),
+104 -105
View File
@@ -12,8 +12,9 @@ from logging import getLogger
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.db import models
from django.db import models, transaction
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.functional import lazy
@@ -24,6 +25,9 @@ from django.utils.translation import override
import jsonschema
from timezone_field import TimeZoneField
from core.enums import WebhookStatusChoices
from core.utils.webhooks import scim_synchronizer
logger = getLogger(__name__)
current_dir = os.path.dirname(os.path.abspath(__file__))
@@ -74,7 +78,7 @@ class BaseModel(models.Model):
def save(self, *args, **kwargs):
"""Call `full_clean` before saving."""
self.full_clean()
super().save(*args, **kwargs)
return super().save(*args, **kwargs)
class Contact(BaseModel):
@@ -157,7 +161,25 @@ class Contact(BaseModel):
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
email = models.EmailField(_("email address"), unique=True, null=True, blank=True)
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
),
max_length=255,
unique=True,
validators=[sub_validator],
)
email = models.EmailField(_("email address"), null=True, blank=True)
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
profile_contact = models.OneToOneField(
Contact,
on_delete=models.SET_NULL,
@@ -199,7 +221,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
objects = auth_models.UserManager()
USERNAME_FIELD = "email"
USERNAME_FIELD = "sub"
REQUIRED_FIELDS = []
class Meta:
@@ -208,91 +230,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
verbose_name_plural = _("users")
def __str__(self):
return (
str(self.profile_contact)
if self.profile_contact
else self.email or str(self.id)
)
def clean(self):
"""Validate fields."""
super().clean()
if self.profile_contact_id and not self.profile_contact.owner == self:
raise exceptions.ValidationError(
"Users can only declare as profile a contact they own."
)
def email_user(self, subject, message, from_email=None, **kwargs):
"""Email this user."""
main_identity = self.identities.get(is_main=True)
mail.send_mail(subject, message, from_email, [main_identity.email], **kwargs)
@classmethod
def get_email_field_name(cls):
"""
Raise error when trying to get email field name from the user as we are using
a separate Email model to allow several emails per user.
"""
raise NotImplementedError(
"This feature is deactivated to allow several emails per user."
)
class Identity(BaseModel):
"""User identity"""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
),
)
user = models.ForeignKey(User, related_name="identities", on_delete=models.CASCADE)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
),
max_length=255,
unique=True,
validators=[sub_validator],
)
email = models.EmailField(_("email address"), null=True, blank=True)
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
is_main = models.BooleanField(
_("main"),
default=False,
help_text=_("Designates whether the email is the main one."),
)
class Meta:
db_table = "people_identity"
ordering = ("-is_main", "email")
verbose_name = _("identity")
verbose_name_plural = _("identities")
constraints = [
# Uniqueness
models.UniqueConstraint(
fields=["user", "email"],
name="unique_user_email",
violation_error_message=_(
"This email address is already declared for this user."
),
),
]
def __str__(self):
main_str = "[main]" if self.is_main else ""
id_str = self.email or self.sub
return f"{id_str:s}{main_str:s}"
return self.name if self.name else self.email or f"User {self.sub}"
def save(self, *args, **kwargs):
"""
Saves identity, ensuring users always have exactly one main identity.
If it's a new identity, give its user access to the relevant teams.
If it's a new user, give her access to the relevant teams.
"""
if self._state.adding:
@@ -300,9 +242,16 @@ class Identity(BaseModel):
super().save(*args, **kwargs)
# Ensure users always have one and only one main identity.
if self.is_main is True:
self.user.identities.exclude(id=self.id).update(is_main=False)
def clean(self):
"""Validate fields."""
super().clean()
if self.email:
self.email = User.objects.normalize_email(self.email)
if self.profile_contact_id and not self.profile_contact.owner == self:
raise exceptions.ValidationError(
"Users can only declare as profile a contact they own."
)
def _convert_valid_invitations(self):
"""
@@ -323,24 +272,17 @@ class Identity(BaseModel):
TeamAccess.objects.bulk_create(
[
TeamAccess(user=self.user, team=invitation.team, role=invitation.role)
TeamAccess(user=self, team=invitation.team, role=invitation.role)
for invitation in valid_invitations
]
)
valid_invitations.delete()
def clean(self):
"""Normalize the email field and clean the 'is_main' field."""
if self.email:
self.email = User.objects.normalize_email(self.email)
if not self.user.identities.exclude(pk=self.pk).filter(is_main=True).exists():
if not self.created_at:
self.is_main = True
elif not self.is_main:
raise exceptions.ValidationError(
{"is_main": "A user should have one and only one main identity."}
)
super().clean()
def email_user(self, subject, message, from_email=None, **kwargs):
"""Email this user."""
if not self.email:
raise ValueError("You must first set an email for the user.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
class Team(BaseModel):
@@ -368,7 +310,7 @@ class Team(BaseModel):
return self.name
def save(self, *args, **kwargs):
"""Overriding save function to compute the slug."""
"""Override save function to compute the slug."""
self.slug = self.get_slug()
return super().save(*args, **kwargs)
@@ -435,6 +377,34 @@ class TeamAccess(BaseModel):
def __str__(self):
return f"{self.user!s} is {self.role:s} in team {self.team!s}"
def save(self, *args, **kwargs):
"""
Override save function to fire webhooks on any addition or update
to a team access.
"""
if self._state.adding:
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)
return instance
def delete(self, *args, **kwargs):
"""
Override delete method to fire webhooks on to team accesses.
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)
def get_abilities(self, user):
"""
Compute and return abilities for a given user taking into account
@@ -485,6 +455,34 @@ class TeamAccess(BaseModel):
}
class TeamWebhook(BaseModel):
"""Webhooks fired on changes in teams."""
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)
status = models.CharField(
max_length=10,
default=WebhookStatusChoices.PENDING,
choices=WebhookStatusChoices.choices,
)
class Meta:
db_table = "people_team_webhook"
verbose_name = _("Team webhook")
verbose_name_plural = _("Team webhooks")
def __str__(self):
return f"Webhook to {self.url} for {self.team}"
def get_headers(self):
"""Build header dict from webhook object."""
headers = {"Content-Type": "application/json"}
if self.secret:
headers["Authorization"] = f"Bearer {self.secret:s}"
return headers
class Invitation(BaseModel):
"""User invitation to teams."""
@@ -528,8 +526,8 @@ class Invitation(BaseModel):
"""Validate fields."""
super().clean()
# Check if an identity already exists for the provided email
if Identity.objects.filter(email=self.email).exists():
# Check if a user already exists for the provided email
if User.objects.filter(email=self.email).exists():
raise exceptions.ValidationError(
{"email": _("This email is already associated to a registered user.")}
)
@@ -574,6 +572,7 @@ class Invitation(BaseModel):
with override(self.issuer.language):
template_vars = {
"title": _("Invitation to join Desk!"),
"site": Site.objects.get_current(),
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
@@ -1,12 +1,11 @@
"""Unit tests for the `get_or_create_user` function."""
"""Unit tests for the Authentication Backends."""
from django.core.exceptions import SuspiciousOperation
import pytest
from core import models
from core.authentication import OIDCAuthenticationBackend
from core.factories import IdentityFactory
from core import factories, models
from core.authentication.backends import OIDCAuthenticationBackend
pytestmark = pytest.mark.django_db
@@ -19,26 +18,19 @@ def test_authentication_getter_existing_user_no_email(
"""
klass = OIDCAuthenticationBackend()
# Create a user and its identity
identity = IdentityFactory(name=None)
# Create multiple identities for a user
for _ in range(5):
IdentityFactory(user=identity.user)
user = factories.UserFactory()
def get_userinfo_mocked(*args):
return {"sub": identity.sub}
return {"sub": user.sub}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(1):
user = klass.get_or_create_user(
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
identity.refresh_from_db()
assert user == identity.user
assert user == authenticated_user
def test_authentication_getter_existing_user_with_email(
@@ -48,19 +40,12 @@ def test_authentication_getter_existing_user_with_email(
When the user's info contains an email and targets an existing user,
"""
klass = OIDCAuthenticationBackend()
identity = IdentityFactory(name="John Doe")
# Create multiple identities for a user
for _ in range(5):
IdentityFactory(user=identity.user)
assert models.User.objects.count() == 1
user = factories.UserFactory(name="John Doe")
def get_userinfo_mocked(*args):
return {
"sub": identity.sub,
"email": identity.email,
"sub": user.sub,
"email": user.email,
"first_name": "John",
"last_name": "Doe",
}
@@ -69,11 +54,11 @@ def test_authentication_getter_existing_user_with_email(
# Only 1 query because email and names have not changed
with django_assert_num_queries(1):
user = klass.get_or_create_user(
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert models.User.objects.get() == user
assert user == authenticated_user
@pytest.mark.parametrize(
@@ -89,23 +74,14 @@ def test_authentication_getter_existing_user_change_fields(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the email or name fields on the identity when they change.
The email on the user should not be changed.
It should update the email or name fields on the user when they change.
"""
klass = OIDCAuthenticationBackend()
identity = IdentityFactory(name="John Doe", email="john.doe@example.com")
user_email = identity.user.email
# Create multiple identities for a user
for _ in range(5):
IdentityFactory(user=identity.user)
assert models.User.objects.count() == 1
user = factories.UserFactory(name="John Doe", email="john.doe@example.com")
def get_userinfo_mocked(*args):
return {
"sub": identity.sub,
"sub": user.sub,
"email": email,
"first_name": first_name,
"last_name": last_name,
@@ -115,23 +91,20 @@ def test_authentication_getter_existing_user_change_fields(
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
user = klass.get_or_create_user(
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
identity.refresh_from_db()
assert identity.email == email
assert identity.name == f"{first_name:s} {last_name:s}"
assert models.User.objects.count() == 1
assert user == identity.user
assert user.email == user_email
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.name == f"{first_name:s} {last_name:s}"
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
User's info doesn't contain an email, created user's email should be empty.
User's info doesn't contain an email/name, created user's email/name should be empty.
"""
klass = OIDCAuthenticationBackend()
@@ -144,11 +117,9 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
access_token="test-token", id_token=None, payload=None
)
identity = user.identities.get()
assert identity.sub == "123"
assert identity.email is None
assert user.sub == "123"
assert user.email is None
assert user.name is None
assert user.password == "!"
assert models.User.objects.count() == 1
@@ -156,11 +127,9 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
def test_authentication_getter_new_user_with_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
User's email and name should be set on the identity.
The "email" field on the User model should not be set as it is reserved for staff users.
User's email and name should be set on the user.
"""
klass = OIDCAuthenticationBackend()
email = "people@example.com"
def get_userinfo_mocked(*args):
@@ -172,12 +141,10 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
access_token="test-token", id_token=None, payload=None
)
identity = user.identities.get()
assert identity.sub == "123"
assert identity.email == email
assert identity.name == "John Doe"
assert user.email is None
assert user.sub == "123"
assert user.email == email
assert user.name == "John Doe"
assert user.password == "!"
assert models.User.objects.count() == 1
@@ -0,0 +1,10 @@
"""Unit tests for the Authentication URLs."""
from core.authentication.urls import urlpatterns
def test_urls_override_default_mozilla_django_oidc():
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
url_names = [u.name for u in urlpatterns]
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
@@ -0,0 +1,231 @@
"""Unit tests for the Authentication Views."""
from unittest import mock
from urllib.parse import parse_qs, urlparse
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.exceptions import SuspiciousOperation
from django.test import RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import crypto
import pytest
from rest_framework.test import APIClient
from core import factories
from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView
pytestmark = pytest.mark.django_db
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_anonymous():
"""Anonymous users calling the logout url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_custom")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
)
def test_view_logout(mocked_oidc_logout_url):
"""Authenticated users should be redirected to OIDC provider for logout."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
)
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
"""Authenticated users should be logged out when no OIDC provider is available."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/default-redirect-logout"
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback_anonymous():
"""Anonymous users calling the logout callback url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_callback")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@pytest.mark.parametrize(
"initial_oidc_states",
[{}, {"other_state": "foo"}],
)
def test_view_logout_persist_state(initial_oidc_states):
"""State value should be persisted in session's data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_oidc_states:
request.session["oidc_states"] = initial_oidc_states
request.session.save()
mocked_state = "mock_state"
OIDCLogoutView().persist_state(request, mocked_state)
assert "oidc_states" in request.session
assert request.session["oidc_states"] == {
"mock_state": {},
**initial_oidc_states,
}
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
@mock.patch.object(OIDCLogoutView, "persist_state")
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
def test_view_logout_construct_oidc_logout_url(
mocked_get_random_string, mocked_persist_state
):
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
request.session["oidc_id_token"] = "mocked_oidc_id_token"
request.session.save()
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
mocked_persist_state.assert_called_once()
mocked_get_random_string.assert_called_once()
params = parse_qs(urlparse(redirect_url).query)
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
assert params["state"][0] == "mocked_state"
url = reverse("oidc_logout_callback")
assert url in params["post_logout_redirect_uri"][0]
@override_settings(LOGOUT_REDIRECT_URL="/")
def test_view_logout_construct_oidc_logout_url_none_id_token():
"""If no ID token is available in the session,
the user should be redirected to the final URL."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
assert redirect_url == "/"
@pytest.mark.parametrize(
"initial_state",
[None, {"other_state": "foo"}],
)
def test_view_logout_callback_wrong_state(initial_state):
"""Should raise an error if OIDC state doesn't match session data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_state:
request.session["oidc_states"] = initial_state
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with pytest.raises(SuspiciousOperation) as excinfo:
callback_view(request)
assert (
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
)
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback():
"""If state matches, callback should clear OIDC state and redirects."""
user = factories.UserFactory()
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
mocked_state = "mocked_state"
request.session["oidc_states"] = {mocked_state: {}}
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
def clear_user(request):
# Assert state is cleared prior to logout
assert request.session["oidc_states"] == {}
request.user = AnonymousUser()
mock_logout.side_effect = clear_user
response = callback_view(request)
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"
@@ -33,7 +33,7 @@ def test_openapi_client_schema():
)
assert output.getvalue() == ""
response = Client().get("/v1.0/swagger.json")
response = Client().get("/api/v1.0/swagger.json")
assert response.status_code == 200
with open(
@@ -2,9 +2,12 @@
Test for team accesses API endpoints in People's core app : create
"""
import json
import random
import re
import pytest
import responses
from rest_framework.test import APIClient
from core import factories, models
@@ -38,9 +41,7 @@ def test_api_team_accesses_create_authenticated_unrelated():
Authenticated users should not be allowed to create team accesses for a team to
which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
other_user = factories.UserFactory()
team = factories.TeamFactory()
@@ -63,9 +64,7 @@ def test_api_team_accesses_create_authenticated_unrelated():
def test_api_team_accesses_create_authenticated_member():
"""Members of a team should not be allowed to create team accesses."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "member")])
other_user = factories.UserFactory()
@@ -93,9 +92,7 @@ def test_api_team_accesses_create_authenticated_administrator():
"""
Administrators of a team should be able to create team accesses except for the "owner" role.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
@@ -146,9 +143,7 @@ def test_api_team_accesses_create_authenticated_owner():
"""
Owners of a team should be able to create team accesses whatever the role.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "owner")])
other_user = factories.UserFactory()
@@ -174,3 +169,60 @@ def test_api_team_accesses_create_authenticated_owner():
"role": role,
"user": str(other_user.id),
}
def test_api_team_accesses_create_webhook():
"""
When the team has a webhook, creating a team access should fire a call.
"""
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "owner")])
webhook = factories.TeamWebhookFactory(team=team)
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
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
assert rsp.call_count == 1
assert rsps.calls[0].request.url == webhook.url
# Payload sent to scim provider
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(other_user.id),
"email": other_user.email,
"type": "User",
}
],
}
],
}
@@ -2,9 +2,12 @@
Test for team accesses API endpoints in People's core app : delete
"""
import json
import random
import re
import pytest
import responses
from rest_framework.test import APIClient
from core import factories, models
@@ -29,9 +32,7 @@ def test_api_team_accesses_delete_authenticated():
Authenticated users should not be allowed to delete a team access for a
team to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
access = factories.TeamAccessFactory()
client = APIClient()
@@ -49,9 +50,7 @@ def test_api_team_accesses_delete_member():
Authenticated users should not be allowed to delete a team access for a
team in which they are a simple member.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
@@ -73,9 +72,7 @@ def test_api_team_accesses_delete_administrators():
Users who are administrators in a team should be allowed to delete an access
from the team provided it is not ownership.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
@@ -99,9 +96,7 @@ def test_api_team_accesses_delete_owners_except_owners():
Users should be able to delete the team access of another user
for a team of which they are owner provided it is not an owner access.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
@@ -125,9 +120,7 @@ def test_api_team_accesses_delete_owners_for_owners():
Users should not be allowed to delete the team access of another owner
even for a team in which they are direct owner.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
@@ -148,18 +141,75 @@ def test_api_team_accesses_delete_owners_last_owner():
"""
It should not be possible to delete the last owner access from a team
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
assert models.TeamAccess.objects.count() == 1
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_webhook():
"""
When the team has a webhook, deleting a team access should fire a call.
"""
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "administrator")])
webhook = factories.TeamWebhookFactory(team=team)
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(user)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert rsp.call_count == 1
assert rsps.calls[0].request.url == webhook.url
# Payload sent to scim provider
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "remove",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": access.user.email,
"type": "User",
}
],
}
],
}
assert models.TeamAccess.objects.count() == 1
assert models.TeamAccess.objects.filter(user=access.user).exists() is False
@@ -28,9 +28,7 @@ def test_api_team_accesses_list_authenticated_unrelated():
Authenticated users should not be allowed to list team accesses for a team
to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(3, team=team)
@@ -57,19 +55,12 @@ def test_api_team_accesses_list_authenticated_related():
Authenticated users should be able to list team accesses for a team
to which they are related, with a given role.
"""
identity = factories.IdentityFactory(is_main=True)
user = identity.user
user, administrator, owner = factories.UserFactory.create_batch(3)
team = factories.TeamFactory()
owner = factories.IdentityFactory(is_main=True)
access1 = factories.TeamAccessFactory.create(
team=team, user=owner.user, role="owner"
)
administrator = factories.IdentityFactory(is_main=True)
access1 = factories.TeamAccessFactory.create(team=team, user=owner, role="owner")
access2 = factories.TeamAccessFactory.create(
team=team, user=administrator.user, role="administrator"
team=team, user=administrator, role="administrator"
)
# Ensure this user's role is different from other team members to test abilities' computation
@@ -93,8 +84,8 @@ def test_api_team_accesses_list_authenticated_related():
"id": str(user_access.id),
"user": {
"id": str(user_access.user.id),
"email": str(identity.email),
"name": str(identity.name),
"email": str(user.email),
"name": str(user.name),
},
"role": str(user_access.role),
"abilities": user_access.get_abilities(user),
@@ -124,46 +115,152 @@ def test_api_team_accesses_list_authenticated_related():
)
def test_api_team_accesses_list_authenticated_main_identity():
def test_api_team_accesses_list_find_members(django_assert_num_queries):
"""
Name and email should be returned from main identity only
Authenticated users should be able to search users access with a case-insensitive and
partial query on the name.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory(user=user, is_main=True)
factories.IdentityFactory(user=user) # additional non-main identity
dave = factories.UserFactory(name="dave bowman", email=None)
frank = factories.UserFactory(name="frank poole", email=None)
mary = factories.UserFactory(name="mary poole", email=None)
nicole = factories.UserFactory(name="nicole foole", email=None)
factories.UserFactory(email="tester@ministry.fr", name="john doe")
factories.UserFactory(name="heywood floyd", email=None)
factories.UserFactory(name="Andrei Smyslov", email=None)
factories.TeamFactory.create_batch(10)
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user) # random role
# Add Mary, Nicole and Dave in the same team
team = factories.TeamFactory(
name="Odyssey",
users=[
(mary, models.RoleChoices.OWNER),
(nicole, models.RoleChoices.ADMIN),
(dave, models.RoleChoices.MEMBER),
],
)
factories.TeamFactory(users=[(frank, models.RoleChoices.MEMBER)])
# other team members should appear, with correct identity
other_user = factories.UserFactory()
other_main_identity = factories.IdentityFactory(is_main=True, user=other_user)
factories.IdentityFactory(user=other_user)
factories.TeamAccessFactory.create(team=team, user=other_user)
# Search users in the team Odyssey
client = APIClient()
client.force_login(mary)
# Accesses for other teams to which the user is related should not be listed either
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
# 5 queries are needed here:
# - 1 query: select on user authenticated
# - 4 queries: get all users, owner included (2 more queries)
with django_assert_num_queries(5):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 3
# We can find David Bowman
# 3 queries are needed here:
# - 1 query: user authenticated
# - 2 queries: search user query with match
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?q=bowman",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 1
dave_access = dave.accesses.get(team=team)
assert response.json()["results"][0]["id"] == str(dave_access.id)
# We can find Nicole
# 3 queries are needed here:
# - 1 query: user authenticated
# - 2 queries: search user query with match
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?q=nicol",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 1
nicole_access = nicole.accesses.get(team=team)
assert response.json()["results"][0]["id"] == str(nicole_access.id)
# We can find Nicole and Mary
# 5 queries are needed here:
# - 1 query: select on user authenticated
# - 4 queries: search user query with match and the owner found (2 more queries)
with django_assert_num_queries(5):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?q=ool",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 2
user_access_ids = sorted([user["id"] for user in response.json()["results"]])
mary_access = mary.accesses.get(team=team)
assert sorted([str(mary_access.id), str(nicole_access.id)]) == user_access_ids
# We cannot find Andrei, because he isn't a member of the current team
# 2 queries are needed here:
# - 1 query: select on user authenticated
# - 1 query: search user query with no match
with django_assert_num_queries(2):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?q=andrei",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 0
# We can find Mary
# 5 queries are needed here:
# - 1 query: select on user authenticated
# - 4 queries: search user query with match and an owner found (2 more queries)
with django_assert_num_queries(5):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?q=mary",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 1
assert response.json()["results"][0]["id"] == str(mary_access.id)
def test_api_team_accesses__list_find_members_by_email():
"""
Authenticated users should be able to search users access with a case-insensitive and
partial query on the email.
"""
user = factories.UserFactory(name=None)
# set all names to None to match only on emails
colleague1 = factories.UserFactory(name=None, email="prudence_crandall@edu.us")
colleague2 = factories.UserFactory(name=None, email="reinebrunehaut@gouv.fr")
colleague3 = factories.UserFactory(name=None, email="artemisia.gentileschi@arte.it")
# Add all colleague in the same team
team = factories.TeamFactory(
users=[
(user, models.RoleChoices.ADMIN),
(colleague1, models.RoleChoices.OWNER),
(colleague2, models.RoleChoices.ADMIN),
(colleague3, models.RoleChoices.MEMBER),
],
)
factories.TeamAccessFactory.create_batch(4)
# Create another team with similar email
factories.TeamFactory(
users=[
(
factories.UserFactory(name=None, email="bruneau@gouv.fr"),
models.RoleChoices.ADMIN,
),
],
)
# Search users in the team Odyssey
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 2
users_info = [
(access["user"]["email"], access["user"]["name"])
for access in response.json()["results"]
]
# user information should be returned from main identity
assert sorted(users_info) == sorted(
[
(str(identity.email), str(identity.name)),
(str(other_main_identity.email), str(other_main_identity.name)),
]
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?q=BRUNE",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 1
assert response.json()["results"][0]["user"]["email"] == "reinebrunehaut@gouv.fr"
def test_api_team_accesses_list_authenticated_constant_numqueries(
@@ -173,31 +270,28 @@ def test_api_team_accesses_list_authenticated_constant_numqueries(
The number of queries should not depend on the amount of fetched accesses.
"""
user = factories.UserFactory()
factories.IdentityFactory(user=user, is_main=True)
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user) # random role
client = APIClient()
client.force_login(user)
# Only 4 queries are needed to efficiently fetch team accesses,
# Only 3 queries are needed to efficiently fetch team accesses,
# related users and identities :
# - query retrieving logged-in user for user_role annotation
# - count from pagination
# - query prefetching users' main identity
# - distinct from viewset
with django_assert_num_queries(4):
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
# create 20 new team members
for _ in range(20):
extra_user = factories.IdentityFactory(is_main=True).user
extra_user = factories.UserFactory()
factories.TeamAccessFactory(team=team, user=extra_user)
# num queries should still be 4
with django_assert_num_queries(4):
# num queries should still be the same
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
@@ -210,14 +304,12 @@ def test_api_team_accesses_list_authenticated_ordering():
"""Team accesses can be ordered by "role"."""
user = factories.UserFactory()
factories.IdentityFactory(user=user, is_main=True)
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user)
# create 20 new team members
for _ in range(20):
extra_user = factories.IdentityFactory(is_main=True).user
extra_user = factories.UserFactory()
factories.TeamAccessFactory(team=team, user=extra_user)
client = APIClient()
@@ -242,44 +334,34 @@ def test_api_team_accesses_list_authenticated_ordering():
assert sorted(results, reverse=True) == results
@pytest.mark.parametrize("ordering_fields", ["name", "email"])
def test_api_team_accesses_list_authenticated_ordering_user(ordering_fields):
"""Team accesses can be ordered by user's fields "email" or "name"."""
@pytest.mark.parametrize("ordering_field", ["email", "name"])
def test_api_team_accesses_list_authenticated_ordering_user(ordering_field):
"""Team accesses can be ordered by user's fields."""
user = factories.UserFactory()
factories.IdentityFactory(user=user, is_main=True)
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user)
# create 20 new team members
for _ in range(20):
extra_user = factories.IdentityFactory(is_main=True).user
extra_user = factories.UserFactory()
factories.TeamAccessFactory(team=team, user=extra_user)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering={ordering_fields}",
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=user__{ordering_field}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 21
results = [
team_access["user"][ordering_fields]
for team_access in response.json()["results"]
]
assert sorted(results) == results
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=-{ordering_fields}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 21
def normalize(x):
"""Mimic Django order_by, which is case-insensitive and space-insensitive"""
return x.casefold().replace(" ", "")
results = [
team_access["user"][ordering_fields]
team_access["user"][ordering_field]
for team_access in response.json()["results"]
]
assert sorted(results, reverse=True) == results
assert sorted(results, key=normalize) == results
@@ -31,9 +31,7 @@ def test_api_team_accesses_retrieve_authenticated_unrelated():
Authenticated users should not be allowed to retrieve a team access for
a team to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
access = factories.TeamAccessFactory(team=factories.TeamFactory())
client = APIClient()
@@ -62,9 +60,7 @@ def test_api_team_accesses_retrieve_authenticated_related():
A user who is related to a team should be allowed to retrieve the
associated team user accesses.
"""
identity = factories.IdentityFactory(is_main=True)
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user)
@@ -79,8 +75,8 @@ def test_api_team_accesses_retrieve_authenticated_related():
"id": str(access.id),
"user": {
"id": str(access.user.id),
"email": str(identity.email),
"name": str(identity.name),
"email": str(user.email),
"name": str(user.name),
},
"role": str(access.role),
"abilities": access.get_abilities(user),
@@ -43,9 +43,7 @@ def test_api_team_accesses_update_authenticated_unrelated():
Authenticated users should not be allowed to update a team access for a team to which
they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
@@ -72,9 +70,7 @@ def test_api_team_accesses_update_authenticated_unrelated():
def test_api_team_accesses_update_authenticated_member():
"""Members of a team should not be allowed to update its accesses."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
old_values = serializers.TeamAccessSerializer(instance=access).data
@@ -105,9 +101,7 @@ def test_api_team_accesses_update_administrator_except_owner():
A user who is an administrator in a team should be allowed to update a user
access for this team, as long as they don't try to set the role to owner.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team,
@@ -151,9 +145,7 @@ def test_api_team_accesses_update_administrator_from_owner():
A user who is an administrator in a team, should not be allowed to update
the user access of an "owner" for this team.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(team=team, user=other_user, role="owner")
@@ -184,9 +176,7 @@ def test_api_team_accesses_update_administrator_to_owner():
A user who is an administrator in a team, should not be allowed to update
the user access of another user to grant team ownership.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(
@@ -227,9 +217,7 @@ def test_api_team_accesses_update_owner_except_owner():
A user who is an owner in a team should be allowed to update
a user access for this team except for existing "owner" accesses.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "owner")])
factories.UserFactory()
access = factories.TeamAccessFactory(
@@ -275,9 +263,7 @@ def test_api_team_accesses_update_owner_for_owners():
A user who is "owner" of a team should not be allowed to update
an existing owner access for this team.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
@@ -307,9 +293,7 @@ def test_api_team_accesses_update_owner_self():
A user who is owner of a team should be allowed to update
their own user access provided there are other owners in the team.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
@@ -10,7 +10,7 @@ from rest_framework.status import (
)
from rest_framework.test import APIClient
from core.factories import IdentityFactory, TeamFactory
from core.factories import TeamFactory, UserFactory
from core.models import Team
pytestmark = pytest.mark.django_db
@@ -34,11 +34,10 @@ def test_api_teams_create_authenticated():
Authenticated users should be able to create teams and should automatically be declared
as the owner of the newly created team.
"""
identity = IdentityFactory()
user = identity.user
user = UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.post(
"/api/v1.0/teams/",
@@ -58,9 +57,9 @@ def test_api_teams_create_authenticated_slugify_name():
"""
Creating teams should automatically generate a slug.
"""
identity = IdentityFactory()
user = UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.post(
"/api/v1.0/teams/",
@@ -87,10 +86,10 @@ def test_api_teams_create_authenticated_expected_slug(param):
"""
Creating teams should automatically create unaccented, no unicode, lower-case slug.
"""
identity = IdentityFactory()
user = UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.post(
"/api/v1.0/teams/",
@@ -110,10 +109,10 @@ def test_api_teams_create_authenticated_unique_slugs():
Creating teams should raise an error if already existing slug.
"""
TeamFactory(name="existing team")
identity = IdentityFactory()
user = UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.post(
"/api/v1.0/teams/",
@@ -33,10 +33,10 @@ def test_api_teams_delete_authenticated_unrelated():
Authenticated users should not be allowed to delete a team to which they are not
related.
"""
identity = factories.IdentityFactory()
user = factories.UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
team = factories.TeamFactory()
@@ -54,8 +54,7 @@ def test_api_teams_delete_authenticated_member():
Authenticated users should not be allowed to delete a team for which they are
only a member.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -78,8 +77,7 @@ def test_api_teams_delete_authenticated_administrator():
Authenticated users should not be allowed to delete a team for which they are
administrator.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -102,8 +100,7 @@ def test_api_teams_delete_authenticated_owner():
Authenticated users should be able to delete a team for which they are directly
owner.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -31,8 +31,7 @@ def test_api_teams_list_authenticated():
Authenticated users should be able to list teams
they are an owner/administrator/member of.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -59,8 +58,7 @@ def test_api_teams_list_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -105,8 +103,7 @@ def test_api_teams_list_pagination(
def test_api_teams_list_authenticated_distinct():
"""A team with several related users should only be listed once."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -129,8 +126,7 @@ def test_api_teams_order():
"""
Test that the endpoint GET teams is sorted in 'created_at' descending order by default.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -159,8 +155,7 @@ def test_api_teams_order_param():
Test that the 'created_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -3,7 +3,7 @@ Tests for Teams API endpoint in People's core app: retrieve
"""
import pytest
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND
from rest_framework import status
from rest_framework.test import APIClient
from core import factories
@@ -16,7 +16,7 @@ def test_api_teams_retrieve_anonymous():
team = factories.TeamFactory()
response = APIClient().get(f"/api/v1.0/teams/{team.id}/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@@ -27,17 +27,17 @@ def test_api_teams_retrieve_authenticated_unrelated():
Authenticated users should not be allowed to retrieve a team to which they are
not related.
"""
identity = factories.IdentityFactory()
user = factories.UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
team = factories.TeamFactory()
response = client.get(
f"/api/v1.0/teams/{team.id!s}/",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
@@ -46,8 +46,7 @@ def test_api_teams_retrieve_authenticated_related():
Authenticated users should be allowed to retrieve a team to which they
are related whatever the role.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -60,7 +59,7 @@ def test_api_teams_retrieve_authenticated_related():
f"/api/v1.0/teams/{team.id!s}/",
)
assert response.status_code == HTTP_200_OK
assert response.status_code == status.HTTP_200_OK
assert sorted(response.json().pop("accesses")) == sorted(
[
str(access1.id),
@@ -45,10 +45,10 @@ def test_api_teams_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a team to which they are not related.
"""
identity = factories.IdentityFactory()
user = factories.UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
team = factories.TeamFactory()
old_team_values = serializers.TeamSerializer(instance=team).data
@@ -73,8 +73,7 @@ def test_api_teams_update_authenticated_members():
Users who are members of a team but not administrators should
not be allowed to update it.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -101,8 +100,7 @@ def test_api_teams_update_authenticated_members():
def test_api_teams_update_authenticated_administrators():
"""Administrators of a team should be allowed to update it."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -134,8 +132,7 @@ def test_api_teams_update_authenticated_administrators():
def test_api_teams_update_authenticated_owners():
"""Administrators of a team should be allowed to update it,
apart from read-only fields."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -170,8 +167,7 @@ def test_api_teams_update_administrator_or_owner_of_another():
Being administrator or owner of a team should not grant authorization to update
another team.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -200,8 +196,7 @@ def test_api_teams_update_existing_slug_should_return_error():
Updating a team's name to an existing slug should return a bad request,
instead of creating a duplicate.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
+23 -42
View File
@@ -69,8 +69,7 @@ def test_api_contacts_list_authenticated_no_query():
Authenticated users should be able to list contacts without applying a query.
Profile and base contacts should be excluded.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
contact = factories.ContactFactory(owner=user)
user.profile_contact = contact
user.save()
@@ -108,8 +107,7 @@ def test_api_contacts_list_authenticated_by_full_name():
Authenticated users should be able to search users with a case insensitive and
partial query on the full name.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
dave = factories.BaseContactFactory(full_name="David Bowman")
nicole = factories.BaseContactFactory(full_name="Nicole Foole")
@@ -150,8 +148,7 @@ def test_api_contacts_list_authenticated_by_full_name():
def test_api_contacts_list_authenticated_uppercase_content():
"""Upper case content should be found by lower case query."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
dave = factories.BaseContactFactory(full_name="EEE", short_name="AAA")
@@ -175,8 +172,7 @@ def test_api_contacts_list_authenticated_uppercase_content():
def test_api_contacts_list_authenticated_capital_query():
"""Upper case query should find lower case content."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
@@ -200,8 +196,7 @@ def test_api_contacts_list_authenticated_capital_query():
def test_api_contacts_list_authenticated_accented_content():
"""Accented content should be found by unaccented query."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
dave = factories.BaseContactFactory(full_name="ééé", short_name="ààà")
@@ -225,8 +220,7 @@ def test_api_contacts_list_authenticated_accented_content():
def test_api_contacts_list_authenticated_accented_query():
"""Accented query should find unaccented content."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
@@ -264,9 +258,7 @@ def test_api_contacts_retrieve_authenticated_owned():
"""
Authenticated users should be allowed to retrieve a contact they own.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
contact = factories.ContactFactory(owner=user)
client = APIClient()
@@ -289,11 +281,11 @@ def test_api_contacts_retrieve_authenticated_public():
"""
Authenticated users should be able to retrieve public contacts.
"""
identity = factories.IdentityFactory()
user = factories.UserFactory()
contact = factories.BaseContactFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 200
@@ -311,11 +303,11 @@ def test_api_contacts_retrieve_authenticated_other():
"""
Authenticated users should not be allowed to retrieve another user's contacts.
"""
identity = factories.IdentityFactory()
user = factories.UserFactory()
contact = factories.ContactFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 403
@@ -339,10 +331,10 @@ def test_api_contacts_create_anonymous_forbidden():
def test_api_contacts_create_authenticated_missing_base():
"""Anonymous users should be able to create users."""
identity = factories.IdentityFactory(user__profile_contact=None)
user = factories.UserFactory(profile_contact=None)
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.post(
"/api/v1.0/contacts/",
@@ -360,8 +352,7 @@ def test_api_contacts_create_authenticated_missing_base():
def test_api_contacts_create_authenticated_successful():
"""Authenticated users should be able to create contacts."""
identity = factories.IdentityFactory(user__profile_contact=None)
user = identity.user
user = factories.UserFactory(profile_contact=None)
base_contact = factories.BaseContactFactory()
client = APIClient()
@@ -407,9 +398,7 @@ def test_api_contacts_create_authenticated_existing_override():
Trying to create a contact for base contact that is already overriden by the user
should receive a 400 error.
"""
identity = factories.IdentityFactory(user__profile_contact=None)
user = identity.user
user = factories.UserFactory(profile_contact=None)
base_contact = factories.BaseContactFactory()
factories.ContactFactory(base=base_contact, owner=user)
@@ -463,8 +452,7 @@ def test_api_contacts_update_authenticated_owned():
"""
Authenticated users should be allowed to update their own contacts.
"""
identity = factories.IdentityFactory(user__profile_contact=None)
user = identity.user
user = factories.UserFactory(profile_contact=None)
client = APIClient()
client.force_login(user)
@@ -498,8 +486,7 @@ def test_api_contacts_update_authenticated_profile():
"""
Authenticated users should be allowed to update their profile contact.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -534,8 +521,7 @@ def test_api_contacts_update_authenticated_other():
"""
Authenticated users should not be allowed to update contacts owned by other users.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -573,8 +559,7 @@ def test_api_contacts_delete_list_anonymous():
def test_api_contacts_delete_list_authenticated():
"""Authenticated users should not be allowed to delete a list of contacts."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -602,8 +587,7 @@ def test_api_contacts_delete_authenticated_public():
"""
Authenticated users should not be allowed to delete a public contact.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -622,8 +606,7 @@ def test_api_contacts_delete_authenticated_owner():
"""
Authenticated users should be allowed to delete a contact they own.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
contact = factories.ContactFactory(owner=user)
client = APIClient()
@@ -642,8 +625,7 @@ def test_api_contacts_delete_authenticated_profile():
"""
Authenticated users should be allowed to delete their profile contact.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
contact = factories.ContactFactory(owner=user, base=None)
user.profile_contact = contact
user.save()
@@ -663,8 +645,7 @@ def test_api_contacts_delete_authenticated_other():
"""
Authenticated users should not be allowed to delete a contact they don't own.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
contact = factories.ContactFactory()
client = APIClient()
@@ -34,15 +34,15 @@ def test_api_team_invitations__create__anonymous():
def test_api_team_invitations__create__authenticated_outsider():
"""Users outside of team should not be permitted to invite to team."""
identity = factories.IdentityFactory()
user = factories.UserFactory()
team = factories.TeamFactory()
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build()
).data
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
@@ -57,16 +57,16 @@ def test_api_team_invitations__create__authenticated_outsider():
)
def test_api_team_invitations__create__privileged_members(role):
"""Owners and administrators should be able to invite new members."""
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, role)])
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, role)])
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build()
).data
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
@@ -79,16 +79,16 @@ def test_api_team_invitations__create__members():
"""
Members should not be able to invite new members.
"""
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, "member")])
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "member")])
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build()
).data
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
@@ -102,16 +102,16 @@ def test_api_team_invitations__create__members():
def test_api_team_invitations__create__issuer_and_team_automatically_added():
"""Team and issuer fields should auto-complete."""
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, "owner")])
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "owner")])
# Generate a random invitation
invitation = factories.InvitationFactory.build()
invitation_data = {"email": invitation.email, "role": invitation.role}
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_data,
@@ -120,7 +120,7 @@ def test_api_team_invitations__create__issuer_and_team_automatically_added():
assert response.status_code == status.HTTP_201_CREATED
# team and issuer automatically set
assert response.json()["team"] == str(team.id)
assert response.json()["issuer"] == str(identity.user.id)
assert response.json()["issuer"] == str(user.id)
def test_api_team_invitations__create__cannot_duplicate_invitation():
@@ -129,8 +129,8 @@ def test_api_team_invitations__create__cannot_duplicate_invitation():
team = existing_invitation.team
# Grant privileged role on the Team to the user
identity = factories.IdentityFactory()
factories.TeamAccessFactory(team=team, user=identity.user, role="administrator")
user = factories.UserFactory()
factories.TeamAccessFactory(team=team, user=user, role="administrator")
# Create a new invitation to the same team with the exact same email address
duplicated_invitation = serializers.InvitationSerializer(
@@ -138,7 +138,7 @@ def test_api_team_invitations__create__cannot_duplicate_invitation():
).data
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
duplicated_invitation,
@@ -154,11 +154,9 @@ def test_api_team_invitations__create__cannot_invite_existing_users():
"""
Should not be able to invite already existing users.
"""
user = factories.UserFactory()
user, existing_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "administrator")])
existing_user = factories.IdentityFactory(is_main=True)
# Build an invitation to the email of an exising identity in the db
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build(email=existing_user.email, team=team)
@@ -166,6 +164,7 @@ def test_api_team_invitations__create__cannot_invite_existing_users():
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
@@ -190,14 +189,10 @@ def test_api_team_invitations__list__authenticated():
Authenticated user should be able to list invitations
in teams they belong to, including from other issuers.
"""
identity = factories.IdentityFactory()
other_user = factories.UserFactory()
team = factories.TeamFactory(
users=[(identity.user, "administrator"), (other_user, "owner")]
)
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "administrator"), (other_user, "owner")])
invitation = factories.InvitationFactory(
team=team, role="administrator", issuer=identity.user
team=team, role="administrator", issuer=user
)
other_invitations = factories.InvitationFactory.create_batch(
2, team=team, role="member", issuer=other_user
@@ -208,7 +203,7 @@ def test_api_team_invitations__list__authenticated():
factories.InvitationFactory.create_batch(2, team=other_team, role="member")
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id}/invitations/",
)
@@ -235,24 +230,22 @@ def test_api_team_invitations__list__expired_invitations_still_listed(settings):
"""
Expired invitations are still listed.
"""
identity = factories.IdentityFactory()
user = factories.UserFactory()
other_user = factories.UserFactory()
team = factories.TeamFactory(
users=[(identity.user, "administrator"), (other_user, "owner")]
)
team = factories.TeamFactory(users=[(user, "administrator"), (other_user, "owner")])
# override settings to accelerate validation expiration
settings.INVITATION_VALIDITY_DURATION = 1 # second
expired_invitation = factories.InvitationFactory(
team=team,
role="member",
issuer=identity.user,
issuer=user,
)
time.sleep(1)
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id}/invitations/",
)
@@ -293,12 +286,12 @@ def test_api_team_invitations__retrieve__unrelated_user():
"""
Authenticated unrelated users should not be able to retrieve invitations.
"""
user = factories.IdentityFactory(user=factories.UserFactory()).user
user = factories.UserFactory()
invitation = factories.InvitationFactory()
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
)
@@ -311,8 +304,7 @@ def test_api_team_invitations__retrieve__team_member():
Authenticated team members should be able to retrieve invitations
whatever their role in the team.
"""
user = factories.IdentityFactory(user=factories.UserFactory()).user
user = factories.UserFactory()
invitation = factories.InvitationFactory()
factories.TeamAccessFactory(team=invitation.team, user=user, role="member")
@@ -342,13 +334,14 @@ def test_api_team_invitations__update__forbidden(method):
"""
Update of invitations is currently forbidden.
"""
user = factories.IdentityFactory(user=factories.UserFactory()).user
user = factories.UserFactory()
invitation = factories.InvitationFactory()
factories.TeamAccessFactory(team=invitation.team, user=user, role="owner")
client = APIClient()
client.force_login(user)
response = None
if method == "put":
response = client.put(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
@@ -357,6 +350,7 @@ def test_api_team_invitations__update__forbidden(method):
response = client.patch(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
)
assert response is not None
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
assert response.json()["detail"] == f'Method "{method.upper()}" not allowed.'
@@ -373,13 +367,12 @@ def test_api_team_invitations__delete__anonymous():
def test_api_team_invitations__delete__authenticated_outsider():
"""Members outside of team should not cancel invitations."""
identity = factories.IdentityFactory()
user = factories.UserFactory()
team = factories.TeamFactory()
invitation = factories.InvitationFactory(team=team)
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
@@ -389,13 +382,12 @@ def test_api_team_invitations__delete__authenticated_outsider():
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_team_invitations__delete__privileged_members(role):
"""Privileged member should be able to cancel invitation."""
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, role)])
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, role)])
invitation = factories.InvitationFactory(team=team)
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
@@ -404,13 +396,12 @@ def test_api_team_invitations__delete__privileged_members(role):
def test_api_team_invitations__delete__members():
"""Member should not be able to cancel invitation."""
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, "member")])
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "member")])
invitation = factories.InvitationFactory(team=team)
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
+99 -226
View File
@@ -35,10 +35,10 @@ def test_api_users_list_authenticated():
"""
Authenticated users should be able to list all users.
"""
identity = factories.IdentityFactory()
user = factories.UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
factories.UserFactory.create_batch(2)
response = client.get(
@@ -53,23 +53,15 @@ def test_api_users_authenticated_list_by_email():
Authenticated users should be able to search users with a case-insensitive and
partial query on the email.
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="john doe")
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
dave = factories.UserFactory(email="david.bowman@work.com", name=None)
nicole = factories.UserFactory(email="nicole_foole@work.com", name=None)
frank = factories.UserFactory(email="frank_poole@work.com", name=None)
factories.UserFactory(email="heywood_floyd@work.com", name=None)
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(
email="david.bowman@work.com", name=None, is_main=True
)
nicole = factories.IdentityFactory(
email="nicole_foole@work.com", name=None, is_main=True
)
frank = factories.IdentityFactory(
email="frank_poole@work.com", name=None, is_main=True
)
factories.IdentityFactory(email="heywood_floyd@work.com", name=None, is_main=True)
# Full query should work
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
@@ -77,14 +69,14 @@ def test_api_users_authenticated_list_by_email():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids[0] == str(dave.user.id)
assert user_ids[0] == str(dave.id)
# Partial query should work
response = client.get("/api/v1.0/users/?q=fran")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids[0] == str(frank.user.id)
assert user_ids[0] == str(frank.id)
# Result that matches a trigram twice ranks better than result that matches once
response = client.get("/api/v1.0/users/?q=ole")
@@ -92,7 +84,7 @@ def test_api_users_authenticated_list_by_email():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
# "Nicole Foole" matches twice on "ole"
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
assert user_ids == [str(nicole.id), str(frank.id)]
# Even with a low similarity threshold, query should match expected emails
response = client.get("/api/v1.0/users/?q=ool")
@@ -100,22 +92,22 @@ def test_api_users_authenticated_list_by_email():
assert response.status_code == HTTP_200_OK
assert response.json()["results"] == [
{
"id": str(nicole.user.id),
"id": str(nicole.id),
"email": nicole.email,
"name": nicole.name,
"is_device": nicole.user.is_device,
"is_staff": nicole.user.is_staff,
"language": nicole.user.language,
"timezone": str(nicole.user.timezone),
"is_device": nicole.is_device,
"is_staff": nicole.is_staff,
"language": nicole.language,
"timezone": str(nicole.timezone),
},
{
"id": str(frank.user.id),
"id": str(frank.id),
"email": frank.email,
"name": frank.name,
"is_device": frank.user.is_device,
"is_staff": frank.user.is_staff,
"language": frank.user.language,
"timezone": str(frank.user.timezone),
"is_device": frank.is_device,
"is_staff": frank.is_staff,
"language": frank.language,
"timezone": str(frank.timezone),
},
]
@@ -125,17 +117,15 @@ def test_api_users_authenticated_list_by_name():
Authenticated users should be able to search users with a case-insensitive and
partial query on the name.
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="john doe")
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
dave = factories.UserFactory(name="dave bowman", email=None)
nicole = factories.UserFactory(name="nicole foole", email=None)
frank = factories.UserFactory(name="frank poole", email=None)
factories.UserFactory(name="heywood floyd", email=None)
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(name="dave bowman", email=None, is_main=True)
nicole = factories.IdentityFactory(name="nicole foole", email=None, is_main=True)
frank = factories.IdentityFactory(name="frank poole", email=None, is_main=True)
factories.IdentityFactory(name="heywood floyd", email=None, is_main=True)
# Full query should work
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
@@ -143,14 +133,14 @@ def test_api_users_authenticated_list_by_name():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids[0] == str(dave.user.id)
assert user_ids[0] == str(dave.id)
# Partial query should work
response = client.get("/api/v1.0/users/?q=fran")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids[0] == str(frank.user.id)
assert user_ids[0] == str(frank.id)
# Result that matches a trigram twice ranks better than result that matches once
response = client.get("/api/v1.0/users/?q=ole")
@@ -158,7 +148,7 @@ def test_api_users_authenticated_list_by_name():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
# "Nicole Foole" matches twice on "ole"
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
assert user_ids == [str(nicole.id), str(frank.id)]
# Even with a low similarity threshold, query should match expected user
response = client.get("/api/v1.0/users/?q=ool")
@@ -166,22 +156,22 @@ def test_api_users_authenticated_list_by_name():
assert response.status_code == HTTP_200_OK
assert response.json()["results"] == [
{
"id": str(nicole.user.id),
"id": str(nicole.id),
"email": nicole.email,
"name": nicole.name,
"is_device": nicole.user.is_device,
"is_staff": nicole.user.is_staff,
"language": nicole.user.language,
"timezone": str(nicole.user.timezone),
"is_device": nicole.is_device,
"is_staff": nicole.is_staff,
"language": nicole.language,
"timezone": str(nicole.timezone),
},
{
"id": str(frank.user.id),
"id": str(frank.id),
"email": frank.email,
"name": frank.name,
"is_device": frank.user.is_device,
"is_staff": frank.user.is_staff,
"language": frank.user.language,
"timezone": str(frank.user.timezone),
"is_device": frank.is_device,
"is_staff": frank.is_staff,
"language": frank.language,
"timezone": str(frank.timezone),
},
]
@@ -192,20 +182,14 @@ def test_api_users_authenticated_list_by_name_and_email():
partial query on the name and email.
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="john doe")
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
nicole = factories.UserFactory(email="nicole_foole@work.com", name="nicole foole")
frank = factories.UserFactory(email="oleg_poole@work.com", name=None)
david = factories.UserFactory(email=None, name="david role")
client = APIClient()
client.force_login(user)
nicole = factories.IdentityFactory(
email="nicole_foole@work.com", name="nicole foole", is_main=True
)
frank = factories.IdentityFactory(
email="oleg_poole@work.com", name=None, is_main=True
)
david = factories.IdentityFactory(email=None, name="david role", is_main=True)
# Result that matches a trigram in name and email ranks better than result that matches once
response = client.get("/api/v1.0/users/?q=ole")
@@ -215,7 +199,7 @@ def test_api_users_authenticated_list_by_name_and_email():
# "Nicole Foole" matches twice on "ole" in her name and twice on "ole" in her email
# "Oleg poole" matches twice on "ole" in her email
# "David role" matches once on "ole" in his name
assert user_ids == [str(nicole.user.id), str(frank.user.id), str(david.user.id)]
assert user_ids == [str(nicole.id), str(frank.id), str(david.id)]
def test_api_users_authenticated_list_exclude_users_already_in_team(
@@ -225,27 +209,26 @@ def test_api_users_authenticated_list_exclude_users_already_in_team(
Authenticated users should be able to search users
but the result should exclude all users already in the given team.
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="john doe")
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
dave = factories.UserFactory(name="dave bowman", email=None)
nicole = factories.UserFactory(name="nicole foole", email=None)
frank = factories.UserFactory(name="frank poole", email=None)
mary = factories.UserFactory(name="mary poole", email=None)
factories.UserFactory(name="heywood floyd", email=None)
factories.UserFactory(name="Andrei Smyslov", email=None)
factories.TeamFactory.create_batch(10)
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(name="dave bowman", email=None, is_main=True)
nicole = factories.IdentityFactory(name="nicole foole", email=None, is_main=True)
frank = factories.IdentityFactory(name="frank poole", email=None, is_main=True)
mary = factories.IdentityFactory(name="mary poole", email=None, is_main=True)
factories.IdentityFactory(name="heywood floyd", email=None, is_main=True)
factories.IdentityFactory(name="Andrei Smyslov", email=None, is_main=True)
factories.TeamFactory.create_batch(10)
# Add Dave and Frank in the same team
team = factories.TeamFactory(
users=[
(dave.user, models.RoleChoices.MEMBER),
(frank.user, models.RoleChoices.MEMBER),
(dave, models.RoleChoices.MEMBER),
(frank, models.RoleChoices.MEMBER),
]
)
factories.TeamFactory(users=[(nicole.user, models.RoleChoices.MEMBER)])
factories.TeamFactory(users=[(nicole, models.RoleChoices.MEMBER)])
# Search user to add him/her to a team, we give a team id to the request
# to exclude all users already in the team
@@ -266,118 +249,25 @@ def test_api_users_authenticated_list_exclude_users_already_in_team(
# - user authenticated
# - search user query
# - User
# - Identity
with django_assert_num_queries(4):
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/users/?q=ool&team_id={team.id}",
)
assert response.status_code == HTTP_200_OK
user_ids = sorted([user["id"] for user in response.json()["results"]])
assert user_ids == sorted([str(mary.user.id), str(nicole.user.id)])
def test_api_users_authenticated_list_multiple_identities_single_user():
"""
User with multiple identities should appear only once in results.
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="eva karl")
client = APIClient()
client.force_login(user)
dave = factories.UserFactory()
factories.IdentityFactory(
user=dave, email="dave.bowman@work.com", name="dave bowman"
)
factories.IdentityFactory(user=dave, email="dave.bowman@fun.fr", name="dave bowman")
# Full query should work
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == HTTP_200_OK
# A single user is returned, despite similarity matching both emails
assert response.json()["count"] == 1
assert response.json()["results"][0]["id"] == str(dave.id)
def test_api_users_authenticated_list_multiple_identities_multiple_users():
"""
User with multiple identities should be ranked
on their best matching identity.
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="john doe")
client = APIClient()
client.force_login(user)
dave = factories.UserFactory()
dave_identity = factories.IdentityFactory(
user=dave, email="dave.bowman@work.com", is_main=True, name="dave bowman"
)
factories.IdentityFactory(user=dave, email="babibou@ehehe.com", name="babihou")
davina_identity = factories.IdentityFactory(
user=factories.UserFactory(), email="davina.bowan@work.com", name="davina"
)
prue_identity = factories.IdentityFactory(
user=factories.UserFactory(),
email="prudence.crandall@work.com",
name="prudence",
)
# Full query should work
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 3
assert response.json()["results"] == [
{
"id": str(dave.id),
"email": dave_identity.email,
"name": dave_identity.name,
"is_device": dave_identity.user.is_device,
"is_staff": dave_identity.user.is_staff,
"language": dave_identity.user.language,
"timezone": str(dave_identity.user.timezone),
},
{
"id": str(davina_identity.user.id),
"email": davina_identity.email,
"name": davina_identity.name,
"is_device": davina_identity.user.is_device,
"is_staff": davina_identity.user.is_staff,
"language": davina_identity.user.language,
"timezone": str(davina_identity.user.timezone),
},
{
"id": str(prue_identity.user.id),
"email": prue_identity.email,
"name": prue_identity.name,
"is_device": prue_identity.user.is_device,
"is_staff": prue_identity.user.is_staff,
"language": prue_identity.user.language,
"timezone": str(prue_identity.user.timezone),
},
]
assert user_ids == sorted([str(mary.id), str(nicole.id)])
def test_api_users_authenticated_list_uppercase_content():
"""Upper case content should be found by lower case query."""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="eva karl")
user = factories.UserFactory(email="tester@ministry.fr", name="eva karl")
dave = factories.UserFactory(
email="DAVID.BOWMAN@INTENSEWORK.COM", name="DAVID BOWMAN"
)
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(
email="DAVID.BOWMAN@INTENSEWORK.COM", name="DAVID BOWMAN"
)
# Unaccented full address
response = client.get(
"/api/v1.0/users/?q=david.bowman@intensework.com",
@@ -385,7 +275,7 @@ def test_api_users_authenticated_list_uppercase_content():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.user.id)]
assert user_ids == [str(dave.id)]
# Partial query
response = client.get(
@@ -394,19 +284,17 @@ def test_api_users_authenticated_list_uppercase_content():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.user.id)]
assert user_ids == [str(dave.id)]
def test_api_users_list_authenticated_capital_query():
"""Upper case query should find lower case content."""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="eva karl")
user = factories.UserFactory(email="tester@ministry.fr", name="eva karl")
dave = factories.UserFactory(email="david.bowman@work.com", name="david bowman")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(email="david.bowman@work.com", name="david bowman")
# Full uppercase query
response = client.get(
"/api/v1.0/users/?q=DAVID.BOWMAN@WORK.COM",
@@ -414,7 +302,7 @@ def test_api_users_list_authenticated_capital_query():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.user.id)]
assert user_ids == [str(dave.id)]
# Partial uppercase email
response = client.get(
@@ -423,21 +311,17 @@ def test_api_users_list_authenticated_capital_query():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.user.id)]
assert user_ids == [str(dave.id)]
def test_api_contacts_list_authenticated_accented_query():
"""Accented content should be found by unaccented query."""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="john doe")
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
helene = factories.UserFactory(email="helene.bowman@work.com", name="helene bowman")
client = APIClient()
client.force_login(user)
helene = factories.IdentityFactory(
email="helene.bowman@work.com", name="helene bowman"
)
# Accented full query
response = client.get(
"/api/v1.0/users/?q=hélène.bowman@work.com",
@@ -445,7 +329,7 @@ def test_api_contacts_list_authenticated_accented_query():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(helene.user.id)]
assert user_ids == [str(helene.id)]
# Unaccented partial email
response = client.get(
@@ -454,7 +338,7 @@ def test_api_contacts_list_authenticated_accented_query():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(helene.user.id)]
assert user_ids == [str(helene.id)]
@mock.patch.object(Pagination, "get_page_size", return_value=3)
@@ -462,8 +346,7 @@ def test_api_users_list_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -503,8 +386,7 @@ def test_api_users_list_pagination_page_size(
page_size,
):
"""Page's size on pagination should work as expected."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -528,8 +410,7 @@ def test_api_users_list_pagination_wrong_page_size(
page_size,
):
"""Page's size on pagination should be limited to "max_page_size"."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -563,8 +444,7 @@ def test_api_users_retrieve_me_anonymous():
def test_api_users_retrieve_me_authenticated():
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
identity = factories.IdentityFactory(is_main=True)
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -582,8 +462,8 @@ def test_api_users_retrieve_me_authenticated():
assert response.status_code == HTTP_200_OK
assert response.json() == {
"id": str(user.id),
"name": str(identity.name),
"email": str(identity.email),
"name": str(user.name),
"email": str(user.email),
"language": user.language,
"timezone": str(user.timezone),
"is_device": False,
@@ -608,8 +488,7 @@ def test_api_users_retrieve_authenticated_self():
Authenticated users should be allowed to retrieve their own user.
The returned object should not contain the password.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -626,12 +505,10 @@ def test_api_users_retrieve_authenticated_other():
Authenticated users should be able to retrieve another user's detail view with
limited information.
"""
identity = factories.IdentityFactory()
user, other_user = factories.UserFactory.create_batch(2)
client = APIClient()
client.force_login(identity.user)
other_user = factories.UserFactory()
client.force_login(user)
response = client.get(
f"/api/v1.0/users/{other_user.id!s}/",
@@ -658,8 +535,7 @@ def test_api_users_create_anonymous():
def test_api_users_create_authenticated():
"""Authenticated users should not be able to create users via the API."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -706,8 +582,7 @@ def test_api_users_update_authenticated_self():
Authenticated users should be able to update their own user but only "language"
and "timezone" fields.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -735,10 +610,10 @@ def test_api_users_update_authenticated_self():
def test_api_users_update_authenticated_other():
"""Authenticated users should not be allowed to update other users."""
identity = factories.IdentityFactory()
user = factories.UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
@@ -788,8 +663,7 @@ def test_api_users_patch_authenticated_self():
Authenticated users should be able to patch their own user but only "language"
and "timezone" fields.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -818,27 +692,27 @@ def test_api_users_patch_authenticated_self():
def test_api_users_patch_authenticated_other():
"""Authenticated users should not be allowed to patch other users."""
identity = factories.IdentityFactory()
user = factories.UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
other_user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=other_user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
for key, new_value in new_user_values.items():
response = client.put(
f"/api/v1.0/users/{user.id!s}/",
f"/api/v1.0/users/{other_user.id!s}/",
{key: new_value},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
other_user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=other_user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
@@ -856,11 +730,11 @@ def test_api_users_delete_list_anonymous():
def test_api_users_delete_list_authenticated():
"""Authenticated users should not be allowed to delete a list of users."""
user = factories.UserFactory()
factories.UserFactory.create_batch(2)
identity = factories.IdentityFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.delete(
"/api/v1.0/users/",
@@ -884,11 +758,10 @@ def test_api_users_delete_authenticated():
"""
Authenticated users should not be allowed to delete a user other than themselves.
"""
identity = factories.IdentityFactory()
other_user = factories.UserFactory()
user, other_user = factories.UserFactory.create_batch(2)
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.delete(f"/api/v1.0/users/{other_user.id!s}/")
@@ -898,13 +771,13 @@ def test_api_users_delete_authenticated():
def test_api_users_delete_self():
"""Authenticated users should not be able to delete their own user."""
identity = factories.IdentityFactory()
user = factories.UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.delete(
f"/api/v1.0/users/{identity.user.id!s}/",
f"/api/v1.0/users/{user.id!s}/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
@@ -1,183 +0,0 @@
"""
Unit tests for the Identity model
"""
from django.core.exceptions import ValidationError
import pytest
from core import factories, models
pytestmark = pytest.mark.django_db
def test_models_identities_str_main():
"""The str representation should be the email address with indication that it is main."""
identity = factories.IdentityFactory(email="david@example.com")
assert str(identity) == "david@example.com[main]"
def test_models_identities_str_secondary():
"""The str representation of a secondary email should be the email address."""
main_identity = factories.IdentityFactory()
secondary_identity = factories.IdentityFactory(
user=main_identity.user, email="david@example.com"
)
assert str(secondary_identity) == "david@example.com"
def test_models_identities_is_main_automatic():
"""The first identity created for a user should automatically be set as main."""
user = factories.UserFactory()
identity = models.Identity.objects.create(
user=user, sub="123", email="david@example.com"
)
assert identity.is_main is True
def test_models_identities_is_main_exists():
"""A user should always keep one and only one of its identities as main."""
user = factories.UserFactory()
main_identity, _secondary_identity = factories.IdentityFactory.create_batch(
2, user=user
)
assert main_identity.is_main is True
main_identity.is_main = False
with pytest.raises(
ValidationError, match="A user should have one and only one main identity."
):
main_identity.save()
def test_models_identities_is_main_switch():
"""Setting a secondary identity as main should reset the existing main identity."""
user = factories.UserFactory()
first_identity, second_identity = factories.IdentityFactory.create_batch(
2, user=user
)
assert first_identity.is_main is True
second_identity.is_main = True
second_identity.save()
second_identity.refresh_from_db()
assert second_identity.is_main is True
first_identity.refresh_from_db()
assert first_identity.is_main is False
def test_models_identities_email_not_required():
"""The 'email' field can be blank."""
user = factories.UserFactory()
models.Identity.objects.create(user=user, sub="123", email=None)
def test_models_identities_user_required():
"""The 'user' field is required."""
with pytest.raises(models.User.DoesNotExist, match="Identity has no user."):
models.Identity.objects.create(user=None, email="david@example.com")
def test_models_identities_email_unique_same_user():
"""The 'email' field should be unique for a given user."""
email = factories.IdentityFactory()
with pytest.raises(
ValidationError,
match="Identity with this User and Email address already exists.",
):
factories.IdentityFactory(user=email.user, email=email.email)
def test_models_identities_email_unique_different_users():
"""The 'email' field should not be unique among users."""
email = factories.IdentityFactory()
factories.IdentityFactory(email=email.email)
def test_models_identities_email_normalization():
"""The 'email' field should be automatically normalized upon saving."""
email = factories.IdentityFactory()
email.email = "Thomas.Jefferson@Example.com"
email.save()
assert email.email == "Thomas.Jefferson@example.com"
def test_models_identities_ordering():
"""Identities should be returned ordered by main status then by their email address."""
user = factories.UserFactory()
factories.IdentityFactory.create_batch(5, user=user)
emails = models.Identity.objects.all()
assert emails[0].is_main is True
for i in range(3):
assert emails[i + 1].is_main is False
assert emails[i + 2].email >= emails[i + 1].email
def test_models_identities_sub_null():
"""The 'sub' field should not be null."""
user = factories.UserFactory()
with pytest.raises(ValidationError, match="This field cannot be null."):
models.Identity.objects.create(user=user, sub=None)
def test_models_identities_sub_blank():
"""The 'sub' field should not be blank."""
user = factories.UserFactory()
with pytest.raises(ValidationError, match="This field cannot be blank."):
models.Identity.objects.create(user=user, email="david@example.com", sub="")
def test_models_identities_sub_unique():
"""The 'sub' field should be unique."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
with pytest.raises(ValidationError, match="Identity with this Sub already exists."):
models.Identity.objects.create(user=user, sub=identity.sub)
def test_models_identities_sub_max_length():
"""The 'sub' field should be 255 characters maximum."""
factories.IdentityFactory(sub="a" * 255)
with pytest.raises(ValidationError) as excinfo:
factories.IdentityFactory(sub="a" * 256)
assert (
str(excinfo.value)
== "{'sub': ['Ensure this value has at most 255 characters (it has 256).']}"
)
def test_models_identities_sub_special_characters():
"""The 'sub' field should accept periods, dashes, +, @ and underscores."""
identity = factories.IdentityFactory(sub="dave.bowman-1+2@hal_9000")
assert identity.sub == "dave.bowman-1+2@hal_9000"
def test_models_identities_sub_spaces():
"""The 'sub' field should not accept spaces."""
with pytest.raises(ValidationError) as excinfo:
factories.IdentityFactory(sub="a b")
assert str(excinfo.value) == (
"{'sub': ['Enter a valid sub. This value may contain only letters, numbers, "
"and @/./+/-/_ characters.']}"
)
def test_models_identities_sub_upper_case():
"""The 'sub' field should accept upper case characters."""
identity = factories.IdentityFactory(sub="John")
assert identity.sub == "John"
def test_models_identities_sub_ascii():
"""The 'sub' field should accept non ASCII letters."""
identity = factories.IdentityFactory(sub="rené")
assert identity.sub == "rené"
@@ -84,7 +84,7 @@ def test_models_invitations__is_expired(settings):
def test_models_invitation__new_user__convert_invitations_to_accesses():
"""
Upon creating a new identity, invitations linked to that email
Upon creating a new user, invitations linked to that email
should be converted to accesses and then deleted.
"""
# Two invitations to the same mail but to different teams
@@ -95,16 +95,14 @@ def test_models_invitation__new_user__convert_invitations_to_accesses():
team=invitation_to_team2.team
) # another person invited to team2
new_identity = factories.IdentityFactory(
is_main=True, email=invitation_to_team1.email
)
new_user = factories.UserFactory(email=invitation_to_team1.email)
# The invitation regarding
assert models.TeamAccess.objects.filter(
team=invitation_to_team1.team, user=new_identity.user
team=invitation_to_team1.team, user=new_user
).exists()
assert models.TeamAccess.objects.filter(
team=invitation_to_team2.team, user=new_identity.user
team=invitation_to_team2.team, user=new_user
).exists()
assert not models.Invitation.objects.filter(
team=invitation_to_team1.team, email=invitation_to_team1.email
@@ -119,7 +117,7 @@ def test_models_invitation__new_user__convert_invitations_to_accesses():
def test_models_invitation__new_user__filter_expired_invitations():
"""
Upon creating a new identity, valid invitations should be converted into accesses
Upon creating a new user, valid invitations should be converted into accesses
and expired invitations should remain unchanged.
"""
with freeze_time("2020-01-01"):
@@ -127,11 +125,11 @@ def test_models_invitation__new_user__filter_expired_invitations():
user_email = expired_invitation.email
valid_invitation = factories.InvitationFactory(email=user_email)
new_identity = factories.IdentityFactory(is_main=True, email=user_email)
new_user = factories.UserFactory(email=user_email)
# valid invitation should have granted access to the related team
assert models.TeamAccess.objects.filter(
team=valid_invitation.team, user=new_identity.user
team=valid_invitation.team, user=new_user
).exists()
assert not models.Invitation.objects.filter(
team=valid_invitation.team, email=user_email
@@ -139,14 +137,14 @@ def test_models_invitation__new_user__filter_expired_invitations():
# expired invitation should not have been consumed
assert not models.TeamAccess.objects.filter(
team=expired_invitation.team, user=new_identity.user
team=expired_invitation.team, user=new_user
).exists()
assert models.Invitation.objects.filter(
team=expired_invitation.team, email=user_email
).exists()
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 8), (1, 11), (20, 11)])
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 4), (1, 7), (20, 7)])
def test_models_invitation__new_user__user_creation_constant_num_queries(
django_assert_num_queries, num_invitations, num_queries
):
@@ -160,15 +158,14 @@ def test_models_invitation__new_user__user_creation_constant_num_queries(
for _ in range(0, num_invitations):
factories.InvitationFactory(email=user_email, team=factories.TeamFactory())
user = factories.UserFactory()
factories.UserFactory()
# with no invitation, we skip an "if", resulting in 8 requests
# otherwise, we should have 11 queries with any number of invitations
with django_assert_num_queries(num_queries):
models.Identity.objects.create(
is_main=True,
models.User.objects.create(
email=user_email,
user=user,
password="!",
name="Prudence C.",
sub=uuid.uuid4(),
)
@@ -257,13 +254,14 @@ def test_models_team_invitations_email():
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
(email,) = mail.outbox
email = mail.outbox[0]
assert email.to == [invitation.email]
assert email.subject == "Invitation to join Desk!"
email_content = " ".join(email.body.split())
assert "Invitation to join Desk!" in email_content
assert "[//example.com]" in email_content
@mock.patch(
@@ -2,12 +2,16 @@
Unit tests for the TeamAccess model
"""
import json
import re
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
import pytest
import responses
from core import factories
from core import factories, models
pytestmark = pytest.mark.django_db
@@ -16,16 +20,13 @@ def test_models_team_accesses_str():
"""
The str representation should include user name, team full name and role.
"""
contact = factories.ContactFactory(full_name="David Bowman")
user = contact.owner
user.profile_contact = contact
user.save()
user = factories.UserFactory()
access = factories.TeamAccessFactory(
role="member",
user=user,
team__name="admins",
)
assert str(access) == "David Bowman is member in team admins"
assert str(access) == f"{user} is {access.role} in team {access.team}"
def test_models_team_accesses_unique():
@@ -39,6 +40,94 @@ def test_models_team_accesses_unique():
factories.TeamAccessFactory(user=access.user, team=access.team)
def test_models_team_accesses_create_webhook():
"""
When the team has a webhook, creating a team access should fire a call.
"""
user = factories.UserFactory()
team = factories.TeamFactory()
webhook = factories.TeamWebhookFactory(team=team)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
models.TeamAccess.objects.create(user=user, team=team)
assert rsp.call_count == 1
assert rsps.calls[0].request.url == webhook.url
# Payload sent to scim provider
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(user.id),
"email": user.email,
"type": "User",
}
],
}
],
}
def test_models_team_accesses_delete_webhook():
"""
When the team has a webhook, deleting a team access should fire a call.
"""
team = factories.TeamFactory()
webhook = factories.TeamWebhookFactory(team=team)
access = factories.TeamAccessFactory(team=team)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
access.delete()
assert rsp.call_count == 1
assert rsps.calls[0].request.url == webhook.url
# Payload sent to scim provider
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "remove",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": access.user.email,
"type": "User",
}
],
}
],
}
assert models.TeamAccess.objects.exists() is False
# get_abilities
+114 -20
View File
@@ -14,13 +14,18 @@ pytestmark = pytest.mark.django_db
def test_models_users_str():
"""The str representation should be the full name."""
"""
user str representation should return name or email when avalaible.
Otherwise, it should return the sub.
"""
user = factories.UserFactory()
contact = factories.ContactFactory(full_name="david bowman", owner=user)
user.profile_contact = contact
user.save()
assert str(user) == user.name
assert str(user) == "david bowman"
no_name_user = factories.UserFactory(name=None)
assert str(no_name_user) == no_name_user.email
no_name_no_email_user = factories.UserFactory(name=None, email=None)
assert str(no_name_no_email_user) == f"User {no_name_no_email_user.sub}"
def test_models_users_id_unique():
@@ -30,23 +35,101 @@ def test_models_users_id_unique():
factories.UserFactory(id=user.id)
def test_models_users_email_unique():
"""The "email" field should be unique except for the null value."""
def test_models_users_sub_null():
"""The 'sub' field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null.") as excinfo:
models.User.objects.create(sub=None, password="password")
assert str(excinfo.value) == "{'sub': ['This field cannot be null.']}"
def test_models_users_sub_blank():
"""The 'sub' field should not be blank."""
with pytest.raises(ValidationError, match="This field cannot be blank.") as excinfo:
models.User.objects.create(sub="", password="password")
assert str(excinfo.value) == "{'sub': ['This field cannot be blank.']}"
def test_models_users_sub_unique():
"""The 'sub' field should be unique."""
user = factories.UserFactory()
with pytest.raises(
ValidationError, match="User with this Email address already exists."
):
models.User.objects.create(email=user.email)
with pytest.raises(ValidationError, match="User with this Sub already exists."):
models.User.objects.create(sub=user.sub, password="password")
def test_models_users_sub_max_length():
"""The 'sub' field should be 255 characters maximum."""
factories.UserFactory(sub="a" * 255)
with pytest.raises(ValidationError) as excinfo:
factories.UserFactory(sub="a" * 256)
assert (
str(excinfo.value)
== "{'sub': ['Ensure this value has at most 255 characters (it has 256).']}"
)
def test_models_users_sub_special_characters():
"""The 'sub' field should accept periods, dashes, +, @ and underscores."""
user = factories.UserFactory(sub="dave.bowman-1+2@hal_9000")
assert user.sub == "dave.bowman-1+2@hal_9000"
def test_models_users_sub_spaces():
"""The 'sub' field should not accept spaces."""
with pytest.raises(ValidationError) as excinfo:
factories.UserFactory(sub="a b")
assert str(excinfo.value) == (
"{'sub': ['Enter a valid sub. This value may contain only letters, numbers, "
"and @/./+/-/_ characters.']}"
)
def test_models_users_sub_upper_case():
"""The 'sub' field should accept upper case characters."""
user = factories.UserFactory(sub="John")
assert user.sub == "John"
def test_models_users_sub_ascii():
"""The 'sub' field should accept non ASCII letters."""
user = factories.UserFactory(sub="rené")
assert user.sub == "rené"
def test_models_users_email_not_required():
"""The 'email' field can be blank."""
user = factories.UserFactory(email=None)
assert user.email is None
def test_models_users_email_normalization():
"""The 'email' field should be automatically normalized upon saving."""
user = factories.UserFactory()
user.email = "Thomas.Jefferson@Example.com"
user.save()
assert user.email == "Thomas.Jefferson@example.com"
def test_models_users_email_several_null():
"""Several users with a null value for the "email" field can co-exist."""
factories.UserFactory(email=None)
models.User.objects.create(email=None, password="foo.")
models.User.objects.create(email=None, sub="123", password="foo.")
assert models.User.objects.filter(email__isnull=True).count() == 2
def test_models_users_email_not_unique():
"""The 'email' field should not necessarily be unique among users."""
user = factories.UserFactory()
other_user = factories.UserFactory(email=user.email)
assert user.email == other_user.email
def test_models_users_profile_not_owned():
"""A user cannot declare as profile a contact that not is owned."""
user = factories.UserFactory()
@@ -78,10 +161,9 @@ def test_models_users_profile_owned_by_other():
def test_models_users_send_mail_main_existing():
"""The 'email_user' method should send mail to the user's main email address."""
main_email = factories.IdentityFactory(email="dave@example.com")
user = main_email.user
factories.IdentityFactory.create_batch(2, user=user)
"""The 'email_user' method should send mail to the user's email address."""
user = factories.UserFactory(email="dave@example.com")
factories.UserFactory.create_batch(2)
with mock.patch("django.core.mail.send_mail") as mock_send:
user.email_user("my subject", "my message")
@@ -91,11 +173,23 @@ def test_models_users_send_mail_main_existing():
)
def test_models_users_send_mail_main_missing():
"""The 'email_user' method should fail if the user has no email address."""
def test_models_users_send_mail_main_admin():
"""
The 'email_user' method should send mail to the user's email address.
"""
user = factories.UserFactory()
with pytest.raises(models.Identity.DoesNotExist) as excinfo:
with mock.patch("django.core.mail.send_mail") as mock_send:
user.email_user("my subject", "my message")
assert str(excinfo.value) == "Identity matching query does not exist."
mock_send.assert_called_once_with("my subject", "my message", None, [user.email])
def test_models_users_send_mail_main_missing():
"""The 'email_user' method should fail if the user has no email address."""
user = factories.UserFactory(email=None)
with pytest.raises(ValueError) as excinfo:
user.email_user("my subject", "my message")
assert str(excinfo.value) == "You must first set an email for the user."
+1 -2
View File
@@ -16,8 +16,7 @@ def test_throttle():
"""
Throttle protection should block requests if too many.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -0,0 +1,341 @@
"""Test Team synchronization webhooks."""
import json
import random
import re
from logging import Logger
from unittest import mock
import pytest
import responses
from core import factories
from core.utils.webhooks import scim_synchronizer
pytestmark = pytest.mark.django_db
def test_utils_webhooks_add_user_to_group_no_webhooks():
"""If no webhook is declared on the team, the function should not make any request."""
access = factories.TeamAccessFactory()
with responses.RequestsMock():
scim_synchronizer.add_user_to_group(access.team, access.user)
assert len(responses.calls) == 0
@mock.patch.object(Logger, "info")
def test_utils_webhooks_add_user_to_group_success(mock_info):
"""The user passed to the function should get added."""
user = factories.UserFactory()
access = factories.TeamAccessFactory(user=user)
webhooks = factories.TeamWebhookFactory.create_batch(2, team=access.team)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
# Check headers
headers = rsps.calls[i].request.headers
assert "Authorization" not in headers
assert headers["Content-Type"] == "application/json"
# Payload sent to scim provider
for call in rsps.calls:
payload = json.loads(call.request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": user.email,
"type": "User",
}
],
}
],
}
# Logger
assert mock_info.call_count == 2
for i, webhook in enumerate(webhooks):
assert mock_info.call_args_list[i][0] == (
"%s synchronization succeeded with %s",
"add_user_to_group",
webhook.url,
)
# Status
for webhook in webhooks:
webhook.refresh_from_db()
assert webhook.status == "success"
@mock.patch.object(Logger, "info")
def test_utils_webhooks_remove_user_from_group_success(mock_info):
"""The user passed to the function should get removed."""
user = factories.UserFactory()
access = factories.TeamAccessFactory(user=user)
webhooks = factories.TeamWebhookFactory.create_batch(2, team=access.team)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
scim_synchronizer.remove_user_from_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
# Payload sent to scim provider
for call in rsps.calls:
payload = json.loads(call.request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "remove",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": user.email,
"type": "User",
}
],
}
],
}
# Logger
assert mock_info.call_count == 2
for i, webhook in enumerate(webhooks):
assert mock_info.call_args_list[i][0] == (
"%s synchronization succeeded with %s",
"remove_user_from_group",
webhook.url,
)
# Status
for webhook in webhooks:
webhook.refresh_from_db()
assert webhook.status == "success"
@mock.patch.object(Logger, "error")
@mock.patch.object(Logger, "info")
def test_utils_webhooks_add_user_to_group_failure(mock_info, mock_error):
"""The logger should be called on webhook call failure."""
user = factories.UserFactory()
access = factories.TeamAccessFactory(user=user)
webhooks = factories.TeamWebhookFactory.create_batch(2, team=access.team)
with responses.RequestsMock() as rsps:
# Simulate webhook failure using "responses":
rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=random.choice([404, 301, 302]),
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
# Payload sent to scim provider
for call in rsps.calls:
payload = json.loads(call.request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": user.email,
"type": "User",
}
],
}
],
}
# Logger
assert not mock_info.called
assert mock_error.call_count == 2
for i, webhook in enumerate(webhooks):
assert mock_error.call_args_list[i][0] == (
"%s synchronization failed with %s",
"add_user_to_group",
webhook.url,
)
# Status
for webhook in webhooks:
webhook.refresh_from_db()
assert webhook.status == "failure"
@mock.patch.object(Logger, "error")
@mock.patch.object(Logger, "info")
def test_utils_webhooks_add_user_to_group_retries(mock_info, mock_error):
"""webhooks synchronization supports retries."""
user = factories.UserFactory()
access = factories.TeamAccessFactory(user=user)
webhook = factories.TeamWebhookFactory(team=access.team)
url = re.compile(r".*/Groups/.*")
with responses.RequestsMock() as rsps:
# Make webhook fail 3 times before succeeding using "responses"
all_rsps = [
rsps.add(rsps.PATCH, url, status=500, content_type="application/json"),
rsps.add(rsps.PATCH, url, status=500, content_type="application/json"),
rsps.add(rsps.PATCH, url, status=500, content_type="application/json"),
rsps.add(rsps.PATCH, url, status=200, content_type="application/json"),
]
scim_synchronizer.add_user_to_group(access.team, access.user)
for i in range(4):
assert all_rsps[i].call_count == 1
assert rsps.calls[i].request.url == webhook.url
payload = json.loads(rsps.calls[i].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": user.email,
"type": "User",
}
],
}
],
}
# Logger
assert not mock_error.called
assert mock_info.call_count == 1
assert mock_info.call_args_list[0][0] == (
"%s synchronization succeeded with %s",
"add_user_to_group",
webhook.url,
)
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
@mock.patch.object(Logger, "error")
@mock.patch.object(Logger, "info")
def test_utils_synchronize_course_runs_max_retries_exceeded(mock_info, mock_error):
"""Webhooks synchronization has exceeded max retries and should get logged."""
user = factories.UserFactory()
access = factories.TeamAccessFactory(user=user)
webhook = factories.TeamWebhookFactory(team=access.team)
with responses.RequestsMock() as rsps:
# Simulate webhook temporary failure using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=random.choice([500, 502]),
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
assert rsp.call_count == 5
assert rsps.calls[0].request.url == webhook.url
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": user.email,
"type": "User",
}
],
}
],
}
# Logger
assert not mock_info.called
assert mock_error.call_count == 1
assert mock_error.call_args_list[0][0] == (
"%s synchronization failed due to max retries exceeded with url %s",
"add_user_to_group",
webhook.url,
)
# Status
webhook.refresh_from_db()
assert webhook.status == "failure"
def test_utils_webhooks_add_user_to_group_authorization():
"""Secret token should be passed in authorization header when set."""
user = factories.UserFactory()
access = factories.TeamAccessFactory(user=user)
webhook = factories.TeamWebhookFactory(team=access.team, secret="123")
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
assert rsps.calls[0].request.url == webhook.url
# Check headers
headers = rsps.calls[0].request.headers
assert headers["Authorization"] == "Bearer 123"
assert headers["Content-Type"] == "application/json"
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
View File
+70
View File
@@ -0,0 +1,70 @@
"""A minimalist SCIM client to synchronize with remote service providers."""
import logging
import requests
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=["PATCH"],
)
)
session = requests.Session()
session.mount("http://", adapter)
session.mount("https://", adapter)
class SCIMClient:
"""A minimalist SCIM client for our needs."""
def add_user_to_group(self, webhook, user):
"""Add a user to a group from its ID or email."""
payload = {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{"value": str(user.id), "email": user.email, "type": "User"}
],
}
],
}
return session.patch(
webhook.url,
json=payload,
headers=webhook.get_headers(),
verify=False,
timeout=3,
)
def remove_user_from_group(self, webhook, user):
"""Remove a user from a group by its ID or email."""
payload = {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "remove",
"path": "members",
"value": [
{"value": str(user.id), "email": user.email, "type": "User"}
],
}
],
}
return session.patch(
webhook.url,
json=payload,
headers=webhook.get_headers(),
verify=False,
timeout=3,
)
+75
View File
@@ -0,0 +1,75 @@
"""Fire webhooks with synchronous retries"""
import logging
import requests
from core.enums import WebhookStatusChoices
from .scim import SCIMClient
logger = logging.getLogger(__name__)
class WebhookSCIMClient:
"""Wraps the SCIM client to record call results on webhooks."""
def __getattr__(self, name):
"""Handle calls from webhooks to synchronize a team access with a distant application."""
def wrapper(team, user):
"""
Wrap SCIMClient calls to handle retries, error handling and storing result in the
calling Webhook instance.
"""
for webhook in team.webhooks.all():
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,
}
# pylint: disable=no-member
if response.status_code == requests.codes.ok:
logger.info(
"%s synchronization succeeded with %s",
name,
webhook.url,
extra=extra,
)
status = WebhookStatusChoices.SUCCESS
else:
logger.error(
"%s synchronization failed with %s",
name,
webhook.url,
extra=extra,
)
webhook._meta.model.objects.filter(id=webhook.id).update(status=status) # noqa
return wrapper
scim_synchronizer = WebhookSCIMClient()
@@ -115,7 +115,9 @@ def create_demo(stdout):
for i in range(defaults.NB_OBJECTS["users"]):
queue.push(
models.User(
email=f"user{i:d}@example.com",
sub=uuid4(),
email=f"user{i:d}@example.com" if random.random() < 0.97 else None,
name=fake.name() if random.random() < 0.97 else None,
password="!",
is_superuser=False,
is_active=True,
@@ -125,27 +127,6 @@ def create_demo(stdout):
)
queue.flush()
with Timeit(stdout, "Creating identities"):
users_values = list(models.User.objects.values("id", "email"))
for user_dict in users_values:
for i in range(
random.choices(range(5), weights=[5, 50, 30, 10, 5], k=1)[0]
):
user_email = user_dict["email"]
queue.push(
models.Identity(
user_id=user_dict["id"],
sub=uuid4(),
is_main=(i == 0),
# Leave 3% of emails and names empty
email=f"identity{i:d}{user_email:s}"
if random.random() < 0.97
else None,
name=fake.name() if random.random() < 0.97 else None,
)
)
queue.flush()
with Timeit(stdout, "Creating teams"):
for i in range(defaults.NB_OBJECTS["teams"]):
queue.push(
@@ -10,15 +10,17 @@ User = get_user_model()
class Command(BaseCommand):
"""Management command to create superusers from an email and a password"""
"""
Management command to create superusers from a username and a password for demo purposes.
"""
help = "Create a superuser with an email and a password"
help = "Create a superuser with a username and a password for demo purposes"
def add_arguments(self, parser):
"""Define required arguments "email" and "password"."""
"""Define required arguments "username" and "password"."""
parser.add_argument(
"--email",
help=("Email for the user."),
"--username",
help=("Username for the user."),
)
parser.add_argument(
"--password",
@@ -30,11 +32,11 @@ class Command(BaseCommand):
Given an email and a password, create a superuser or upgrade the existing
user to superuser status.
"""
email = options.get("email")
username = options.get("username")
try:
user = User.objects.get(email=email)
user = User.objects.get(sub=username)
except User.DoesNotExist:
user = User(email=email)
user = User(sub=username)
message = "Superuser created successfully."
else:
if user.is_superuser and user.is_staff:
@@ -28,6 +28,18 @@ def test_commands_create_demo():
call_command("create_demo")
assert models.User.objects.count() == 5
assert models.Identity.objects.exists()
assert models.Team.objects.count() == 3
assert models.TeamAccess.objects.count() >= 3
def test_commands_createsuperuser():
"""
The createsuperuser management command should create a user
with superuser permissions.
"""
call_command("createsuperuser", username="admin", password="admin")
assert models.User.objects.count() == 1
user = models.User.objects.get()
assert user.sub == "admin"
+41
View File
@@ -0,0 +1,41 @@
"""Admin classes and registrations for People's mailbox manager app."""
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from mailbox_manager import models
@admin.register(models.MailDomain)
class MailDomainAdmin(admin.ModelAdmin):
"""Mail domain admin interface declaration."""
list_display = (
"name",
"created_at",
"updated_at",
"slug",
"status",
)
search_fields = ("name",)
readonly_fields = ["created_at", "slug"]
@admin.register(models.MailDomainAccess)
class MailDomainAccessAdmin(admin.ModelAdmin):
"""Admin for mail domain accesses model."""
list_display = (
"user",
"domain",
"role",
"created_at",
"updated_at",
)
@admin.register(models.Mailbox)
class MailboxAdmin(admin.ModelAdmin):
"""Admin for mailbox model."""
list_display = ("__str__", "first_name", "last_name")
@@ -0,0 +1,23 @@
"""Permission handlers for the People mailbox manager app."""
from core.api import permissions as core_permissions
from mailbox_manager import models
class AccessPermission(core_permissions.IsAuthenticated):
"""Permission class for access objects."""
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)
class MailBoxPermission(core_permissions.IsAuthenticated):
"""Permission class to manage mailboxes for a mail domain"""
def has_permission(self, request, view):
domain = models.MailDomain.objects.get(slug=view.kwargs.get("domain_slug", ""))
abilities = domain.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
@@ -0,0 +1,60 @@
"""Client serializers for People's mailbox manager app."""
from rest_framework import serializers
from mailbox_manager import models
class MailboxSerializer(serializers.ModelSerializer):
"""Serialize mailbox."""
class Meta:
model = models.Mailbox
fields = ["id", "first_name", "last_name", "local_part", "secondary_email"]
class MailDomainSerializer(serializers.ModelSerializer):
"""Serialize mail domain."""
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.MailDomain
lookup_field = "slug"
fields = [
"id",
"name",
"slug",
"abilities",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"slug",
"abilities",
"created_at",
"updated_at",
]
def get_abilities(self, domain) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return domain.get_abilities(request.user)
return {}
class MailDomainAccessSerializer(serializers.ModelSerializer):
"""Serialize mail domain accesses."""
class Meta:
model = models.MailDomainAccess
fields = [
"id",
"user",
"role",
"created_at",
"updated_at",
]
read_only_fields = ["id"]
@@ -0,0 +1,99 @@
"""API endpoints"""
from rest_framework import filters, mixins, viewsets
from rest_framework import permissions as drf_permissions
from core import models as core_models
from mailbox_manager import models
from mailbox_manager.api import permissions, serializers
# pylint: disable=too-many-ancestors
class MailDomainViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
"""
MailDomain viewset.
GET /api/<version>/mail-domains/
Return a list of mail domains user has access to.
GET /api/<version>/mail-domains/<domain-slug>/
Return details for a mail domain user has access to.
POST /api/<version>/mail-domains/ with expected data:
- name: str
Return newly created domain
"""
permission_classes = [permissions.AccessPermission]
serializer_class = serializers.MailDomainSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "name"]
ordering = ["-created_at"]
lookup_field = "slug"
queryset = models.MailDomain.objects.all()
def get_queryset(self):
return self.queryset.filter(accesses__user=self.request.user)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created mail domain."""
domain = serializer.save()
models.MailDomainAccess.objects.create(
user=self.request.user,
domain=domain,
role=core_models.RoleChoices.OWNER,
)
# pylint: disable=too-many-ancestors
class MailDomainAccessViewSet(
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""
MailDomainAccess viewset.
"""
permission_classes = [drf_permissions.IsAuthenticated]
serializer_class = serializers.MailDomainAccessSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "user", "domain", "role"]
ordering = ["-created_at"]
queryset = models.MailDomainAccess.objects.all()
class MailBoxViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""MailBox ViewSet"""
permission_classes = [permissions.MailBoxPermission]
serializer_class = serializers.MailboxSerializer
filter_backends = [filters.OrderingFilter]
ordering = ["-created_at"]
queryset = models.Mailbox.objects.all()
def get_queryset(self):
"""Custom queryset to get mailboxes related to a mail domain."""
domain_slug = self.kwargs.get("domain_slug", "")
if domain_slug:
return self.queryset.filter(domain__slug=domain_slug)
return self.queryset
def perform_create(self, serializer):
"""Create new mailbox."""
domain_slug = self.kwargs.get("domain_slug", "")
if domain_slug:
serializer.validated_data["domain"] = models.MailDomain.objects.get(
slug=domain_slug
)
super().perform_create(serializer)
+1
View File
@@ -0,0 +1 @@
"""People additionnal application, to manage email adresses."""
+23
View File
@@ -0,0 +1,23 @@
"""
Application enums declaration
"""
from django.db import models
from django.utils.translation import gettext_lazy as _
class MailDomainRoleChoices(models.TextChoices):
"""Defines the possible roles a user can have to access to a mail domain."""
VIEWER = "viewer", _("Viewer")
ADMIN = "administrator", _("Administrator")
OWNER = "owner", _("Owner")
class MailDomainStatusChoices(models.TextChoices):
"""Defines the possible statuses in which a mail domain can be."""
PENDING = "pending", _("Pending")
ENABLED = "enabled", _("Enabled")
FAILED = "failed", _("Failed")
DISABLED = "disabled", _("Disabled")
+77
View File
@@ -0,0 +1,77 @@
"""
Mailbox manager application factories
"""
from django.utils.text import slugify
import factory.fuzzy
from faker import Faker
from core import factories as core_factories
from core import models as core_models
from mailbox_manager import enums, models
fake = Faker()
class MailDomainFactory(factory.django.DjangoModelFactory):
"""A factory to create mail domain. Please not this is a factory to create mail domain with
default values. So the status is pending and no mailbox can be created from it,
until the mail domain is enabled."""
class Meta:
model = models.MailDomain
django_get_or_create = ("name",)
skip_postgeneration_save = True
name = factory.Faker("domain_name")
slug = factory.LazyAttribute(lambda o: slugify(o.name))
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to domain from a given list of users with or without roles."""
if not create or not extracted:
return
for user_entry in extracted:
if isinstance(user_entry, core_models.User):
MailDomainAccessFactory(domain=self, user=user_entry)
else:
MailDomainAccessFactory(
domain=self, user=user_entry[0], role=user_entry[1]
)
class MailDomainEnabledFactory(MailDomainFactory):
"""A factory to create mail domain enabled."""
status = enums.MailDomainStatusChoices.ENABLED
class MailDomainAccessFactory(factory.django.DjangoModelFactory):
"""A factory to create mail domain accesses."""
class Meta:
model = models.MailDomainAccess
user = factory.SubFactory(core_factories.UserFactory)
domain = factory.SubFactory(MailDomainEnabledFactory)
role = factory.fuzzy.FuzzyChoice(
[r[0] for r in enums.MailDomainRoleChoices.choices]
)
class MailboxFactory(factory.django.DjangoModelFactory):
"""A factory to create mailboxes for a mail domain."""
class Meta:
model = models.Mailbox
first_name = factory.Faker("first_name", locale="fr_FR")
last_name = factory.Faker("last_name", locale="de_DE")
local_part = factory.LazyAttribute(
lambda a: f"{slugify(a.first_name)}.{slugify(a.last_name)}"
)
domain = factory.SubFactory(MailDomainEnabledFactory)
secondary_email = factory.Faker("email")
@@ -0,0 +1,67 @@
# Generated by Django 5.0.3 on 2024-04-16 12:51
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='MailDomain',
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')),
('name', models.CharField(max_length=150, unique=True, verbose_name='name')),
],
options={
'verbose_name': 'Mail domain',
'verbose_name_plural': 'Mail domains',
'db_table': 'people_mail_domain',
},
),
migrations.CreateModel(
name='Mailbox',
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=150, validators=[django.core.validators.RegexValidator(regex='^[a-zA-Z0-9_.+-]+$')], verbose_name='local_part')),
('secondary_email', models.EmailField(max_length=254, verbose_name='secondary email address')),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mail_domain', to='mailbox_manager.maildomain')),
],
options={
'verbose_name': 'Mailbox',
'verbose_name_plural': 'Mailboxes',
'db_table': 'people_mail_box',
'unique_together': {('local_part', 'domain')},
},
),
migrations.CreateModel(
name='MailDomainAccess',
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')),
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mail_domain_accesses', to='mailbox_manager.maildomain')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mail_domain_accesses', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'User/mail domain relation',
'verbose_name_plural': 'User/mail domain relations',
'db_table': 'people_mail_domain_accesses',
'unique_together': {('user', 'domain')},
},
),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.0.3 on 2024-04-17 11:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='maildomainaccess',
name='domain',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='mailbox_manager.maildomain'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-06-03 14:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0002_alter_maildomainaccess_domain'),
]
operations = [
migrations.AddField(
model_name='maildomain',
name='slug',
field=models.SlugField(blank=True, max_length=80),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2024-06-03 14:14
from django.db import migrations, models
from django.utils.text import slugify
def populate_slug(apps, schema_editor):
MailDomain = apps.get_model('mailbox_manager', 'MailDomain')
for mail_domain in MailDomain.objects.filter(slug=''):
mail_domain.slug = slugify(mail_domain.name)
mail_domain.save()
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0003_maildomain_slug'),
]
operations = [
migrations.RunPython(populate_slug, reverse_code=migrations.RunPython.noop),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-06-03 14:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0004_maildomain_fill_slug'),
]
operations = [
migrations.AlterField(
model_name='maildomain',
name='slug',
field=models.SlugField(max_length=80, unique=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-07-06 23:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0005_alter_maildomain_slug'),
]
operations = [
migrations.AddField(
model_name='maildomain',
name='status',
field=models.CharField(choices=[('pending', 'Pending'), ('enabled', 'Enabled'), ('failed', 'Failed'), ('disabled', 'Disabled')], default='pending', max_length=10),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-08-02 15:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0006_maildomain_status'),
]
operations = [
migrations.AlterField(
model_name='maildomainaccess',
name='role',
field=models.CharField(choices=[('viewer', 'Viewer'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='viewer', max_length=20),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2024-08-07 09:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0007_alter_maildomainaccess_role'),
]
operations = [
migrations.AddField(
model_name='mailbox',
name='first_name',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='mailbox',
name='last_name',
field=models.CharField(blank=True, max_length=200),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.0.6 on 2024-08-07 09:43
from django.db import migrations, models
from django.db.models import F
def populate_first_name_last_name(apps, schema_editor):
Mailbox = apps.get_model('mailbox_manager', 'Mailbox')
Mailbox.objects.filter(first_name='').update(first_name=F("local_part"))
Mailbox.objects.filter(last_name='').update(last_name=F("local_part"))
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0008_mailbox_first_name_mailbox_last_name'),
]
operations = [
migrations.RunPython(populate_first_name_last_name, reverse_code=migrations.RunPython.noop),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2024-08-07 10:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0009_fill_mailbox_first_name_mailbox_last_name'),
]
operations = [
migrations.AlterField(
model_name='mailbox',
name='first_name',
field=models.CharField(max_length=200),
),
migrations.AlterField(
model_name='mailbox',
name='last_name',
field=models.CharField(max_length=200),
),
]
+144
View File
@@ -0,0 +1,144 @@
"""
Declare and configure the models for the People additional application : mailbox_manager
"""
from django.conf import settings
from django.core import validators
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from core.models import BaseModel
from mailbox_manager.enums import MailDomainRoleChoices, MailDomainStatusChoices
class MailDomain(BaseModel):
"""Domain names from which we will create email addresses (mailboxes)."""
name = models.CharField(
_("name"), max_length=150, null=False, blank=False, unique=True
)
slug = models.SlugField(null=False, blank=False, unique=True, max_length=80)
status = models.CharField(
max_length=10,
default=MailDomainStatusChoices.PENDING,
choices=MailDomainStatusChoices.choices,
)
class Meta:
db_table = "people_mail_domain"
verbose_name = _("Mail domain")
verbose_name_plural = _("Mail domains")
def __str__(self):
return self.name
def save(self, *args, **kwargs):
"""Override save function to compute the slug."""
self.slug = self.get_slug()
return super().save(*args, **kwargs)
def get_slug(self):
"""Compute slug value from name."""
return slugify(self.name)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the domain.
"""
is_owner_or_admin = False
role = None
if user.is_authenticated:
try:
role = self.accesses.filter(user=user).values("role")[0]["role"]
except (MailDomainAccess.DoesNotExist, IndexError):
role = None
is_owner_or_admin = role in [
MailDomainRoleChoices.OWNER,
MailDomainRoleChoices.ADMIN,
]
return {
"get": bool(role),
"patch": is_owner_or_admin,
"put": is_owner_or_admin,
"post": is_owner_or_admin,
"delete": role == MailDomainRoleChoices.OWNER,
"manage_accesses": is_owner_or_admin,
}
class MailDomainAccess(BaseModel):
"""Allow to manage users' accesses to mail domains."""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="mail_domain_accesses",
null=False,
blank=False,
)
domain = models.ForeignKey(
MailDomain,
on_delete=models.CASCADE,
related_name="accesses",
null=False,
blank=False,
)
role = models.CharField(
max_length=20,
choices=MailDomainRoleChoices.choices,
default=MailDomainRoleChoices.VIEWER,
)
class Meta:
db_table = "people_mail_domain_accesses"
verbose_name = _("User/mail domain relation")
verbose_name_plural = _("User/mail domain relations")
unique_together = ("user", "domain")
def __str__(self):
return f"Access of user {self.user} on domain {self.domain}."
class Mailbox(BaseModel):
"""Mailboxes for users from mail domain."""
first_name = models.CharField(max_length=200, blank=False)
last_name = models.CharField(max_length=200, blank=False)
local_part = models.CharField(
_("local_part"),
max_length=150,
null=False,
blank=False,
validators=[validators.RegexValidator(regex="^[a-zA-Z0-9_.+-]+$")],
)
domain = models.ForeignKey(
MailDomain,
on_delete=models.CASCADE,
related_name="mail_domain",
null=False,
blank=False,
)
secondary_email = models.EmailField(
_("secondary email address"), null=False, blank=False
)
class Meta:
db_table = "people_mail_box"
verbose_name = _("Mailbox")
verbose_name_plural = _("Mailboxes")
unique_together = ("local_part", "domain")
def __str__(self):
return f"{self.local_part!s}@{self.domain.name:s}"
def save(self, *args, **kwargs):
self.full_clean()
if self.domain.status != MailDomainStatusChoices.ENABLED:
raise ValidationError("You can create mailbox only for a domain enabled")
super().save(*args, **kwargs)
@@ -0,0 +1,74 @@
"""
Tests for MailDomains API endpoint in People's app mailbox_manager. Focus on "create" 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, models
pytestmark = pytest.mark.django_db
def test_api_mail_domains__create_anonymous():
"""Anonymous users should not be allowed to create mail domains."""
response = APIClient().post(
"/api/v1.0/mail-domains/",
{
"name": "mydomain.com",
},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert not models.MailDomain.objects.exists()
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()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": "existing_domain.com",
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["name"] == ["Mail domain with this name already exists."]
def test_api_mail_domains__create_authenticated():
"""
Authenticated users should be able to create mail domains
and should automatically be added as owner of the newly created domain.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": "mydomain.com",
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
# a new domain pending is created and the authenticated user is the owner
domain = models.MailDomain.objects.get()
assert domain.status == enums.MailDomainStatusChoices.PENDING
assert domain.name == "mydomain.com"
assert domain.accesses.filter(role="owner", user=user).exists()
@@ -0,0 +1,42 @@
"""
Tests for MailDomains API endpoint, in People's mailbox manager app. Focus on "delete" 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 factories, models
pytestmark = pytest.mark.django_db
def test_api_mail_domains__delete_anonymous():
"""Anonymous users should not be allowed to destroy a domain."""
domain = factories.MailDomainFactory()
response = APIClient().delete(
f"/api/v1.0/mail-domains/{domain.slug}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert models.MailDomain.objects.count() == 1
def test_api_mail_domains__delete_authenticated():
"""
Delete a domain is not allowed.
"""
user = core_factories.UserFactory()
domain = factories.MailDomainFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/mail-domains/{domain.slug}/",
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
assert models.MailDomain.objects.count() == 1
@@ -0,0 +1,55 @@
"""
Tests for MailDomains API endpoint in People's mailbox manager app. 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 factories
pytestmark = pytest.mark.django_db
def test_api_mail_domains__list_anonymous():
"""Anonymous users should not be allowed to list mail domains."""
factories.MailDomainFactory.create_batch(3)
response = APIClient().get("/api/v1.0/mail-domains/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_mail_domains__list_authenticated():
"""
Authenticated users should be able to list domains
to which they have access.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
expected_ids = {
str(access.domain.id)
for access in factories.MailDomainAccessFactory.create_batch(5, user=user)
}
factories.MailDomainFactory.create_batch(2) # Other domains
factories.MailDomainAccessFactory.create_batch(2) # Other domains and accesses
response = client.get(
"/api/v1.0/mail-domains/",
)
assert response.status_code == status.HTTP_200_OK
results = response.json()["results"]
assert len(results) == 5
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@@ -0,0 +1,87 @@
"""
Tests for MailDomains API endpoint in People's mailbox manager app. Focus on "retrieve" 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 factories
pytestmark = pytest.mark.django_db
def test_api_mail_domains__retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a domain."""
domain = factories.MailDomainEnabledFactory()
response = APIClient().get(f"/api/v1.0/mail-domains/{domain.slug}/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_domains__retrieve_non_existing():
"""
Authenticated users should have an explicit error when trying to retrive
a domain that doesn't exist.
"""
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.get(
"/api/v1.0/mail-domains/nonexistent.domain/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "Not found."}
def test_api_mail_domains__retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a domain
to which they have access.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
domain = factories.MailDomainEnabledFactory()
response = client.get(
f"/api/v1.0/mail-domains/{domain.slug}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomain matches the given query."}
def test_api_mail_domains__retrieve_authenticated_related():
"""
Authenticated users should be allowed to retrieve a domain
to which they have access.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
domain = factories.MailDomainEnabledFactory()
factories.MailDomainAccessFactory(domain=domain, user=user)
response = client.get(
f"/api/v1.0/mail-domains/{domain.slug}/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"created_at": domain.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
"abilities": domain.get_abilities(user),
}
@@ -0,0 +1,189 @@
"""
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, models
from mailbox_manager.api import serializers
pytestmark = pytest.mark.django_db
def test_api_mailboxes__create_anonymous_forbidden():
"""Anonymous users should not be able to create a new mailbox via the API."""
mail_domain = factories.MailDomainEnabledFactory()
mailbox_values = serializers.MailboxSerializer(
factories.MailboxFactory.build()
).data
response = APIClient().post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert not models.Mailbox.objects.exists()
def test_api_mailboxes__create_authenticated_failure():
"""Authenticated users should not be able to create mailbox
without specific role on mail domain."""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
mailbox_values = serializers.MailboxSerializer(
factories.MailboxFactory.build()
).data
mail_domain = factories.MailDomainEnabledFactory()
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert not models.Mailbox.objects.exists()
def test_api_mailboxes__create_viewer_failure():
"""Users with viewer role should not be able to create mailbox on the mail domain."""
mail_domain = factories.MailDomainEnabledFactory()
access = factories.MailDomainAccessFactory(
role=enums.MailDomainRoleChoices.VIEWER, domain=mail_domain
)
client = APIClient()
client.force_login(access.user)
mailbox_values = serializers.MailboxSerializer(
factories.MailboxFactory.build()
).data
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert not models.Mailbox.objects.exists()
@pytest.mark.parametrize(
"role",
[
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
],
)
def test_api_mailboxes__create_roles_success(role):
"""Users with owner or admin role should be able to create mailbox on the mail domain."""
mail_domain = factories.MailDomainEnabledFactory()
access = factories.MailDomainAccessFactory(role=role, domain=mail_domain)
client = APIClient()
client.force_login(access.user)
mailbox_values = serializers.MailboxSerializer(
factories.MailboxFactory.build()
).data
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
mailbox = models.Mailbox.objects.get()
assert mailbox.local_part == mailbox_values["local_part"]
assert mailbox.secondary_email == mailbox_values["secondary_email"]
assert response.json() == {
"id": str(mailbox.id),
"first_name": str(mailbox.first_name),
"last_name": str(mailbox.last_name),
"local_part": str(mailbox.local_part),
"secondary_email": str(mailbox.secondary_email),
}
@pytest.mark.parametrize(
"role",
[
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
],
)
def test_api_mailboxes__create_with_accent_success(role):
"""Users with proper abilities should be able to create mailbox on the mail domain with a
first_name accentuated."""
mail_domain = factories.MailDomainEnabledFactory()
access = factories.MailDomainAccessFactory(role=role, domain=mail_domain)
client = APIClient()
client.force_login(access.user)
mailbox_values = serializers.MailboxSerializer(
factories.MailboxFactory.build(first_name="Aimé")
).data
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
mailbox = models.Mailbox.objects.get()
assert mailbox.local_part == mailbox_values["local_part"]
assert mailbox.secondary_email == mailbox_values["secondary_email"]
assert response.json() == {
"id": str(mailbox.id),
"first_name": str(mailbox.first_name),
"last_name": str(mailbox.last_name),
"local_part": str(mailbox.local_part),
"secondary_email": str(mailbox.secondary_email),
}
def test_api_mailboxes__create_administrator_missing_fields():
"""
Administrator users should not be able to create mailboxes
without local part or secondary mail.
"""
mail_domain = factories.MailDomainEnabledFactory()
access = factories.MailDomainAccessFactory(
role=enums.MailDomainRoleChoices.ADMIN, domain=mail_domain
)
client = APIClient()
client.force_login(access.user)
mailbox_values = serializers.MailboxSerializer(
factories.MailboxFactory.build()
).data
del mailbox_values["local_part"]
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not models.Mailbox.objects.exists()
assert response.json() == {"local_part": ["This field is required."]}
mailbox_values = serializers.MailboxSerializer(
factories.MailboxFactory.build()
).data
del mailbox_values["secondary_email"]
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not models.Mailbox.objects.exists()
assert response.json() == {"secondary_email": ["This field is required."]}
@@ -0,0 +1,80 @@
"""
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__list_anonymous():
"""Anonymous users should not be allowed to list mailboxes."""
mail_domain = factories.MailDomainEnabledFactory()
factories.MailboxFactory.create_batch(2, domain=mail_domain)
response = APIClient().get(f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_mailboxes__list_authenticated():
"""Authenticated users should not be able to list mailboxes"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
mail_domain = factories.MailDomainEnabledFactory()
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."
}
@pytest.mark.parametrize(
"role",
[
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
],
)
def test_api_mailboxes__list_roles(role):
"""Owner, admin and viewer users should be able to list mailboxes"""
mail_domain = factories.MailDomainEnabledFactory()
mailbox1 = factories.MailboxFactory(domain=mail_domain)
mailbox2 = factories.MailboxFactory(domain=mail_domain)
access = factories.MailDomainAccessFactory(role=role, domain=mail_domain)
client = APIClient()
client.force_login(access.user)
response = client.get(f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/")
assert response.status_code == status.HTTP_200_OK
assert response.json()["results"] == [
{
"id": str(mailbox2.id),
"first_name": str(mailbox2.first_name),
"last_name": str(mailbox2.last_name),
"local_part": str(mailbox2.local_part),
"secondary_email": str(mailbox2.secondary_email),
},
{
"id": str(mailbox1.id),
"first_name": str(mailbox1.first_name),
"last_name": str(mailbox1.last_name),
"local_part": str(mailbox1.local_part),
"secondary_email": str(mailbox1.secondary_email),
},
]
@@ -0,0 +1,128 @@
"""
Unit tests for the mailbox model
"""
from django.core.exceptions import ValidationError
import pytest
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
# LOCAL PART FIELD
def test_models_mailboxes__local_part_cannot_be_empty():
"""The "local_part" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank"):
factories.MailboxFactory(local_part="")
def test_models_mailboxes__local_part_cannot_be_null():
"""The "local_part" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null"):
factories.MailboxFactory(local_part=None)
def test_models_mailboxes__local_part_matches_expected_format():
"""
The local part should contain alpha-numeric caracters
and a limited set of special caracters ("+", "-", ".", "_").
"""
factories.MailboxFactory(local_part="Marie-Jose.Perec+JO_2024")
with pytest.raises(ValidationError, match="Enter a valid value"):
factories.MailboxFactory(local_part="mariejo@unnecessarydomain.com")
with pytest.raises(ValidationError, match="Enter a valid value"):
factories.MailboxFactory(local_part="!")
def test_models_mailboxes__local_part_unique_per_domain():
"""Local parts should be unique per domain."""
existing_mailbox = factories.MailboxFactory()
# same local part on another domain should not be a problem
factories.MailboxFactory(local_part=existing_mailbox.local_part)
# same local part on the same domain should not be possible
with pytest.raises(
ValidationError, match="Mailbox with this Local_part and Domain already exists."
):
factories.MailboxFactory(
local_part=existing_mailbox.local_part, domain=existing_mailbox.domain
)
# DOMAIN FIELD
def test_models_mailboxes__domain_must_be_a_maildomain_instance():
"""The "domain" field should be an instance of MailDomain."""
expected_error = '"Mailbox.domain" must be a "MailDomain" instance.'
with pytest.raises(ValueError, match=expected_error):
factories.MailboxFactory(domain="")
with pytest.raises(ValueError, match=expected_error):
factories.MailboxFactory(domain="domain-as-string.com")
def test_models_mailboxes__domain_cannot_be_null():
"""The "domain" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null"):
factories.MailboxFactory(domain=None)
# SECONDARY_EMAIL FIELD
def test_models_mailboxes__secondary_email_cannot_be_empty():
"""The "secondary_email" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank"):
factories.MailboxFactory(secondary_email="")
def test_models_mailboxes__secondary_email_cannot_be_null():
"""The "secondary_email" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null"):
factories.MailboxFactory(secondary_email=None)
def test_models_mailboxes__cannot_be_created_for_disabled_maildomain():
"""Mailbox creation is allowed only for a domain enabled.
A disabled status for the mail domain raises an error."""
with pytest.raises(
ValidationError, match="You can create mailbox only for a domain enabled"
):
factories.MailboxFactory(
domain=factories.MailDomainFactory(
status=enums.MailDomainStatusChoices.DISABLED
)
)
def test_models_mailboxes__cannot_be_created_for_failed_maildomain():
"""Mailbox creation is allowed only for a domain enabled.
A failed status for the mail domain raises an error."""
with pytest.raises(
ValidationError, match="You can create mailbox only for a domain enabled"
):
factories.MailboxFactory(
domain=factories.MailDomainFactory(
status=enums.MailDomainStatusChoices.FAILED
)
)
def test_models_mailboxes__cannot_be_created_for_pending_maildomain():
"""Mailbox creation is allowed only for a domain enabled.
A pending status for the mail domain raises an error."""
with pytest.raises(
ValidationError, match="You can create mailbox only for a domain enabled"
):
# MailDomainFactory initializes a mail domain with default values,
# so mail domain status is pending!
factories.MailboxFactory(domain=factories.MailDomainFactory())
@@ -0,0 +1,110 @@
"""
Unit tests for the MailDomain model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
from django.utils.text import slugify
import pytest
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
# NAME FIELD
def test_models_mail_domain__domain_name_should_not_be_empty():
"""The domain name field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank"):
factories.MailDomainFactory(name="")
def test_models_mail_domain__domain_name_should_not_be_null():
"""The domain name field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null."):
factories.MailDomainFactory(name=None)
def test_models_mail_domain__slug_inferred_from_name():
"""Passed slug is ignored. Slug is automatically generated from name."""
name = "N3w_D0main-Name$.com"
domain = factories.MailDomainFactory(name=name, slug="something else")
assert domain.slug == slugify(name)
# get_abilities
def test_models_maildomains_get_abilities_anonymous():
"""Check abilities returned for an anonymous user."""
maildomain = factories.MailDomainFactory()
abilities = maildomain.get_abilities(AnonymousUser())
assert abilities == {
"delete": False,
"get": False,
"patch": False,
"put": False,
"post": False,
"manage_accesses": False,
}
def test_models_maildomains_get_abilities_authenticated():
"""Check abilities returned for an authenticated user."""
maildomain = factories.MailDomainFactory()
abilities = maildomain.get_abilities(core_factories.UserFactory())
assert abilities == {
"delete": False,
"get": False,
"patch": False,
"put": False,
"post": False,
"manage_accesses": False,
}
def test_models_maildomains_get_abilities_owner():
"""Check abilities returned for the owner of a maildomain."""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
abilities = access.domain.get_abilities(access.user)
assert abilities == {
"delete": True,
"get": True,
"patch": True,
"put": True,
"post": True,
"manage_accesses": True,
}
def test_models_maildomains_get_abilities_administrator():
"""Check abilities returned for the administrator of a maildomain."""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.ADMIN)
abilities = access.domain.get_abilities(access.user)
assert abilities == {
"delete": False,
"get": True,
"patch": True,
"put": True,
"post": True,
"manage_accesses": True,
}
def test_models_maildomains_get_abilities_viewer():
"""Check abilities returned for the member of a mail domain. It's a viewer role."""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.VIEWER)
abilities = access.domain.get_abilities(access.user)
assert abilities == {
"delete": False,
"get": True,
"patch": False,
"put": False,
"post": False,
"manage_accesses": False,
}
@@ -0,0 +1,60 @@
"""
Unit tests for the MailDomainAccess model
"""
from django.core.exceptions import ValidationError
import pytest
from mailbox_manager import factories
pytestmark = pytest.mark.django_db
# USER FIELD
def test_models_maildomainaccess__user_be_a_user_instance():
"""The "user" field should be a user instance."""
expected_error = '"MailDomainAccess.user" must be a "User" instance.'
with pytest.raises(ValueError, match=expected_error):
factories.MailDomainAccessFactory(user="")
def test_models_maildomainaccess__user_should_not_be_null():
"""The user field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null."):
factories.MailDomainAccessFactory(user=None)
# DOMAIN FIELD
def test_models_maildomainaccesses__domain_must_be_a_maildomain_instance():
"""The "domain" field should be an instance of MailDomain."""
expected_error = '"MailDomainAccess.domain" must be a "MailDomain" instance.'
with pytest.raises(ValueError, match=expected_error):
factories.MailDomainAccessFactory(domain="")
with pytest.raises(ValueError, match=expected_error):
factories.MailDomainAccessFactory(domain="domain-as-string.com")
def test_models_maildomainaccesses__domain_cannot_be_null():
"""The "domain" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null"):
factories.MailDomainAccessFactory(domain=None)
# ROLE FIELD
def test_models_maildomainaccesses__role_cannot_be_empty():
"""The "role" field cannot be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank"):
factories.MailDomainAccessFactory(role="")
def test_models_maildomainaccesses__role_cannot_be_null():
"""The "role" field cannot be null."""
with pytest.raises(ValidationError, match="This field cannot be null"):
factories.MailDomainAccessFactory(role=None)
+41
View File
@@ -0,0 +1,41 @@
"""API URL Configuration"""
from django.urls import include, path, re_path
from rest_framework.routers import DefaultRouter
from mailbox_manager.api import viewsets
maildomain_router = DefaultRouter()
maildomain_router.register(
"mail-domains", viewsets.MailDomainViewSet, basename="mail-domains"
)
# - Routes nested under a mail domain
maildomain_related_router = DefaultRouter()
maildomain_related_router.register(
"accesses",
viewsets.MailDomainAccessViewSet,
basename="accesses",
)
maildomain_related_router.register(
"mailboxes",
viewsets.MailBoxViewSet,
basename="mailboxes",
)
urlpatterns = [
path(
"",
include(
[
*maildomain_router.urls,
re_path(
r"^mail-domains/(?P<domain_slug>[\w-]+)/",
include(maildomain_related_router.urls),
),
]
),
),
]
+4 -2
View File
@@ -3,10 +3,10 @@
from django.conf import settings
from django.urls import include, path, re_path
from mozilla_django_oidc.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.authentication.urls import urlpatterns as oidc_urls
# - Main endpoints
router = DefaultRouter()
@@ -28,6 +28,7 @@ team_related_router.register(
basename="invitations",
)
urlpatterns = [
path(
f"api/{settings.API_VERSION}/",
@@ -41,5 +42,6 @@ urlpatterns = [
),
]
),
)
),
path(f"api/{settings.API_VERSION}/", include("mailbox_manager.urls")),
]
+13 -3
View File
@@ -182,7 +182,7 @@ class Base(Configuration):
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"core.authentication.OIDCAuthenticationBackend",
"core.authentication.backends.OIDCAuthenticationBackend",
]
# Django's applications from the highest priority to the lowest
@@ -190,6 +190,7 @@ class Base(Configuration):
# People
"core",
"demo",
"mailbox_manager",
"drf_spectacular",
# Third party apps
"corsheaders",
@@ -268,7 +269,7 @@ class Base(Configuration):
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("do-not-reply@desk.beta.numerique.gouv.fr")
EMAIL_FROM = values.Value("from@example.com")
AUTH_USER_MODEL = "core.User"
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
@@ -323,6 +324,9 @@ class Base(Configuration):
OIDC_OP_USER_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
)
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
)
OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue(
{}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None
)
@@ -347,6 +351,12 @@ class Base(Configuration):
OIDC_REDIRECT_ALLOWED_HOSTS = values.ListValue(
default=[], environ_name="OIDC_REDIRECT_ALLOWED_HOSTS", environ_prefix=None
)
OIDC_STORE_ID_TOKEN = values.BooleanValue(
default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None
)
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
USER_OIDC_FIELDS_TO_NAME = values.ListValue(
default=["first_name", "last_name"],
@@ -515,7 +525,7 @@ class Production(Base):
# - Your proxy sets the X-Forwarded-Proto header and sends it to Django
#
# In other cases, you should comment the following line to avoid security issues.
# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True
+3 -3
View File
@@ -33,7 +33,7 @@ if settings.DEBUG:
if settings.USE_SWAGGER or settings.DEBUG:
urlpatterns += [
path(
f"{API_VERSION}/swagger.json",
f"api/{API_VERSION}/swagger.json",
SpectacularJSONAPIView.as_view(
api_version=API_VERSION,
urlconf="people.api_urls",
@@ -41,12 +41,12 @@ if settings.USE_SWAGGER or settings.DEBUG:
name="client-api-schema",
),
path(
f"{API_VERSION}/swagger/",
f"api/{API_VERSION}/swagger/",
SpectacularSwaggerView.as_view(url_name="client-api-schema"),
name="swagger-ui-schema",
),
re_path(
f"{API_VERSION}/redoc/",
f"api/{API_VERSION}/redoc/",
SpectacularRedocView.as_view(url_name="client-api-schema"),
name="redoc-schema",
),
+45 -26
View File
@@ -25,32 +25,32 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.34.75",
"boto3==1.34.153",
"Brotli==1.1.0",
"celery[redis]==5.3.6",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.3.1",
"django-cors-headers==4.4.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.0.3",
"redis==5.0.8",
"django-redis==5.4.0",
"django-storages==1.14.2",
"django-storages==1.14.4",
"django-timezone-field>=5.1",
"django==5.0.3",
"djangorestframework==3.15.1",
"django==5.0.8",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.3.0",
"easy_thumbnails==2.8.5",
"dockerflow==2024.4.2",
"easy_thumbnails==2.9",
"factory_boy==3.3.0",
"gunicorn==21.2.0",
"jsonschema==4.21.1",
"gunicorn==22.0.0",
"jsonschema==4.23.0",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.1.18",
"PyJWT==2.8.0",
"requests==2.31.0",
"sentry-sdk==1.44.0",
"psycopg[binary]==3.2.1",
"PyJWT==2.9.0",
"requests==2.32.3",
"sentry-sdk==2.12.0",
"url-normalize==1.4.3",
"whitenoise==6.6.0",
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
]
@@ -63,21 +63,21 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.4.1",
"drf-spectacular-sidecar==2024.7.1",
"ipdb==0.13.13",
"ipython==8.23.0",
"pyfakefs==5.3.5",
"ipython==8.26.0",
"pyfakefs==5.6.0",
"pylint-django==2.5.5",
"pylint==3.1.0",
"pylint==3.2.6",
"pytest-cov==5.0.0",
"pytest-django==4.8.0",
"pytest==8.1.1",
"pytest==8.3.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.5.0",
"responses==0.25.0",
"ruff==0.3.5",
"types-requests==2.31.0.20240402",
"freezegun==1.4.0",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.5.6",
"types-requests==2.32.0.20240712",
"freezegun==1.5.1",
]
[tool.setuptools]
@@ -137,3 +137,22 @@ python_files = [
"test_*.py",
"tests.py",
]
[tool.coverage.run]
branch = true
omit = [
"*/admin.py",
"*/migrations/*",
"*/tests/*",
"*/urls.py",
"manage.py",
"celery_app.py",
"wsgi.py",
]
[tool.coverage.report]
show_missing = true
skip_empty = true
[tool.coverage.json]
pretty_print = true
-7
View File
@@ -1,7 +0,0 @@
#!/usr/bin/env python
"""Setup file for the people module. All configuration stands in the setup.cfg file."""
# coding: utf-8
from setuptools import setup
setup()
+1 -1
View File
@@ -1 +1 @@
NEXT_PUBLIC_API_URL=http://localhost:8071/api/v1.0/
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
-1
View File
@@ -1 +0,0 @@
NEXT_PUBLIC_API_URL=https://desk-staging.beta.numerique.gouv.fr/api/v1.0/
+1 -1
View File
@@ -1 +1 @@
NEXT_PUBLIC_API_URL=/api/
NEXT_PUBLIC_API_ORIGIN=http://test.jest
-1
View File
@@ -33,4 +33,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+1
View File
@@ -0,0 +1 @@
next-env.d.ts

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