Compare commits

...

59 Commits

Author SHA1 Message Date
Anthony LC 2ac11f27fb 🚨(app-desk) add duration on TextErrors component
Add a duration on the TextErrors component.
I will display the error message the time specified
in the `duration` prop.
By default the errors will be displayed 6 seconds.
2024-03-06 11:03:49 +01:00
Anthony LC d983397a34 💫(app-desk) add effect prop on Box component
Add the effect prop on Box component.
2 effects are available: 'show' and 'hide'.
2024-03-06 10:56:54 +01:00
Anthony LC b010a7b5a7 🤡(app-desk) remove mock endpoint teams accesses
The endpoint teams/accesses is ready.
We remove the mock and the related libraries
and use the real endpoint.
2024-03-04 17:52:52 +01:00
Anthony LC e16f51ca20 (app-desk) integrate member list design
Integrate the member list design in the team page
based on the mockup.
2024-03-04 15:49:50 +01:00
Anthony LC 9d30bc88f1 🤡(app-desk) mock endpoint teams/:teamId/accesses/
We intercept the request to the endpoint teams/:teamId/accesses/
and return a json with dummy accesses of the team.
2024-03-04 15:49:50 +01:00
Anthony LC 1da978e121 (app-desk) add useTeamAccesses react-query hook
Add the hook useTeamAccesses, it queries the accesses
if a team. It is paginated.
2024-03-04 15:49:50 +01:00
Anthony LC 3bf8965209 🚚(app-desk) rename and group team Panel
- add folder Panel
- rename PanelTeams to TeamList
- rename PanelTeam to TeamItem
2024-03-04 15:49:50 +01:00
renovate[bot] 5b9d2cccc5 ⬆️(dependencies) update js dependencies 2024-03-04 15:16:15 +01:00
Marie PUPO JEAMMET 81243cfc9a (api) return user id, name and email on /team/<id>/accesses/
Add serializers to return basic user info when listing /team/<id>/accesses/
endpoint. This will allow front-end to retrieve members info without having
to query API for each user.id.
2024-03-03 23:00:05 +01:00
Marie PUPO JEAMMET 70b1b996df 🏗️(tests) separate team accesses tests by action
Small commit to separate team accesses tests into diferent files.
2024-03-03 23:00:05 +01:00
renovate[bot] 29d274ab7c ⬆️(dependencies) update python dependencies 2024-02-28 14:21:49 +01:00
Anthony LC f17771fc9b (app-desk) fix error warning jest test logout
We had a error warning in the jest test logout with fetchApi,
window.location.replace had to be mocked to avoid the error.
2024-02-26 16:31:02 +01:00
Anthony LC 65e78cde68 ⬇️(app-desk) downgrade @openfun/cunningham-react
Downgrade @openfun/cunningham-react to 2.4.0, because of a
compatibility problem with Jest.

We add this package with this version to the ignore list
in renovate.json, when we will have a new compatible version, we will
remove it from the ignore list.
2024-02-26 16:31:02 +01:00
Anthony LC 33288ab225 ⬇️(app-desk) downgrade @types/react-dom
Downgrade @types/react-dom to 18.2.18.
The lastest version seems to have lot of compatibility
issues with other packages:
- @openfun/cunningham-react
- @tanstack/react-query-devtools
- next

We add this package with this version to the ignore list
in renovate.json, when we will have a new compatible version, we will
remove it from the ignore list.
2024-02-26 16:31:02 +01:00
renovate[bot] a3c0069697 ⬆️(dependencies) update js dependencies 2024-02-26 16:31:02 +01:00
Anthony LC b307b373bb (app-desk) add luxon to display date
Add luxon to display date in the team description.
The date are internationalized and formatted as the
mockup requested.
2024-02-25 20:48:51 +01:00
Anthony LC f21740e5e5 👔(backend) add read fields to teams api
Some fields are missing for the frontend.
Add read fields to teams api:
- created_at
- updated_at
2024-02-25 20:48:51 +01:00
Anthony LC 035a7a1fcc 🏷️(app-desk) rename type TeamResponse to Team
Rename type TeamResponse to Team, the components
using this type don't need to know that the data
is coming from the API.
2024-02-25 20:48:51 +01:00
Anthony LC 3f7e5c88bc (app-desk) change backend settings for e2e tests
When we run e2e tests with the CI, we are doing lot of
calls to the backend in a short amount of time. This can
lead to a rate limit particulary on the "user/me" endpoint.
To avoid this, we will use different backend settings
for the e2e tests.
2024-02-25 20:31:27 +01:00
Anthony LC 51064ec236 🥅(app-desk) better error management
We don't know how the error body returned by the
api will be, so we handle it in a more generic way.
2024-02-25 20:31:27 +01:00
Anthony LC 195e738c3c 🚸(app-desk) add 404 page
- Add a 404 page.
- Redirect to 404 page when a team is not found.
2024-02-25 20:31:27 +01:00
Samuel Paccoud - DINUM 54497c1261 🔒️(settings) remove default value for setting OIDC_RP_CLIENT_SECRET
Secret settings should not contain any default value as we risk shipping
them to production. The default value can be set via an environment variable
in the `env.d/development/common` file: OIDC_RP_CLIENT_SECRET
2024-02-23 17:15:46 +01:00
Anthony LC 8d7c545d1a 🗃️(backend) add name field to identity
We need a name for the user when we display the members in the
frontend. This commit adds the name column to the identity model.
We sync the Keycloak user with the identity model when the user
logs in to fill and udpate the name automatically.
2024-02-23 17:15:46 +01:00
Anthony LC 8cbfb38cc4 🚚(app-desk) alias home with teams url path
In order the keep the url path consistent and correctly
structured, the homepage is aliased with the teams page.
2024-02-22 16:20:39 +01:00
Anthony LC 4bd8095975 🐛(i18n) dot in key was not added
The parser was not adding the dot in the key to the
json file sent to crowdin. Some translations were
not being translated correctly.
2024-02-22 14:28:04 +01:00
Anthony LC fc8dc24ba2 (app-desk) add team info component
Add the team info component to the team page.
This component shows some informations about the team:
  - name
  - amount of members
  - date created
  - date updated
2024-02-22 14:28:04 +01:00
Anthony LC 95219a33b3 💄(app-desk) change the text color
The text color from the mockup is not blue but
a dark grey. This commit changes the base color of
the Text component to match the mockup.
2024-02-22 14:28:04 +01:00
Lebaud Antoine 26fbe9fbe7 ✏️(project) fix minor typos
Found typos and fixed them.
2024-02-22 11:59:36 +01:00
Lebaud Antoine 4cacfd3a45 ♻️(frontend) switch to Authorization Code flow
Instead of interacting with Keycloak, the frontend navigate to the
/authenticate endpoint, which starts the Authorization code flow.

When the flow is done, the backend redirect back to the SPA,
passing a session cookie and a csrf cookie.

Done:
- Query GET user/me to determine if user is authenticated yet
- Remove Keycloak js dependency, as all the OIDC logic is handled by the backend
- Store user's data instead of the JWT token
2024-02-22 11:59:36 +01:00
Lebaud Antoine 38c4d33791 (backend) support Authorization code flow
Integrate 'mozilla-django-oidc' dependency, to support
Authorization Code flow, which is required by Agent Connect.

Thus, we provide a secure back channel OIDC flow, and return
to the client only a session cookie.

Done:
- Replace JWT authentication by Session based authentication in DRF
- Update Django settings to make OIDC configurations easily editable
- Add 'mozilla-django-oidc' routes to our router
- Implement a custom Django Authentication class to adapt
'mozilla-django-oidc' to our needs

'mozilla-django-oidc' routes added are:
- /authenticate
- /callback (the redirect_uri called back by the Idp)
- /logout
2024-02-22 11:59:36 +01:00
Lebaud Antoine ec28c28d47 (backend) drop JWT authentication in API tests
Force login to bypass authorization checks when necessary.

Note: Generating a session cookie through OIDC flow
is not supported while testing our API.
2024-02-22 11:59:36 +01:00
Lebaud Antoine 927d0e5a22 🔧(project) proxy Keycloak with nginx
Backend and Frontend send requests to Keycloak through Nginx.

Thus, all requests from frontend and backend shared a same host
when received by Keycloak.

Otherwise, the flow is initiated from http://localhost:8080. When the Backend
calls token endpoint from Keycloak container at http://keycloak:8080,
the JWT token issuer and sender are mismatching.
2024-02-22 11:59:36 +01:00
Lebaud Antoine 699854e76b 🔧(project) configure standard OIDC flow in Keycloak
Enforce Authorization Code flow, and disable Implicit flow.

Done:
- Rename client people-front to people
- Add a client secret shared with the backend
- Add allowed redirect uris
- Disable implicit flow and enable Authorization Code flow without PCKE
- Sign userinfo endpoint to return application/jwt content
2024-02-22 11:59:36 +01:00
Marie PUPO JEAMMET 63e059a4e6 🔥(backend) remove users systematic return of profile_contact
Custom UserManaged returned profile_contact field when returning users.
While this may be useful later, we'd currently rather have it return users.
2024-02-21 17:49:19 +01:00
Anthony LC 5113eb013b 💄(app-desk) highlight team selected
Highlight the selected team in the team list.
2024-02-20 16:25:31 +01:00
Anthony LC 45d05873e2 🔥(app-desk) remove FAQ part in header
The FAQ part in the header is not displayed in
the mockup anymore, we remove it.
2024-02-20 16:25:31 +01:00
Anthony LC 1f3ab759d7 ⬇️(app-desk) downgrade @types/react-dom
Downgrade @types/react-dom to 18.2.18.
The lastest version seems to have lot of compatibility
issues with other packages:
- @openfun/cunningham-react
- @tanstack/react-query-devtools
- next
2024-02-19 16:58:23 +01:00
renovate[bot] 8b5f5bf092 ⬆️(dependencies) update js dependencies 2024-02-19 16:58:23 +01:00
Anthony LC cd2efbe40d ️(frontend) add stale cache
We add a stale cache to reduce the number of requests to
the server.
This will help to improve the performance of the application.
2024-02-19 15:22:09 +01:00
Anthony LC 8b0c20dbdc (app-desk) add team page
- add the team page, you can access to
the team page with the id of the team.
- Add link to the panel team to access to the
team page.
2024-02-19 15:22:09 +01:00
Anthony LC d0562029e8 (app-desk) integrate team endpoint
Integrate team endpoint with react-query.
It will be used to get the team on the team page.
2024-02-19 15:22:09 +01:00
Anthony LC 77efb1a89c 💄(app-desk) do not count the owner in item list icon
In every team, the owner is always included in it,
so we shouldn't count the owner when we display the icon
to say if a team has members.
2024-02-19 15:04:53 +01:00
Anthony LC 4566e132e1 💄(app-desk) integrate the create team design
- Integrate the create team design based from the
mockup
- Manage the different states of the create team
2024-02-19 15:04:53 +01:00
Anthony LC f818715a45 🚚(app-desk) change next router to pages
2 routers exists in Next.js, "app" router and "page" router.
The "app" router has a bug introduced in Next.js 13.4.14, which is
not fixed yet. For the moment we cannot use dynamic routes with
"app" router with an SPA. As advised by the Next.js team, we
migrated to the "pages" router.
2024-02-19 15:04:53 +01:00
renovate[bot] 7d90092020 ⬆️(dependencies) update python dependencies 2024-02-19 10:08:28 +01:00
Lebaud Antoine 469903c9eb ✏️(project) fix minor typos
Found typos, fixed them.
2024-02-16 16:03:52 +01:00
Lebaud Antoine 1f1253ab21 🔧(backend) activate container liveness probes
Enabled Dockerflow Django app by activating liveness probes. The previously
unavailable routes such as `__heartbeat__` and `__lbheartbeat__` are now
accessible. New endpoints include:
* GET /__version__
* GET /__heartbeat__
* GET /__lbheartbeat__
2024-02-16 15:16:30 +01:00
Lebaud Antoine 6620932371 🐛(project) run production image locally with docker-compose
The local deployment of the Production image through docker-compose was
failing due to issues in the Django configurations, influenced by Joanie.

The bug stemmed from a dependency on a development-specific package
(drf-spectacular-sidecar) while attempting to run the application in
production mode.

Changes Made:
- Introduced new Django settings for local demo environments.
- Uncommented the nginx configuration to address the production image
  deployment issues.
2024-02-16 15:16:30 +01:00
Marie PUPO JEAMMET 8e537d962c 🗃️(database) create invitation model
Create invitation model, factory and related tests to prepare back-end
for invitation endpoints. We chose to use a separate dedicated model
for separation of concerns, see
https://github.com/numerique-gouv/people/issues/25
2024-02-15 19:42:40 +01:00
Lebaud Antoine 6080af961a 🩹(backend) fix Django Admin UserAdmin page
*Broken Identity string representation
Resolving a format error in the Identity string representation caused by
potential None values in the email field. This issue was discovered when
attempting to access the User details page in the Django Admin

*Broken User creation form
The replacement of the User's username with an email led to errors in
the UserAdmin class. The base class used the 'username' field in the
'add_fieldsets' attribute. This problem was discovered while attempting to
create a new user in the Django Admin.
2024-02-15 15:54:07 +01:00
Anthony LC aba376702f 🐛(app-desk) fix circular dependency problem
Some circular dependency problems started to appear with Jest.
This commit fixes the problem by removing the feature index
file and moving the exports to the respective feature.
2024-02-15 09:56:07 +01:00
Anthony LC 1e38174c1b ️(e2e) add workers to playwright with CI
We have added workers to playwright to run tests in parallel,
this will help us to run tests faster.
The tests run on a commun database, so to keep the tests
stable between browsers, we created 3 different
users to run the tests, it will avoid to have commun data
stepping on each other.
2024-02-15 09:56:07 +01:00
Anthony LC 0ab9f16cb3 🌐(app-desk) improve message missing translations
- Improve the message when a translation is missing in the app,
now it will show the key of the missing translation.
- It also show the translation that are in crowdin but not in the
app.

- Add missing translations
2024-02-15 09:56:07 +01:00
Anthony LC c71463a385 🚚(app-desk) create route teams/create
- Create the routes teams/create.
- Add link to the buttons routing to this page
- Adapt design
2024-02-15 09:56:07 +01:00
Anthony LC 47ffa60a94 ️(app-desk) add infinite scrool to teams list
If we have a big list of teams, we need to add infinite
scroll to avoid loading all the teams at once.
2024-02-15 09:56:07 +01:00
Anthony LC fafffd2391 (app-desk) add interaction to sort button
In the team panel, we have the possibility to sort the teams.
This commit adds the interaction to the button.
2024-02-15 09:56:07 +01:00
Anthony LC 36e2dc2378 ♻️(backend) api teams list ordering
Give the possibility to order the teams list by
creation date.
By default the list is ordered by
creation date descending.
2024-02-15 09:56:07 +01:00
Anthony LC 4f0465fd32 (app-desk) integrate teams panel design
- Integrate teams panel design based from the mockup.
- List teams from the API.
2024-02-15 09:56:07 +01:00
Anthony LC 148ea81aa9 🎨(app-desk) box default direction column
Change the default direction of the Box component to column
to have a behavior closer to a normal div.
2024-02-15 09:56:07 +01:00
150 changed files with 6301 additions and 3268 deletions
+3 -2
View File
@@ -115,6 +115,7 @@ jobs:
- name: Set services env variables
run: |
make create-env-files
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Build and Start Docker Servers
env:
@@ -124,7 +125,7 @@ jobs:
docker-compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
make run
- name: Apply DRH migrations
- name: Apply DRF migrations
run: |
make migrate
@@ -216,7 +217,7 @@ jobs:
DJANGO_CONFIGURATION: Test
DJANGO_SETTINGS_MODULE: people.settings
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
DJANGO_JWT_PRIVATE_SIGNING_KEY: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
DB_HOST: localhost
DB_NAME: people
DB_USER: dinum
+1 -1
View File
@@ -2,7 +2,7 @@
People is an application to handle users and teams.
This project is as of yet **not ready for production**. Expect breaking changes.
As of today, this project is **not yet ready for production**. Expect breaking changes.
People is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/).
+10 -2
View File
@@ -66,7 +66,7 @@ services:
user: ${DOCKER_USER:-1000}
image: people:production
environment:
- DJANGO_CONFIGURATION=ContinuousIntegration
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
@@ -81,7 +81,7 @@ services:
image: people:production
command: ["celery", "-A", "people.celery_app", "worker", "-l", "INFO"]
environment:
- DJANGO_CONFIGURATION=ContinuousIntegration
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
@@ -92,6 +92,7 @@ services:
image: nginx:1.25
ports:
- "8082:8082"
- "8083:8083"
- "8088:8088"
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
@@ -99,6 +100,7 @@ services:
- ./data/media:/data/media:ro
depends_on:
- app
- keycloak
dockerize:
image: jwilder/dockerize
@@ -152,6 +154,11 @@ services:
- start-dev
- --features=preview
- --import-realm
- --proxy=edge
- --hostname-url=http://localhost:8083
- --hostname-admin-url=http://localhost:8083/
- --hostname-strict=false
- --hostname-strict-https=false
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
@@ -161,6 +168,7 @@ services:
KC_DB_PASSWORD: pass
KC_DB_USERNAME: people
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true'
ports:
- "8080:8080"
depends_on:
+72 -18
View File
@@ -46,6 +46,9 @@
"users": [
{
"username": "people",
"email": "people@people.world",
"firstName": "John",
"lastName": "Doe",
"enabled": true,
"credentials": [
{
@@ -56,12 +59,43 @@
"realmRoles": ["user"]
},
{
"username": "user-e2e",
"username": "user-e2e-chromium",
"email": "user@chromium.e2e",
"firstName": "E2E",
"lastName": "Chromium",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e"
"value": "password-e2e-chromium"
}
],
"realmRoles": ["user"]
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.e2e",
"firstName": "E2E",
"lastName": "Webkit",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e-webkit"
}
],
"realmRoles": ["user"]
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.e2e",
"firstName": "E2E",
"lastName": "Firefox",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e-firefox"
}
],
"realmRoles": ["user"]
@@ -313,7 +347,6 @@
],
"security-admin-console": [],
"admin-cli": [],
"people-front": [],
"account-console": [],
"broker": [
{
@@ -326,6 +359,7 @@
"attributes": {}
}
],
"people": [],
"account": [
{
"id": "63b1a4e1-a594-4571-99c3-7c5c3efd61ce",
@@ -580,7 +614,9 @@
"publicClient": true,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@@ -618,7 +654,9 @@
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@@ -638,7 +676,7 @@
},
{
"id": "869481d0-5774-4e64-bc30-fedc7c58958f",
"clientId": "people-front",
"clientId": "people",
"name": "",
"description": "",
"rootUrl": "",
@@ -648,9 +686,10 @@
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
"redirectUris": [
"",
"http://localhost:8070/*",
"http://localhost:8071/*",
"http://localhost:3200/*",
"http://localhost:8088/*",
"http://localhost:3000/*"
@@ -666,18 +705,29 @@
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"publicClient": true,
"publicClient": false,
"frontchannelLogout": true,
"protocol": "openid-connect",
"attributes": {
"access.token.lifespan": "-1",
"client.secret.creation.time": "1707820779",
"user.info.response.signature.alg": "RS256",
"post.logout.redirect.uris": "http://localhost:8070/*##http://localhost:3200/*##http://localhost:3000/*",
"oauth2.device.authorization.grant.enabled": "false",
"use.jwks.url": "false",
"backchannel.logout.revoke.offline.tokens": "false",
"use.refresh.tokens": "true",
"tls-client-certificate-bound-access-tokens": "false",
"oidc.ciba.grant.enabled": "false",
"backchannel.logout.session.required": "true",
"post.logout.redirect.uris": "http://localhost:8070/*##http://localhost:3200/*##http://localhost:3000/*",
"client_credentials.use_refresh_token": "false",
"acr.loa.map": "{}",
"require.pushed.authorization.requests": "false",
"display.on.consent.screen": "false",
"oauth2.device.authorization.grant.enabled": "false",
"backchannel.logout.revoke.offline.tokens": "false"
"client.session.idle.timeout": "-1",
"token.response.type.bearer.lower-case": "false"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
@@ -716,7 +766,9 @@
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@@ -887,7 +939,8 @@
"consentRequired": false,
"config": {
"id.token.claim": "true",
"access.token.claim": "true"
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
@@ -1057,7 +1110,7 @@
},
{
"id": "79712bcf-b7f7-4ca3-b97c-418f48fded9b",
"name": "given name",
"name": "first name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
@@ -1066,7 +1119,7 @@
"user.attribute": "firstName",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "given_name",
"claim.name": "first_name",
"jsonType.label": "String"
}
},
@@ -1087,7 +1140,7 @@
},
{
"id": "7f741e96-41fe-4021-bbfd-506e7eb94e69",
"name": "family name",
"name": "last name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
@@ -1096,7 +1149,7 @@
"user.attribute": "lastName",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "family_name",
"claim.name": "last_name",
"jsonType.label": "String"
}
},
@@ -1207,6 +1260,7 @@
"consentRequired": false,
"config": {
"multivalued": "true",
"userinfo.token.claim": "true",
"user.attribute": "foo",
"id.token.claim": "true",
"access.token.claim": "true",
+20 -6
View File
@@ -8,12 +8,12 @@ server {
alias /data/media;
}
# location / {
# proxy_pass http://app:8000;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# }
location / {
proxy_pass http://app:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
server {
@@ -32,3 +32,17 @@ server {
}
}
server {
listen 8083;
server_name localhost;
charset utf-8;
location / {
proxy_pass http://keycloak:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
+17 -5
View File
@@ -7,9 +7,6 @@ DJANGO_SUPERUSER_PASSWORD=admin
# Python
PYTHONPATH=/app
#JWT
DJANGO_JWT_PRIVATE_SIGNING_KEY=ThisIsAnExampleKeyForDevPurposeOnly
# People settings
# Mail
@@ -19,5 +16,20 @@ DJANGO_EMAIL_PORT=1025
# Backend url
PEOPLE_BASE_URL="http://localhost:8072"
# Keycloak
SIMPLE_JWT_JWK_URL="http://keycloak:8080/realms/people/protocol/openid-connect/certs"
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/people/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/people/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/people/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/people/protocol/openid-connect/userinfo
OIDC_RP_CLIENT_ID=people
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
LOGIN_REDIRECT_URL=http://localhost:3000
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
+3
View File
@@ -0,0 +1,3 @@
# For the CI job test-e2e
SUSTAINED_THROTTLE_RATES="200/hour"
BURST_THROTTLE_RATES="200/minute"
+14
View File
@@ -13,6 +13,20 @@
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": ["node", "node-fetch", "i18next-parser"]
},
{
"enabled": false,
"groupName": "ignored js @types/react-dom@18.2.19",
"matchManagers": ["npm"],
"matchPackageNames": ["@types/react-dom"],
"allowedVersions": ">18.2.19"
},
{
"enabled": false,
"groupName": "ignored js @openfun/cunningham-react@2.5.0",
"matchManagers": ["npm"],
"matchPackageNames": ["@openfun/cunningham-react"],
"allowedVersions": ">2.5.0"
}
]
}
+37
View File
@@ -82,6 +82,15 @@ class UserAdmin(auth_admin.UserAdmin):
),
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("email", "password1", "password2"),
},
),
)
inlines = (IdentityInline, TeamAccessInline)
list_display = (
"email",
@@ -110,3 +119,31 @@ class TeamAdmin(admin.ModelAdmin):
"updated_at",
)
search_fields = ("name",)
@admin.register(models.Invitation)
class InvitationAdmin(admin.ModelAdmin):
"""Admin interface to handle invitations."""
fields = (
"email",
"team",
"role",
"created_at",
"issuer",
)
readonly_fields = (
"created_at",
"is_expired",
"issuer",
)
list_display = (
"email",
"team",
"created_at",
"is_expired",
)
def save_model(self, request, obj, form, change):
obj.issuer = request.user
obj.save()
+92 -10
View File
@@ -26,28 +26,66 @@ class ContactSerializer(serializers.ModelSerializer):
return super().update(instance, validated_data)
class UserSerializer(serializers.ModelSerializer):
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
"""
A ModelSerializer that takes an additional `fields` argument that
controls which fields should be displayed.
"""
def __init__(self, *args, **kwargs):
# Don't pass the 'fields' arg up to the superclass
fields = kwargs.pop("fields", None)
# Instantiate the superclass normally
super().__init__(*args, **kwargs)
if fields is not None:
# Drop any fields that are not specified in the `fields` argument.
allowed = set(fields)
existing = set(self.fields)
for field_name in existing - allowed:
self.fields.pop(field_name)
class UserSerializer(DynamicFieldsModelSerializer):
"""Serialize users."""
data = serializers.SerializerMethodField(read_only=True)
timezone = TimeZoneSerializerField(use_pytz=False, required=True)
name = serializers.SerializerMethodField(read_only=True)
email = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.User
fields = [
"id",
"name",
"email",
"data",
"language",
"timezone",
"is_device",
"is_staff",
]
read_only_fields = ["id", "email", "data", "is_device", "is_staff"]
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
def get_data(self, user) -> dict:
"""Return contact data for the user."""
return user.profile_contact.data if user.profile_contact else {}
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):
@@ -126,17 +164,52 @@ class TeamAccessSerializer(serializers.ModelSerializer):
return attrs
class TeamAccessReadOnlySerializer(TeamAccessSerializer):
"""Serialize team accesses for list and retrieve actions."""
user = UserSerializer(read_only=True, fields=["id", "name", "email"])
class Meta:
model = models.TeamAccess
fields = [
"id",
"user",
"role",
"abilities",
]
read_only_fields = [
"id",
"user",
"role",
"abilities",
]
class TeamSerializer(serializers.ModelSerializer):
"""Serialize teams."""
abilities = serializers.SerializerMethodField(read_only=True)
accesses = TeamAccessSerializer(many=True, read_only=True)
slug = serializers.SerializerMethodField()
class Meta:
model = models.Team
fields = ["id", "name", "accesses", "abilities", "slug"]
read_only_fields = ["id", "accesses", "abilities", "slug"]
fields = [
"id",
"name",
"accesses",
"abilities",
"slug",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"accesses",
"abilities",
"slug",
"created_at",
"updated_at",
]
def get_abilities(self, team) -> dict:
"""Return abilities of the logged-in user on the instance."""
@@ -148,3 +221,12 @@ class TeamSerializer(serializers.ModelSerializer):
def get_slug(self, instance):
"""Return slug from the team's name."""
return instance.get_slug()
class InvitationSerializer(serializers.ModelSerializer):
"""Serialize invitations."""
class Meta:
model = models.Invitation
fields = ["email", "team", "role", "issuer"]
read_only_fields = ["team", "issuer"]
-14
View File
@@ -1,14 +0,0 @@
"""
Utils that can be useful throughout the People core app
"""
from rest_framework_simplejwt.tokens import RefreshToken
def get_tokens_for_user(user):
"""Get JWT tokens for user authentication."""
refresh = RefreshToken.for_user(user)
return {
"refresh": str(refresh),
"access": str(refresh.access_token),
}
+33 -6
View File
@@ -1,10 +1,11 @@
"""API endpoints"""
from django.contrib.postgres.search import TrigramSimilarity
from django.db.models import Func, Max, OuterRef, Q, Subquery, Value
from django.db.models import Func, Max, OuterRef, Prefetch, Q, Subquery, Value
from rest_framework import (
decorators,
exceptions,
filters,
mixins,
pagination,
response,
@@ -185,7 +186,7 @@ class UserViewSet(
"""
permission_classes = [permissions.IsSelf]
queryset = models.User.objects.all().select_related("profile_contact")
queryset = models.User.objects.all()
serializer_class = serializers.UserSerializer
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
pagination_class = Pagination
@@ -198,6 +199,12 @@ 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",
)
)
# Search by case-insensitive and accent-insensitive trigram similarity
@@ -226,9 +233,12 @@ class UserViewSet(
"""
Return information on currently logged user
"""
context = {"request": request}
user = request.user
user.main_identity = models.Identity.objects.filter(
user=user, is_main=True
).first()
return response.Response(
self.serializer_class(request.user, context=context).data
self.serializer_class(user, context={"request": request}).data
)
@@ -244,6 +254,9 @@ class TeamViewSet(
permission_classes = [permissions.AccessPermission]
serializer_class = serializers.TeamSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at"]
ordering = ["-created_at"]
queryset = models.Team.objects.all()
def get_queryset(self):
@@ -301,7 +314,8 @@ class TeamAccessViewSet(
pagination_class = Pagination
permission_classes = [permissions.AccessPermission]
queryset = models.TeamAccess.objects.all().select_related("user")
serializer_class = serializers.TeamAccessSerializer
list_serializer_class = serializers.TeamAccessReadOnlySerializer
detail_serializer_class = serializers.TeamAccessSerializer
def get_permissions(self):
"""User only needs to be authenticated to list team accesses"""
@@ -318,22 +332,35 @@ class TeamAccessViewSet(
context["team_id"] = self.kwargs["team_id"]
return context
def get_serializer_class(self):
if self.action in {"list", "retrieve"}:
return self.list_serializer_class
return self.detail_serializer_class
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(team=self.kwargs["team_id"])
if self.action == "list":
if self.action in {"list", "retrieve"}:
# Limit to team access instances related to a team THAT also has a team access
# instance for the logged-in user (we don't want to list only the team access
# instances pointing to the logged-in user)
user_role_query = models.TeamAccess.objects.filter(
team__accesses__user=self.request.user
).values("role")[:1]
queryset = (
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",
)
)
.annotate(user_role=Subquery(user_role_query))
.distinct()
)
+106 -44
View File
@@ -1,59 +1,121 @@
"""Authentication for the People core app."""
from django.conf import settings
from django.utils.functional import SimpleLazyObject
from django.utils.module_loading import import_string
from django.core.exceptions import SuspiciousOperation
from django.db import models
from django.utils.translation import gettext_lazy as _
from drf_spectacular.authentication import SessionScheme, TokenScheme
from drf_spectacular.plumbing import build_bearer_security_scheme_object
from rest_framework import authentication
from rest_framework_simplejwt.authentication import JWTAuthentication
import requests
from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
from .models import Identity
class DelegatedJWTAuthentication(JWTAuthentication):
"""Override JWTAuthentication to create missing users on the fly."""
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
def get_user(self, validated_token):
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.
"""
def get_userinfo(self, access_token, id_token, payload):
"""Return user details dictionary.
Parameters:
- access_token (str): The access token.
- id_token (str): The id token (unused).
- payload (dict): The token payload (unused).
Note: The id_token and payload parameters are unused in this implementation,
but were kept to preserve base method signature.
Note: It handles signed and/or encrypted UserInfo Response. It is required by
Agent Connect, which follows the OIDC standard. It forces us to override the
base method, which deal with 'application/json' response.
Returns:
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
"""
Return the user related to the given validated token, creating or updating it if necessary.
user_response = requests.get(
self.OIDC_OP_USER_ENDPOINT,
headers={"Authorization": f"Bearer {access_token}"},
verify=self.get_settings("OIDC_VERIFY_SSL", True),
timeout=self.get_settings("OIDC_TIMEOUT", None),
proxies=self.get_settings("OIDC_PROXY", None),
)
user_response.raise_for_status()
userinfo = self.verify_token(user_response.text)
return userinfo
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
Parameters:
- access_token (str): The access token.
- id_token (str): The ID token.
- payload (dict): The user payload.
Returns:
- User: An existing or newly created User instance.
Raises:
- Exception: Raised when user creation is not allowed and no existing user is found.
"""
get_user = import_string(settings.JWT_USER_GETTER)
return SimpleLazyObject(lambda: get_user(validated_token))
user_info = self.get_userinfo(access_token, id_token, payload)
class OpenApiJWTAuthenticationExtension(TokenScheme):
"""Extension for specifying JWT authentication schemes."""
# Compute user name from OIDC name fields as defined in settings
names_list = [
user_info[field]
for field in settings.USER_OIDC_FIELDS_TO_NAME
if user_info.get(field)
]
user_info["name"] = " ".join(names_list) or None
target_class = "core.authentication.DelegatedJWTAuthentication"
name = "DelegatedJWTAuthentication"
sub = user_info.get("sub")
if sub is None:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
def get_security_definition(self, auto_schema):
"""Return the security definition for JWT authentication."""
return build_bearer_security_scheme_object(
header_name="Authorization",
token_prefix="Bearer", # noqa S106
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:
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)
return user
def create_user(self, claims):
"""Return a newly created User instance."""
sub = claims.get("sub")
if sub is None:
raise SuspiciousOperation(
_("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")
)
class SessionAuthenticationWithAuthenticateHeader(authentication.SessionAuthentication):
"""
This class is needed, because REST Framework's default SessionAuthentication does
never return 401's, because they cannot fill the WWW-Authenticate header with a
valid value in the 401 response. As a result, we cannot distinguish calls that are
not unauthorized (401 unauthorized) and calls for which the user does not have
permission (403 forbidden).
See https://github.com/encode/django-rest-framework/issues/5968
We do set authenticate_header function in SessionAuthentication, so that a value
for the WWW-Authenticate header can be retrieved and the response code is
automatically set to 401 in case of unauthenticated requests.
"""
def authenticate_header(self, request):
return "Session"
class OpenApiSessionAuthenticationExtension(SessionScheme):
"""Extension for specifying session authentication schemes."""
target_class = "core.api.authentication.SessionAuthenticationWithAuthenticateHeader"
return user
+13
View File
@@ -139,6 +139,7 @@ class IdentityFactory(factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory)
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
name = factory.Faker("name")
class TeamFactory(factory.django.DjangoModelFactory):
@@ -170,3 +171,15 @@ class TeamAccessFactory(factory.django.DjangoModelFactory):
team = factory.SubFactory(TeamFactory)
user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class InvitationFactory(factory.django.DjangoModelFactory):
"""A factory to create invitations for a user"""
class Meta:
model = models.Invitation
email = factory.Faker("email")
team = factory.SubFactory(TeamFactory)
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory)
+20 -4
View File
@@ -1,6 +1,6 @@
# Generated by Django 5.0.1 on 2024-02-06 15:08
# Generated by Django 5.0.2 on 2024-02-23 06:46
import core.models
import django.contrib.auth.models
import django.core.validators
import django.db.models.deletion
import timezone_field.fields
@@ -27,7 +27,7 @@ class Migration(migrations.Migration):
('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=100)),
('slug', models.SlugField(max_length=100, unique=True)),
('slug', models.SlugField(editable=False, max_length=100, unique=True)),
],
options={
'verbose_name': 'Team',
@@ -60,7 +60,7 @@ class Migration(migrations.Migration):
'db_table': 'people_user',
},
managers=[
('objects', core.models.UserManager()),
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
@@ -95,6 +95,7 @@ class Migration(migrations.Migration):
('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)),
],
@@ -105,6 +106,21 @@ class Migration(migrations.Migration):
'ordering': ('-is_main', 'email'),
},
),
migrations.CreateModel(
name='Invitation',
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')),
('email', models.EmailField(max_length=254, verbose_name='email address')),
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.team')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='TeamAccess',
fields=[
+29 -46
View File
@@ -4,19 +4,19 @@ Declare and configure the models for the People core application
import json
import os
import uuid
from datetime import timedelta
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.core import exceptions, mail, validators
from django.db import models
from django.utils import timezone
from django.utils.functional import lazy
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
import jsonschema
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework_simplejwt.settings import api_settings
from timezone_field import TimeZoneField
current_dir = os.path.dirname(os.path.abspath(__file__))
@@ -147,16 +147,6 @@ class Contact(BaseModel):
raise exceptions.ValidationError({"data": [error_message]}) from e
class UserManager(auth_models.UserManager):
"""
Override user manager to get the related contact in the same query by default (Contact model)
"""
def get_queryset(self):
"""Always select the related contact when doing a query on users."""
return super().get_queryset().select_related("profile_contact")
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
@@ -200,7 +190,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
),
)
objects = UserManager()
objects = auth_models.UserManager()
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
@@ -227,7 +217,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
)
def email_user(self, subject, message, from_email=None, **kwargs):
"""Send an email to this user."""
"""Email this user."""
main_identity = self.identities.get(is_main=True)
mail.send_mail(subject, message, from_email, [main_identity.email], **kwargs)
@@ -264,6 +254,7 @@ class Identity(BaseModel):
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,
@@ -288,7 +279,8 @@ class Identity(BaseModel):
def __str__(self):
main_str = "[main]" if self.is_main else ""
return f"{self.email:s}{main_str:s}"
id_str = self.email or self.sub
return f"{id_str:s}{main_str:s}"
def save(self, *args, **kwargs):
"""Ensure users always have one and only one main identity."""
@@ -452,38 +444,29 @@ class TeamAccess(BaseModel):
}
def oidc_user_getter(validated_token):
"""
Given a valid OIDC token , retrieve, create or update corresponding user/contact/email from db.
class Invitation(BaseModel):
"""User invitation to teams."""
The token is expected to have the following fields in payload:
- sub
- email
- ...
"""
try:
user_id = validated_token[api_settings.USER_ID_CLAIM]
except KeyError as exc:
raise InvalidToken(
_("Token contained no recognizable user identification")
) from exc
try:
email_param = {"email": validated_token["email"]}
except KeyError:
email_param = {}
user = (
User.objects.filter(identities__sub=user_id)
.annotate(identity_email=models.F("identities__email"))
.distinct()
.first()
email = models.EmailField(_("email address"), null=False, blank=False)
team = models.ForeignKey(
Team,
on_delete=models.CASCADE,
related_name="invitations",
)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
)
issuer = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="invitations",
)
if user is None:
user = User.objects.create(password="!", **email_param) # noqa: S106
Identity.objects.create(user=user, sub=user_id, **email_param)
elif email_param and validated_token["email"] != user.identity_email:
Identity.objects.filter(sub=user_id).update(email=validated_token["email"])
def __str__(self):
return f"{self.email} invited to {self.team}"
return user
@property
def is_expired(self):
"""Calculate if invitation is still valid or has expired."""
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
return timezone.now() > (self.created_at + validity_duration)
@@ -0,0 +1,175 @@
"""
Test for team accesses API endpoints in People's core app : create
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_team_accesses_create_anonymous():
"""Anonymous users should not be allowed to create team accesses."""
user = factories.UserFactory()
team = factories.TeamFactory()
response = APIClient().post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(user.id),
"team": str(team.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TeamAccess.objects.exists() is False
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
other_user = factories.UserFactory()
team = factories.TeamFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You are not allowed to manage accesses for this team."
}
assert not models.TeamAccess.objects.filter(user=other_user).exists()
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
team = factories.TeamFactory(users=[(user, "member")])
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
for role in [role[0] for role in models.RoleChoices.choices]:
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 == 403
assert response.json() == {
"detail": "You are not allowed to manage accesses for this team."
}
assert not models.TeamAccess.objects.filter(user=other_user).exists()
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
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a team can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
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 models.TeamAccess.objects.filter(user=other_user).count() == 1
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_team_access.get_abilities(user),
"id": str(new_team_access.id),
"role": role,
"user": str(other_user.id),
}
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
team = factories.TeamFactory(users=[(user, "owner")])
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
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 models.TeamAccess.objects.filter(user=other_user).count() == 1
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_team_access.get_abilities(user),
"id": str(new_team_access.id),
"role": role,
"user": str(other_user.id),
}
@@ -0,0 +1,164 @@
"""
Test for team accesses API endpoints in People's core app : delete
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_team_accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a team access."""
access = factories.TeamAccessFactory()
response = APIClient().delete(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert models.TeamAccess.objects.count() == 1
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
access = factories.TeamAccessFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
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
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
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() == 2
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
team = factories.TeamFactory(users=[(user, "administrator")])
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)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TeamAccess.objects.count() == 1
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
team = factories.TeamFactory(users=[(user, "owner")])
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)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TeamAccess.objects.count() == 1
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
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
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() == 2
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
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
@@ -0,0 +1,187 @@
"""
Test for team accesses API endpoints in People's core app : list
"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_team_accesses_list_anonymous():
"""Anonymous users should not be allowed to list team accesses."""
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(2, team=team)
response = APIClient().get(f"/api/v1.0/teams/{team.id!s}/accesses/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
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
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(3, team=team)
# 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)
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": 0,
"next": None,
"previous": None,
"results": [],
}
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, whatever their role in the team.
"""
identity = factories.IdentityFactory(is_main=True)
user = identity.user
team = factories.TeamFactory()
user_access = models.TeamAccess.objects.create(team=team, user=user) # random role
# other team members should appear
other_member = factories.UserFactory()
other_member_identity = factories.IdentityFactory(is_main=True, user=other_member)
access1 = factories.TeamAccessFactory.create(team=team, user=other_member)
# 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)
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
assert sorted(response.json()["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": {
"id": str(user_access.user.id),
"email": str(identity.email),
"name": str(identity.name),
},
"role": str(user_access.role),
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": {
"id": str(access1.user.id),
"email": str(other_member_identity.email),
"name": str(other_member_identity.name),
},
"role": str(access1.role),
"abilities": access1.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_team_accesses_list_authenticated_main_identity():
"""
Name and email should be returned from main identity only
"""
user = factories.UserFactory()
identity = factories.IdentityFactory(user=user, is_main=True)
factories.IdentityFactory(user=user) # additional non-main identity
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user) # random role
# 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)
# 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)
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)),
]
)
def test_api_team_accesses_list_authenticated_constant_numqueries(
django_assert_num_queries,
):
"""
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,
# 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):
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
factories.TeamAccessFactory(team=team, user=extra_user)
# num queries should still be 4
with django_assert_num_queries(4):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 21
@@ -0,0 +1,86 @@
"""
Test for team accesses API endpoints in People's core app : retrieve
"""
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_team_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a team access.
"""
access = factories.TeamAccessFactory()
response = APIClient().get(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
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
access = factories.TeamAccessFactory(team=factories.TeamFactory())
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
# Accesses related to another team should be excluded even if the user is related to it
for other_access in [
factories.TeamAccessFactory(),
factories.TeamAccessFactory(user=user),
]:
response = client.get(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{other_access.id!s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
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
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": {
"id": str(access.user.id),
"email": str(identity.email),
"name": str(identity.name),
},
"role": str(access.role),
"abilities": access.get_abilities(user),
}
@@ -0,0 +1,340 @@
"""
Test for team accesses API endpoints in People's core app : update
"""
import random
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_team_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a team access."""
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = APIClient().put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 401
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
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
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
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
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
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
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "member"]),
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
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
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(team=team, user=other_user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
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
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(
team=team,
user=other_user,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": "owner",
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
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
team = factories.TeamFactory(users=[(user, "owner")])
factories.UserFactory()
access = factories.TeamAccessFactory(
team=team,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
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
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
content_type="application/json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
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
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "member"])
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
assert access.role == "owner"
# Add another owner and it should now work
factories.TeamAccessFactory(team=team, role="owner")
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
@@ -11,7 +11,6 @@ from rest_framework.test import APIClient
from core.factories import IdentityFactory, TeamFactory
from core.models import Team
from core.tests.utils import OIDCToken
pytestmark = pytest.mark.django_db
@@ -36,15 +35,16 @@ def test_api_teams_create_authenticated():
"""
identity = IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
response = APIClient().post(
client = APIClient()
client.force_login(identity.user)
response = client.post(
"/api/v1.0/teams/",
{
"name": "my team",
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_201_CREATED
@@ -58,12 +58,12 @@ def test_api_teams_create_authenticated_slugify_name():
Creating teams should automatically generate a slug.
"""
identity = IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
client = APIClient()
client.force_login(identity.user)
response = APIClient().post(
response = client.post(
"/api/v1.0/teams/",
{"name": "my team"},
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_201_CREATED
@@ -87,14 +87,15 @@ def test_api_teams_create_authenticated_expected_slug(param):
Creating teams should automatically create unaccented, no unicode, lower-case slug.
"""
identity = IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
response = APIClient().post(
client = APIClient()
client.force_login(identity.user)
response = client.post(
"/api/v1.0/teams/",
{
"name": param[0],
},
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_201_CREATED
@@ -109,14 +110,15 @@ def test_api_teams_create_authenticated_unique_slugs():
"""
TeamFactory(name="existing team")
identity = IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
response = APIClient().post(
client = APIClient()
client.force_login(identity.user)
response = client.post(
"/api/v1.0/teams/",
{
"name": "èxisting team",
},
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
@@ -11,7 +11,6 @@ from rest_framework.status import (
from rest_framework.test import APIClient
from core import factories, models
from core.tests.utils import OIDCToken
pytestmark = pytest.mark.django_db
@@ -34,13 +33,14 @@ def test_api_teams_delete_authenticated_unrelated():
related.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(identity.user)
team = factories.TeamFactory()
response = APIClient().delete(
f"/api/v1.0/teams/{team.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/",
)
assert response.status_code == HTTP_404_NOT_FOUND
@@ -55,12 +55,14 @@ def test_api_teams_delete_authenticated_member():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "member")])
response = APIClient().delete(
f"/api/v1.0/teams/{team.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.delete(
f"/api/v1.0/teams/{team.id}/",
)
assert response.status_code == HTTP_403_FORBIDDEN
@@ -77,12 +79,14 @@ def test_api_teams_delete_authenticated_administrator():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
response = APIClient().delete(
f"/api/v1.0/teams/{team.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.delete(
f"/api/v1.0/teams/{team.id}/",
)
assert response.status_code == HTTP_403_FORBIDDEN
@@ -99,12 +103,14 @@ def test_api_teams_delete_authenticated_owner():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
response = APIClient().delete(
f"/api/v1.0/teams/{team.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.delete(
f"/api/v1.0/teams/{team.id}/",
)
assert response.status_code == HTTP_204_NO_CONTENT
@@ -8,10 +8,7 @@ from rest_framework.pagination import PageNumberPagination
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from ..utils import OIDCToken
from core import factories
pytestmark = pytest.mark.django_db
@@ -29,10 +26,15 @@ def test_api_teams_list_anonymous():
def test_api_teams_list_authenticated():
"""Authenticated users should be able to list teams they are an owner/administrator/member of."""
"""
Authenticated users should be able to list teams
they are an owner/administrator/member of.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
expected_ids = {
str(access.team.id)
@@ -40,8 +42,8 @@ def test_api_teams_list_authenticated():
}
factories.TeamFactory.create_batch(2) # Other teams
response = APIClient().get(
"/api/v1.0/teams/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.get(
"/api/v1.0/teams/",
)
assert response.status_code == HTTP_200_OK
@@ -58,7 +60,9 @@ def test_api_teams_list_pagination(
"""Pagination should work as expected."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team_ids = [
str(access.team.id)
@@ -66,8 +70,8 @@ def test_api_teams_list_pagination(
]
# Get page 1
response = APIClient().get(
"/api/v1.0/teams/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.get(
"/api/v1.0/teams/",
)
assert response.status_code == HTTP_200_OK
@@ -82,8 +86,8 @@ def test_api_teams_list_pagination(
team_ids.remove(item["id"])
# Get page 2
response = APIClient().get(
"/api/v1.0/teams/?page=2", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.get(
"/api/v1.0/teams/?page=2",
)
assert response.status_code == HTTP_200_OK
@@ -102,17 +106,77 @@ def test_api_teams_list_authenticated_distinct():
"""A team with several related users should only be listed once."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
team = factories.TeamFactory(users=[user, other_user])
response = APIClient().get(
"/api/v1.0/teams/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.get(
"/api/v1.0/teams/",
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(team.id)
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
client = APIClient()
client.force_login(user)
team_ids = [
str(team.id) for team in factories.TeamFactory.create_batch(5, users=[user])
]
response = client.get(
"/api/v1.0/teams/",
)
assert response.status_code == 200
response_data = response.json()
response_team_ids = [team["id"] for team in response_data["results"]]
team_ids.reverse()
assert (
response_team_ids == team_ids
), "created_at values are not sorted from newest to oldest"
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
client = APIClient()
client.force_login(user)
team_ids = [
str(team.id) for team in factories.TeamFactory.create_batch(5, users=[user])
]
response = client.get(
"/api/v1.0/teams/?ordering=created_at",
)
assert response.status_code == 200
response_data = response.json()
response_team_ids = [team["id"] for team in response_data["results"]]
assert (
response_team_ids == team_ids
), "created_at values are not sorted from oldest to newest"
@@ -6,7 +6,6 @@ from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_404_N
from rest_framework.test import APIClient
from core import factories
from core.tests.utils import OIDCToken
pytestmark = pytest.mark.django_db
@@ -28,13 +27,14 @@ def test_api_teams_retrieve_authenticated_unrelated():
not related.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(identity.user)
team = factories.TeamFactory()
response = APIClient().get(
f"/api/v1.0/teams/{team.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.get(
f"/api/v1.0/teams/{team.id!s}/",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "Not found."}
@@ -47,37 +47,30 @@ def test_api_teams_retrieve_authenticated_related():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory()
access1 = factories.TeamAccessFactory(team=team, user=user)
access2 = factories.TeamAccessFactory(team=team)
response = APIClient().get(
f"/api/v1.0/teams/{team.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.get(
f"/api/v1.0/teams/{team.id!s}/",
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted(
assert sorted(response.json().pop("accesses")) == sorted(
[
{
"id": str(access1.id),
"user": str(user.id),
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["user"],
str(access1.id),
str(access2.id),
]
)
assert response.json() == {
"id": str(team.id),
"name": team.name,
"slug": team.slug,
"abilities": team.get_abilities(user),
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -15,7 +15,6 @@ from rest_framework.test import APIClient
from core import factories
from core.api import serializers
from core.tests.utils import OIDCToken
pytestmark = pytest.mark.django_db
@@ -46,18 +45,18 @@ 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 = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(identity.user)
team = factories.TeamFactory()
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
response = APIClient().put(
response = client.put(
f"/api/v1.0/teams/{team.id!s}/",
new_team_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_404_NOT_FOUND
@@ -75,17 +74,18 @@ def test_api_teams_update_authenticated_members():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "member")])
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
response = APIClient().put(
response = client.put(
f"/api/v1.0/teams/{team.id!s}/",
new_team_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_403_FORBIDDEN
@@ -102,26 +102,29 @@ def test_api_teams_update_authenticated_administrators():
"""Administrators of a team should be allowed to update it."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
initial_values = serializers.TeamSerializer(instance=team).data
# generate new random values
new_values = serializers.TeamSerializer(instance=factories.TeamFactory.build()).data
response = APIClient().put(
response = client.put(
f"/api/v1.0/teams/{team.id!s}/",
new_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_200_OK
team.refresh_from_db()
final_values = serializers.TeamSerializer(instance=team).data
for key, value in final_values.items():
if key in ["id", "accesses"]: # pylint: disable=R1733
if key in ["id", "accesses", "created_at"]:
assert value == initial_values[key]
elif key == "updated_at":
assert value > initial_values[key]
else:
# name, slug and abilities successfully modified
assert value == new_values[key]
@@ -132,7 +135,9 @@ def test_api_teams_update_authenticated_owners():
apart from read-only fields."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
old_team_values = serializers.TeamSerializer(instance=team).data
@@ -140,19 +145,20 @@ def test_api_teams_update_authenticated_owners():
new_team_values = serializers.TeamSerializer(
instance=factories.TeamFactory.build()
).data
response = APIClient().put(
response = client.put(
f"/api/v1.0/teams/{team.id!s}/",
new_team_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_200_OK
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
for key, value in team_values.items():
if key in ["id", "accesses"]:
if key in ["id", "accesses", "created_at"]:
assert value == old_team_values[key]
elif key == "updated_at":
assert value > old_team_values[key]
else:
# name, slug and abilities successfully modified
assert value == new_team_values[key]
@@ -165,18 +171,19 @@ def test_api_teams_update_administrator_or_owner_of_another():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
factories.TeamFactory(users=[(user, random.choice(["administrator", "owner"]))])
team = factories.TeamFactory(name="Old name")
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
response = APIClient().put(
response = client.put(
f"/api/v1.0/teams/{team.id!s}/",
new_team_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_404_NOT_FOUND
@@ -194,7 +201,9 @@ def test_api_teams_update_existing_slug_should_return_error():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
factories.TeamFactory(name="Existing team", users=[(user, "administrator")])
my_team = factories.TeamFactory(name="New team", users=[(user, "administrator")])
@@ -202,11 +211,10 @@ def test_api_teams_update_existing_slug_should_return_error():
updated_values = serializers.TeamSerializer(instance=my_team).data
# Update my team's name for existing team. Creates a duplicate slug
updated_values["name"] = "existing team"
response = APIClient().put(
response = client.put(
f"/api/v1.0/teams/{my_team.id!s}/",
updated_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["slug"] == ["Team with this Slug already exists."]
+88 -101
View File
@@ -9,8 +9,6 @@ from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from .utils import OIDCToken
pytestmark = pytest.mark.django_db
@@ -75,7 +73,6 @@ def test_api_contacts_list_authenticated_no_query():
contact = factories.ContactFactory(owner=user)
user.profile_contact = contact
user.save()
jwt_token = OIDCToken.for_user(user)
# Let's have 5 contacts in database:
assert user.profile_contact is not None # Excluded because profile contact
@@ -87,9 +84,10 @@ def test_api_contacts_list_authenticated_no_query():
base=base_contact, owner=user, full_name="Bernard"
) # Included
response = APIClient().get(
"/api/v1.0/contacts/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/contacts/")
assert response.status_code == 200
assert response.json() == [
@@ -111,7 +109,6 @@ def test_api_contacts_list_authenticated_by_full_name():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
dave = factories.BaseContactFactory(full_name="David Bowman")
nicole = factories.BaseContactFactory(full_name="Nicole Foole")
@@ -119,36 +116,31 @@ def test_api_contacts_list_authenticated_by_full_name():
factories.BaseContactFactory(full_name="Heywood Floyd")
# Full query should work
response = APIClient().get(
"/api/v1.0/contacts/?q=David%20Bowman", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/contacts/?q=David%20Bowman")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
# Partial query should work
response = APIClient().get(
"/api/v1.0/contacts/?q=ank", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
response = client.get("/api/v1.0/contacts/?q=ank")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(frank.id)]
# Result that matches a trigram twice ranks better than result that matches once
response = APIClient().get(
"/api/v1.0/contacts/?q=ole", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
response = client.get("/api/v1.0/contacts/?q=ole")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
# "Nicole Foole" matches twice on "ole"
assert contact_ids == [str(nicole.id), str(frank.id)]
response = APIClient().get(
"/api/v1.0/contacts/?q=ool", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
response = client.get("/api/v1.0/contacts/?q=ool")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
@@ -159,23 +151,21 @@ def test_api_contacts_list_authenticated_uppercase_content():
"""Upper case content should be found by lower case query."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
dave = factories.BaseContactFactory(full_name="EEE", short_name="AAA")
# Unaccented full name
response = APIClient().get(
"/api/v1.0/contacts/?q=eee", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/contacts/?q=eee")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
# Unaccented short name
response = APIClient().get(
"/api/v1.0/contacts/?q=aaa", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
response = client.get("/api/v1.0/contacts/?q=aaa")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
@@ -186,23 +176,21 @@ def test_api_contacts_list_authenticated_capital_query():
"""Upper case query should find lower case content."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
client = APIClient()
client.force_login(user)
# Unaccented full name
response = APIClient().get(
"/api/v1.0/contacts/?q=EEE", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
response = client.get("/api/v1.0/contacts/?q=EEE")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
# Unaccented short name
response = APIClient().get(
"/api/v1.0/contacts/?q=AAA", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
response = client.get("/api/v1.0/contacts/?q=AAA")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
@@ -213,23 +201,21 @@ def test_api_contacts_list_authenticated_accented_content():
"""Accented content should be found by unaccented query."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
dave = factories.BaseContactFactory(full_name="ééé", short_name="ààà")
client = APIClient()
client.force_login(user)
# Unaccented full name
response = APIClient().get(
"/api/v1.0/contacts/?q=eee", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
response = client.get("/api/v1.0/contacts/?q=eee")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
# Unaccented short name
response = APIClient().get(
"/api/v1.0/contacts/?q=aaa", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
response = client.get("/api/v1.0/contacts/?q=aaa")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
@@ -240,23 +226,21 @@ def test_api_contacts_list_authenticated_accented_query():
"""Accented query should find unaccented content."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
client = APIClient()
client.force_login(user)
# Unaccented full name
response = APIClient().get(
"/api/v1.0/contacts/?q=ééé", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
response = client.get("/api/v1.0/contacts/?q=ééé")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
# Unaccented short name
response = APIClient().get(
"/api/v1.0/contacts/?q=ààà", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
response = client.get("/api/v1.0/contacts/?q=ààà")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
@@ -281,13 +265,13 @@ def test_api_contacts_retrieve_authenticated_owned():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
contact = factories.ContactFactory(owner=user)
response = APIClient().get(
f"/api/v1.0/contacts/{contact.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 200
assert response.json() == {
@@ -305,13 +289,12 @@ def test_api_contacts_retrieve_authenticated_public():
Authenticated users should be able to retrieve public contacts.
"""
identity = factories.IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
contact = factories.BaseContactFactory()
response = APIClient().get(
f"/api/v1.0/contacts/{contact.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
client = APIClient()
client.force_login(identity.user)
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 200
assert response.json() == {
"id": str(contact.id),
@@ -328,13 +311,12 @@ def test_api_contacts_retrieve_authenticated_other():
Authenticated users should not be allowed to retrieve another user's contacts.
"""
identity = factories.IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
contact = factories.ContactFactory()
response = APIClient().get(
f"/api/v1.0/contacts/{contact.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
client = APIClient()
client.force_login(identity.user)
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
@@ -357,17 +339,17 @@ 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 = identity.user
jwt_token = OIDCToken.for_user(user)
response = APIClient().post(
client = APIClient()
client.force_login(identity.user)
response = client.post(
"/api/v1.0/contacts/",
{
"full_name": "David Bowman",
"short_name": "Dave",
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 400
assert models.Contact.objects.exists() is False
@@ -379,14 +361,15 @@ 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
jwt_token = OIDCToken.for_user(user)
base_contact = factories.BaseContactFactory()
client = APIClient()
client.force_login(user)
# Existing override for another user should not interfere
factories.ContactFactory(base=base_contact)
response = APIClient().post(
response = client.post(
"/api/v1.0/contacts/",
{
"base": str(base_contact.id),
@@ -395,7 +378,6 @@ def test_api_contacts_create_authenticated_successful():
"data": CONTACT_DATA,
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 201
@@ -426,12 +408,14 @@ def test_api_contacts_create_authenticated_existing_override():
"""
identity = factories.IdentityFactory(user__profile_contact=None)
user = identity.user
jwt_token = OIDCToken.for_user(user)
base_contact = factories.BaseContactFactory()
factories.ContactFactory(base=base_contact, owner=user)
response = APIClient().post(
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/contacts/",
{
"base": str(base_contact.id),
@@ -440,7 +424,6 @@ def test_api_contacts_create_authenticated_existing_override():
"data": CONTACT_DATA,
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 400
@@ -481,7 +464,9 @@ def test_api_contacts_update_authenticated_owned():
"""
identity = factories.IdentityFactory(user__profile_contact=None)
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
contact = factories.ContactFactory(owner=user) # Owned by the logged-in user
old_contact_values = serializers.ContactSerializer(instance=contact).data
@@ -491,11 +476,10 @@ def test_api_contacts_update_authenticated_owned():
).data
new_contact_values["base"] = str(factories.ContactFactory().id)
response = APIClient().put(
response = client.put(
f"/api/v1.0/contacts/{contact.id!s}/",
new_contact_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
@@ -511,11 +495,13 @@ def test_api_contacts_update_authenticated_owned():
def test_api_contacts_update_authenticated_profile():
"""
Authenticated users should be allowed to update their prodile contact.
Authenticated users should be allowed to update their profile contact.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
contact = factories.ContactFactory(owner=user)
user.profile_contact = contact
@@ -527,11 +513,10 @@ def test_api_contacts_update_authenticated_profile():
).data
new_contact_values["base"] = str(factories.ContactFactory().id)
response = APIClient().put(
response = client.put(
f"/api/v1.0/contacts/{contact.id!s}/",
new_contact_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
@@ -550,7 +535,9 @@ def test_api_contacts_update_authenticated_other():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
contact = factories.ContactFactory() # owned by another user
old_contact_values = serializers.ContactSerializer(instance=contact).data
@@ -560,11 +547,10 @@ def test_api_contacts_update_authenticated_other():
).data
new_contact_values["base"] = str(factories.ContactFactory().id)
response = APIClient().put(
response = client.put(
f"/api/v1.0/contacts/{contact.id!s}/",
new_contact_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
@@ -588,13 +574,13 @@ 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
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
factories.ContactFactory.create_batch(2)
response = APIClient().delete(
"/api/v1.0/contacts/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
response = client.delete("/api/v1.0/contacts/")
assert response.status_code == 405
assert models.Contact.objects.count() == 4
@@ -617,13 +603,14 @@ def test_api_contacts_delete_authenticated_public():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
contact = factories.BaseContactFactory()
response = APIClient().delete(
response = client.delete(
f"/api/v1.0/contacts/{contact.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
@@ -636,13 +623,13 @@ def test_api_contacts_delete_authenticated_owner():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
contact = factories.ContactFactory(owner=user)
response = APIClient().delete(
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/contacts/{contact.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 204
@@ -656,15 +643,15 @@ def test_api_contacts_delete_authenticated_profile():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
contact = factories.ContactFactory(owner=user, base=None)
user.profile_contact = contact
user.save()
response = APIClient().delete(
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/contacts/{contact.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 204
@@ -677,13 +664,13 @@ def test_api_contacts_delete_authenticated_other():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
contact = factories.ContactFactory()
response = APIClient().delete(
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/contacts/{contact.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
@@ -1,843 +0,0 @@
"""
Test team accesses API endpoints for users in People's core app.
"""
import random
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from .utils import OIDCToken
pytestmark = pytest.mark.django_db
def test_api_team_accesses_list_anonymous():
"""Anonymous users should not be allowed to list team accesses."""
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(2, team=team)
response = APIClient().get(f"/api/v1.0/teams/{team.id!s}/accesses/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(3, team=team)
# 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)
response = APIClient().get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
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, whatever their role in the team.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory()
user_access = models.TeamAccess.objects.create(team=team, user=user) # random role
access1, access2 = factories.TeamAccessFactory.create_batch(2, team=team)
# 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)
response = APIClient().get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 3
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": str(user.id),
"role": user_access.role,
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": str(access1.user.id),
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_team_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a team access.
"""
access = factories.TeamAccessFactory()
response = APIClient().get(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team)
response = APIClient().get(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Accesses related to another team should be excluded even if the user is related to it
for access in [
factories.TeamAccessFactory(),
factories.TeamAccessFactory(user=user),
]:
response = APIClient().get(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
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()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[user])
access = factories.TeamAccessFactory(team=team)
response = APIClient().get(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": str(access.user.id),
"role": access.role,
"abilities": access.get_abilities(user),
}
def test_api_team_accesses_create_anonymous():
"""Anonymous users should not be allowed to create team accesses."""
user = factories.UserFactory()
team = factories.TeamFactory()
response = APIClient().post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(user.id),
"team": str(team.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TeamAccess.objects.exists() is False
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
jwt_token = OIDCToken.for_user(user)
other_user = factories.UserFactory()
team = factories.TeamFactory()
response = APIClient().post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You are not allowed to manage accesses for this team."
}
assert not models.TeamAccess.objects.filter(user=other_user).exists()
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "member")])
other_user = factories.UserFactory()
api_client = APIClient()
for role in [role[0] for role in models.RoleChoices.choices]:
response = api_client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You are not allowed to manage accesses for this team."
}
assert not models.TeamAccess.objects.filter(user=other_user).exists()
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
api_client = APIClient()
# It should not be allowed to create an owner access
response = api_client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a team can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = api_client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 201
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_team_access.get_abilities(user),
"id": str(new_team_access.id),
"role": role,
"user": str(other_user.id),
}
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "owner")])
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = APIClient().post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 201
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_team_access.get_abilities(user),
"id": str(new_team_access.id),
"role": role,
"user": str(other_user.id),
}
def test_api_team_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a team access."""
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 401
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
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
jwt_token = OIDCToken.for_user(user)
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "member"]),
}
api_client = APIClient()
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = api_client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(team=team, user=other_user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(
team=team,
user=other_user,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": "owner",
}
api_client = APIClient()
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = api_client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "owner")])
factories.UserFactory()
access = factories.TeamAccessFactory(
team=team,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = api_client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
content_type="application/json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "member"])
api_client = APIClient()
response = api_client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
access.refresh_from_db()
assert access.role == "owner"
# Add another owner and it should now work
factories.TeamAccessFactory(team=team, role="owner")
response = api_client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
# Delete
def test_api_team_accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a team access."""
access = factories.TeamAccessFactory()
response = APIClient().delete(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert models.TeamAccess.objects.count() == 1
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
jwt_token = OIDCToken.for_user(user)
access = factories.TeamAccessFactory()
response = APIClient().delete(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = APIClient().delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 2
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "administrator")])
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()
response = APIClient().delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 204
assert models.TeamAccess.objects.count() == 1
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "owner")])
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()
response = APIClient().delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 204
assert models.TeamAccess.objects.count() == 1
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = APIClient().delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 2
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
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
assert models.TeamAccess.objects.count() == 1
response = APIClient().delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
+156 -92
View File
@@ -16,8 +16,6 @@ from core import factories, models
from core.api import serializers
from core.api.viewsets import Pagination
from .utils import OIDCToken
pytestmark = pytest.mark.django_db
@@ -37,11 +35,13 @@ def test_api_users_list_authenticated():
Authenticated users should be able to list all users.
"""
identity = factories.IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
client = APIClient()
client.force_login(identity.user)
factories.UserFactory.create_batch(2)
response = APIClient().get(
"/api/v1.0/users/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.get(
"/api/v1.0/users/",
)
assert response.status_code == HTTP_200_OK
assert len(response.json()["results"]) == 3
@@ -54,17 +54,18 @@ def test_api_users_authenticated_list_by_email():
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email)
jwt_token = OIDCToken.for_user(user)
dave = factories.IdentityFactory(email="david.bowman@work.com")
nicole = factories.IdentityFactory(email="nicole_foole@work.com")
frank = factories.IdentityFactory(email="frank_poole@work.com")
factories.IdentityFactory(email="heywood_floyd@work.com")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(email="david.bowman@work.com", is_main=True)
nicole = factories.IdentityFactory(email="nicole_foole@work.com", is_main=True)
frank = factories.IdentityFactory(email="frank_poole@work.com", is_main=True)
factories.IdentityFactory(email="heywood_floyd@work.com", is_main=True)
# Full query should work
response = APIClient().get(
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_200_OK
@@ -72,18 +73,14 @@ def test_api_users_authenticated_list_by_email():
assert user_ids[0] == str(dave.user.id)
# Partial query should work
response = APIClient().get(
"/api/v1.0/users/?q=fran", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
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)
# Result that matches a trigram twice ranks better than result that matches once
response = APIClient().get(
"/api/v1.0/users/?q=ole", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
response = client.get("/api/v1.0/users/?q=ole")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
@@ -91,13 +88,29 @@ def test_api_users_authenticated_list_by_email():
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
# Even with a low similarity threshold, query should match expected emails
response = APIClient().get(
"/api/v1.0/users/?q=ool", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
response = client.get("/api/v1.0/users/?q=ool")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
assert response.json()["results"] == [
{
"id": str(nicole.user.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),
},
{
"id": str(frank.user.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),
},
]
def test_api_users_authenticated_list_multiple_identities_single_user():
@@ -106,16 +119,17 @@ def test_api_users_authenticated_list_multiple_identities_single_user():
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email)
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
dave = factories.UserFactory()
factories.IdentityFactory(user=dave, email="david.bowman@work.com")
factories.IdentityFactory(user=dave, email="david.bowman@fun.fr")
# Full query should work
response = APIClient().get(
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_200_OK
@@ -131,40 +145,73 @@ def test_api_users_authenticated_list_multiple_identities_multiple_users():
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email)
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
dave = factories.UserFactory()
davina = factories.UserFactory()
prudence = factories.UserFactory()
factories.IdentityFactory(user=dave, email="david.bowman@work.com")
dave_identity = factories.IdentityFactory(
user=dave, email="david.bowman@work.com", is_main=True
)
factories.IdentityFactory(user=dave, email="babibou@ehehe.com")
factories.IdentityFactory(user=davina, email="davina.bowan@work.com")
factories.IdentityFactory(user=prudence, email="prudence.crandall@work.com")
davina_identity = factories.IdentityFactory(
user=factories.UserFactory(), email="davina.bowan@work.com"
)
prue_identity = factories.IdentityFactory(
user=factories.UserFactory(), email="prudence.crandall@work.com"
)
# Full query should work
response = APIClient().get(
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 3
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id), str(davina.id), str(prudence.id)]
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),
},
]
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)
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(email="DAVID.BOWMAN@INTENSEWORK.COM")
# Unaccented full address
response = APIClient().get(
response = client.get(
"/api/v1.0/users/?q=david.bowman@intensework.com",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_200_OK
@@ -172,8 +219,8 @@ def test_api_users_authenticated_list_uppercase_content():
assert user_ids == [str(dave.user.id)]
# Partial query
response = APIClient().get(
"/api/v1.0/users/?q=david", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.get(
"/api/v1.0/users/?q=david",
)
assert response.status_code == HTTP_200_OK
@@ -185,14 +232,15 @@ 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)
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(email="david.bowman@work.com")
# Full uppercase query
response = APIClient().get(
response = client.get(
"/api/v1.0/users/?q=DAVID.BOWMAN@WORK.COM",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_200_OK
@@ -200,8 +248,8 @@ def test_api_users_list_authenticated_capital_query():
assert user_ids == [str(dave.user.id)]
# Partial uppercase email
response = APIClient().get(
"/api/v1.0/users/?q=DAVID", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.get(
"/api/v1.0/users/?q=DAVID",
)
assert response.status_code == HTTP_200_OK
@@ -213,14 +261,15 @@ 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)
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
helene = factories.IdentityFactory(email="helene.bowman@work.com")
# Accented full query
response = APIClient().get(
response = client.get(
"/api/v1.0/users/?q=hélène.bowman@work.com",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_200_OK
@@ -228,8 +277,8 @@ def test_api_contacts_list_authenticated_accented_query():
assert user_ids == [str(helene.user.id)]
# Unaccented partial email
response = APIClient().get(
"/api/v1.0/users/?q=hélène", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.get(
"/api/v1.0/users/?q=hélène",
)
assert response.status_code == HTTP_200_OK
@@ -244,13 +293,15 @@ def test_api_users_list_pagination(
"""Pagination should work as expected."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
factories.UserFactory.create_batch(4)
# Get page 1
response = APIClient().get(
"/api/v1.0/users/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.get(
"/api/v1.0/users/",
)
assert response.status_code == HTTP_200_OK
@@ -262,8 +313,8 @@ def test_api_users_list_pagination(
assert content["previous"] is None
# Get page 2
response = APIClient().get(
"/api/v1.0/users/?page=2", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.get(
"/api/v1.0/users/?page=2",
)
assert response.status_code == HTTP_200_OK
@@ -289,9 +340,11 @@ 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()
identity = factories.IdentityFactory(is_main=True)
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
# Define profile contact
contact = factories.ContactFactory(owner=user)
@@ -299,19 +352,19 @@ def test_api_users_retrieve_me_authenticated():
user.save()
factories.UserFactory.create_batch(2)
response = APIClient().get(
"/api/v1.0/users/me/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.get(
"/api/v1.0/users/me/",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"id": str(user.id),
"email": str(user.email),
"name": str(identity.name),
"email": str(identity.email),
"language": user.language,
"timezone": str(user.timezone),
"is_device": False,
"is_staff": False,
"data": user.profile_contact.data,
}
@@ -334,10 +387,12 @@ def test_api_users_retrieve_authenticated_self():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
response = APIClient().get(
f"/api/v1.0/users/{user.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/users/{user.id!s}/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.json() == {"detail": 'Method "GET" not allowed.'}
@@ -349,12 +404,14 @@ def test_api_users_retrieve_authenticated_other():
limited information.
"""
identity = factories.IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
client = APIClient()
client.force_login(identity.user)
other_user = factories.UserFactory()
response = APIClient().get(
f"/api/v1.0/users/{other_user.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
response = client.get(
f"/api/v1.0/users/{other_user.id!s}/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.json() == {"detail": 'Method "GET" not allowed.'}
@@ -380,16 +437,17 @@ def test_api_users_create_authenticated():
"""Authenticated users should not be able to create users via the API."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
response = APIClient().post(
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/users/",
{
"language": "fr-fr",
"password": "mypassword",
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.json() == {"detail": 'Method "POST" not allowed.'}
@@ -427,18 +485,19 @@ def test_api_users_update_authenticated_self():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
response = APIClient().put(
response = client.put(
f"/api/v1.0/users/{user.id!s}/",
new_user_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_200_OK
@@ -454,17 +513,18 @@ 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()
jwt_token = OIDCToken.for_user(identity.user)
client = APIClient()
client.force_login(identity.user)
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
response = APIClient().put(
response = client.put(
f"/api/v1.0/users/{user.id!s}/",
new_user_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_403_FORBIDDEN
@@ -507,7 +567,9 @@ def test_api_users_patch_authenticated_self():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
@@ -515,11 +577,10 @@ def test_api_users_patch_authenticated_self():
)
for key, new_value in new_user_values.items():
response = APIClient().patch(
response = client.patch(
f"/api/v1.0/users/{user.id!s}/",
{key: new_value},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_200_OK
@@ -535,7 +596,9 @@ 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()
jwt_token = OIDCToken.for_user(identity.user)
client = APIClient()
client.force_login(identity.user)
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
@@ -544,11 +607,10 @@ def test_api_users_patch_authenticated_other():
)
for key, new_value in new_user_values.items():
response = APIClient().put(
response = client.put(
f"/api/v1.0/users/{user.id!s}/",
{key: new_value},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_403_FORBIDDEN
@@ -573,11 +635,12 @@ def test_api_users_delete_list_authenticated():
"""Authenticated users should not be allowed to delete a list of users."""
factories.UserFactory.create_batch(2)
identity = factories.IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
client = APIClient()
client.force_login(identity.user)
response = client.delete(
"/api/v1.0/users/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
"/api/v1.0/users/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
@@ -599,12 +662,12 @@ def test_api_users_delete_authenticated():
Authenticated users should not be allowed to delete a user other than themselves.
"""
identity = factories.IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
other_user = factories.UserFactory()
response = APIClient().delete(
f"/api/v1.0/users/{other_user.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
client = APIClient()
client.force_login(identity.user)
response = client.delete(f"/api/v1.0/users/{other_user.id!s}/")
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert models.User.objects.count() == 2
@@ -613,11 +676,12 @@ 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()
jwt_token = OIDCToken.for_user(identity.user)
response = APIClient().delete(
client = APIClient()
client.force_login(identity.user)
response = client.delete(
f"/api/v1.0/users/{identity.user.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
@@ -0,0 +1,201 @@
"""Unit tests for the `get_or_create_user` function."""
from django.core.exceptions import SuspiciousOperation
import pytest
from core import models
from core.authentication import OIDCAuthenticationBackend
from core.factories import IdentityFactory
pytestmark = pytest.mark.django_db
def test_authentication_getter_existing_user_no_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user matches the user's info sub, the user should be returned.
"""
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)
def get_userinfo_mocked(*args):
return {"sub": identity.sub}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(1):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
identity.refresh_from_db()
assert user == identity.user
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):
"""
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
def get_userinfo_mocked(*args):
return {
"sub": identity.sub,
"email": identity.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# Only 1 query because email and names have not changed
with django_assert_num_queries(1):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert models.User.objects.get() == user
@pytest.mark.parametrize(
"first_name, last_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
("John", "Doe", "jack.duy@example.com"),
("Jack", "Duy", "jack.duy@example.com"),
],
)
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.
"""
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
def get_userinfo_mocked(*args):
return {
"sub": identity.sub,
"email": email,
"first_name": first_name,
"last_name": last_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
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
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.
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": "123"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
identity = user.identities.get()
assert identity.sub == "123"
assert identity.email is None
assert user.email is None
assert user.password == "!"
assert models.User.objects.count() == 1
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.
"""
klass = OIDCAuthenticationBackend()
email = "people@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
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 models.User.objects.count() == 1
def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkeypatch):
"""The user's info doesn't contain a sub."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"test": "123",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(0), pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
@@ -52,7 +52,7 @@ def test_models_contacts_base_to_base():
def test_models_contacts_owner_base_unique():
"""Their should be only one contact deriving from a given base contact for a given owner."""
"""There should be only one contact deriving from a given base contact for a given owner."""
contact = factories.ContactFactory()
with pytest.raises(ValidationError) as excinfo:
@@ -70,19 +70,19 @@ def test_models_identities_is_main_switch():
def test_models_identities_email_not_required():
"""The "email" field can be blank."""
"""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."""
"""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."""
"""The 'email' field should be unique for a given user."""
email = factories.IdentityFactory()
with pytest.raises(
@@ -93,13 +93,13 @@ def test_models_identities_email_unique_same_user():
def test_models_identities_email_unique_different_users():
"""The "email" field should not be unique among 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."""
"""The 'email' field should be automatically normalized upon saving."""
email = factories.IdentityFactory()
email.email = "Thomas.Jefferson@Example.com"
email.save()
@@ -107,7 +107,7 @@ def test_models_identities_email_normalization():
def test_models_identities_ordering():
"""Identitys should be returned ordered by main status then by their email address."""
"""Identities should be returned ordered by main status then by their email address."""
user = factories.UserFactory()
factories.IdentityFactory.create_batch(5, user=user)
@@ -120,21 +120,21 @@ def test_models_identities_ordering():
def test_models_identities_sub_null():
"""The "sub" field should not be 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."""
"""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."""
"""The 'sub' field should be unique."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
with pytest.raises(ValidationError, match="Identity with this Sub already exists."):
@@ -142,7 +142,7 @@ def test_models_identities_sub_unique():
def test_models_identities_sub_max_length():
"""The sub field should be 255 characters maximum."""
"""The 'sub' field should be 255 characters maximum."""
factories.IdentityFactory(sub="a" * 255)
with pytest.raises(ValidationError) as excinfo:
factories.IdentityFactory(sub="a" * 256)
@@ -154,13 +154,13 @@ def test_models_identities_sub_max_length():
def test_models_identities_sub_special_characters():
"""The sub field should accept periods, dashes, +, @ and underscores."""
"""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."""
"""The 'sub' field should not accept spaces."""
with pytest.raises(ValidationError) as excinfo:
factories.IdentityFactory(sub="a b")
@@ -171,12 +171,12 @@ def test_models_identities_sub_spaces():
def test_models_identities_sub_upper_case():
"""The sub field should accept upper case characters."""
"""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."""
"""The 'sub' field should accept non ASCII letters."""
identity = factories.IdentityFactory(sub="rené")
assert identity.sub == "rené"
@@ -0,0 +1,46 @@
"""
Unit tests for the Invitation model
"""
from django.core.exceptions import ValidationError
import pytest
from core import factories
pytestmark = pytest.mark.django_db
def test_models_invitations_email_no_empty_mail():
"""The "email" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank"):
factories.InvitationFactory(email="")
def test_models_invitations_email_no_null_mail():
"""The "email" field is required."""
with pytest.raises(ValidationError, match="This field cannot be null"):
factories.InvitationFactory(email=None)
def test_models_invitations_team_required():
"""The "team" field is required."""
with pytest.raises(ValidationError, match="This field cannot be null"):
factories.InvitationFactory(team=None)
def test_models_invitations_team_should_be_team_instance():
"""The "team" field should be a team instance."""
with pytest.raises(ValueError, match='Invitation.team" must be a "Team" instance'):
factories.InvitationFactory(team="ee")
def test_models_invitations_role_required():
"""The "role" field is required."""
with pytest.raises(ValidationError, match="This field cannot be blank"):
factories.InvitationFactory(role="")
def test_models_invitations_role_among_choices():
"""The "role" field should be a valid choice."""
with pytest.raises(ValidationError, match="Value 'boss' is not a valid choice"):
factories.InvitationFactory(role="boss")
@@ -1,107 +0,0 @@
"""Unit tests for the `oidc_user_getter` function."""
import pytest
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework_simplejwt.tokens import AccessToken
from core import factories, models
pytestmark = pytest.mark.django_db
def test_models_oidc_user_getter_existing_user_no_email(django_assert_num_queries):
"""
When a valid token is passed, an existing user matching the token's sub should be returned.
"""
identity = factories.IdentityFactory()
factories.IdentityFactory(user=identity.user) # another identity for the user
token = AccessToken()
token["sub"] = str(identity.sub)
with django_assert_num_queries(1):
user = models.oidc_user_getter(token)
identity.refresh_from_db()
assert user == identity.user
def test_models_oidc_user_getter_existing_user_with_email(django_assert_num_queries):
"""
When the valid token passed contains an email and targets an existing user,
it should update the email on the identity but not on the user.
"""
identity = factories.IdentityFactory()
factories.IdentityFactory(user=identity.user) # another identity for the user
user_email = identity.user.email
assert models.User.objects.count() == 1
token = AccessToken()
token["sub"] = str(identity.sub)
# Only 1 query if the email has not changed
token["email"] = identity.email
with django_assert_num_queries(1):
user = models.oidc_user_getter(token)
# Additional update query if the email has changed
new_email = "people@example.com"
token["email"] = new_email
with django_assert_num_queries(2):
user = models.oidc_user_getter(token)
identity.refresh_from_db()
assert identity.email == new_email
assert models.User.objects.count() == 1
assert user == identity.user
assert user.email == user_email
def test_models_oidc_user_getter_new_user_no_email():
"""
When a valid token is passed, a user should be created if the sub
does not match any existing user.
"""
token = AccessToken()
token["sub"] = "123"
user = models.oidc_user_getter(token)
identity = user.identities.get()
assert identity.sub == "123"
assert identity.email is None
assert user.email is None
assert user.password == "!"
assert models.User.objects.count() == 1
def test_models_oidc_user_getter_new_user_with_email():
"""
When the valid token passed contains an email and a new user is created,
the email should be set on the user and on the identity.
"""
email = "people@example.com"
token = AccessToken()
token["sub"] = "123"
token["email"] = email
user = models.oidc_user_getter(token)
identity = user.identities.get()
assert identity.sub == "123"
assert identity.email == email
assert user.email == email
assert models.User.objects.count() == 1
def test_models_oidc_user_getter_invalid_token(django_assert_num_queries):
"""The token passed in argument should contain the configured user id claim."""
token = AccessToken()
with django_assert_num_queries(0), pytest.raises(
InvalidToken, match="Token contained no recognizable user identification"
):
models.oidc_user_getter(token)
assert models.User.objects.exists() is False
+2 -2
View File
@@ -77,7 +77,7 @@ 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."""
"""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)
@@ -91,7 +91,7 @@ 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."""
"""The 'email_user' method should fail if the user has no email address."""
user = factories.UserFactory()
with pytest.raises(models.Identity.DoesNotExist) as excinfo:
+3 -5
View File
@@ -8,8 +8,6 @@ from core import factories
from people.settings import REST_FRAMEWORK # pylint: disable=E0611
from .utils import OIDCToken
pytestmark = pytest.mark.django_db
@@ -19,9 +17,9 @@ def test_throttle():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
endpoint = "/api/v1.0/users/"
# loop to activate throttle protection
@@ -29,8 +27,8 @@ def test_throttle():
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["burst"].replace("/minute", "")
)
for _ in range(0, throttle_limit):
client.get(endpoint, HTTP_AUTHORIZATION=f"Bearer {jwt_token}")
client.get(endpoint)
# this call should err
response = client.get(endpoint, HTTP_AUTHORIZATION=f"Bearer {jwt_token}")
response = client.get(endpoint)
assert response.status_code == 429 # too many requests
-25
View File
@@ -1,25 +0,0 @@
"""Utils for tests in the People core application"""
from rest_framework_simplejwt.tokens import AccessToken
class OIDCToken(AccessToken):
"""Set payload on token from user/contact/email"""
@classmethod
def for_user(cls, user):
"""Returns an authorization token for the given user for testing."""
identity = user.identities.filter(is_main=True).first()
token = cls()
token["first_name"] = (
user.profile_contact.short_name if user.profile_contact else "David"
)
token["last_name"] = (
" ".join(user.profile_contact.full_name.split()[1:])
if user.profile_contact
else "Bowman"
)
token["sub"] = str(identity.sub)
token["email"] = user.email
return token
-10
View File
@@ -1,10 +0,0 @@
"""Tokens for Magnify's core app."""
from rest_framework_simplejwt.settings import api_settings
from rest_framework_simplejwt.tokens import Token
class BearerToken(Token):
"""Bearer token as emitted by Keycloak OIDC for example."""
token_type = "Bearer" # noqa: S105
lifetime = api_settings.ACCESS_TOKEN_LIFETIME
@@ -11,10 +11,14 @@ from django import db
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from faker import Faker
from core import models
from demo import defaults
fake = Faker()
logger = logging.getLogger("people.commands.demo.create_demo")
@@ -134,6 +138,7 @@ def create_demo(stdout):
sub=uuid4(),
email=f"identity{i:d}{user_email:s}",
is_main=(i == 0),
name=fake.name(),
)
)
queue.flush()
+2
View File
@@ -2,6 +2,7 @@
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
@@ -26,6 +27,7 @@ urlpatterns = [
include(
[
*router.urls,
*oidc_urls,
re_path(
r"^teams/(?P<team_id>[0-9a-z-]*)/",
include(team_related_router.urls),
+103 -37
View File
@@ -1,5 +1,5 @@
"""
Django settings for People project.
Django's settings for People project.
Generated by 'django-admin startproject' using Django 3.1.5.
@@ -175,13 +175,15 @@ class Base(Configuration):
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
]
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"core.authentication.OIDCAuthenticationBackend",
]
# Django applications from the highest priority to the lowest
# Django's applications from the highest priority to the lowest
INSTALLED_APPS = [
# People
"core",
@@ -202,6 +204,8 @@ class Base(Configuration):
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.staticfiles",
# OIDC third party
"mozilla_django_oidc",
]
# Cache
@@ -211,7 +215,8 @@ class Base(Configuration):
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"core.authentication.DelegatedJWTAuthentication",
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser",
@@ -223,7 +228,18 @@ class Base(Configuration):
"PAGE_SIZE": 20,
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_THROTTLE_RATES": {"sustained": "150/hour", "burst": "20/minute"},
"DEFAULT_THROTTLE_RATES": {
"sustained": values.Value(
default="150/hour",
environ_name="SUSTAINED_THROTTLE_RATES",
environ_prefix=None,
),
"burst": values.Value(
default="20/minute",
environ_name="BURST_THROTTLE_RATES",
environ_prefix=None,
),
},
}
SPECTACULAR_SETTINGS = {
@@ -242,34 +258,6 @@ class Base(Configuration):
"REDOC_DIST": "SIDECAR",
}
# Simple JWT
SIMPLE_JWT = {
"ALGORITHM": values.Value(
"RS256", environ_name="SIMPLE_JWT_ALGORITHM", environ_prefix=None
),
"JWK_URL": values.Value(
None, environ_name="SIMPLE_JWT_JWK_URL", environ_prefix=None
),
"SIGNING_KEY": values.Value(
None, environ_name="SIMPLE_JWT_SIGNING_KEY", environ_prefix=None
),
"VERIFYING_KEY": values.Value(
None, environ_name="SIMPLE_JWT_VERIFYING_KEY", environ_prefix=None
),
"AUTH_HEADER_TYPES": ("Bearer",),
"AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
"TOKEN_TYPE_CLAIM": "typ",
"USER_ID_FIELD": "sub",
"USER_ID_CLAIM": "sub",
"AUTH_TOKEN_CLASSES": ("core.tokens.BearerToken",),
}
JWT_USER_GETTER = values.Value(
"core.models.oidc_user_getter",
environ_name="PEOPLE_JWT_USER_GETTER",
environ_prefix=None,
)
# Mail
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
EMAIL_HOST = values.Value(None)
@@ -280,6 +268,7 @@ class Base(Configuration):
EMAIL_FROM = values.Value("from@example.com")
AUTH_USER_MODEL = "core.User"
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
# CORS
CORS_ALLOW_CREDENTIALS = True
@@ -299,6 +288,68 @@ class Base(Configuration):
CELERY_BROKER_URL = values.Value("redis://redis:6379/0")
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({})
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_COOKIE_AGE = 60 * 60 * 12 # 12 hours to match Agent Connect
# OIDC - Authorization Code Flow
OIDC_CREATE_USER = values.BooleanValue(
default=True,
environ_name="OIDC_CREATE_USER",
)
OIDC_RP_SIGN_ALGO = values.Value(
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
)
OIDC_RP_CLIENT_ID = values.Value(
"people", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None
)
OIDC_RP_CLIENT_SECRET = values.Value(
environ_name="OIDC_RP_CLIENT_SECRET",
environ_prefix=None,
)
OIDC_OP_JWKS_ENDPOINT = values.Value(
environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None
)
OIDC_OP_AUTHORIZATION_ENDPOINT = values.Value(
environ_name="OIDC_OP_AUTHORIZATION_ENDPOINT", environ_prefix=None
)
OIDC_OP_TOKEN_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_TOKEN_ENDPOINT", environ_prefix=None
)
OIDC_OP_USER_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
)
OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue(
{}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None
)
OIDC_RP_SCOPES = values.Value(
"openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None
)
LOGIN_REDIRECT_URL = values.Value(
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
)
LOGIN_REDIRECT_URL_FAILURE = values.Value(
None, environ_name="LOGIN_REDIRECT_URL_FAILURE", environ_prefix=None
)
LOGOUT_REDIRECT_URL = values.Value(
None, environ_name="LOGOUT_REDIRECT_URL", environ_prefix=None
)
OIDC_USE_NONCE = values.BooleanValue(
default=True, environ_name="OIDC_USE_NONCE", environ_prefix=None
)
OIDC_REDIRECT_REQUIRE_HTTPS = values.BooleanValue(
default=False, environ_name="OIDC_REDIRECT_REQUIRE_HTTPS", environ_prefix=None
)
OIDC_REDIRECT_ALLOWED_HOSTS = values.ListValue(
default=[], environ_name="OIDC_REDIRECT_ALLOWED_HOSTS", environ_prefix=None
)
USER_OIDC_FIELDS_TO_NAME = values.ListValue(
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_NAME",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -379,7 +430,7 @@ class Development(Base):
ALLOWED_HOSTS = ["*"]
CORS_ALLOW_ALL_ORIGINS = True
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072"]
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072", "http://localhost:3000"]
DEBUG = True
SESSION_COOKIE_NAME = "people_sessionid"
@@ -394,10 +445,6 @@ class Development(Base):
class Test(Base):
"""Test environment settings"""
SIMPLE_JWT = {
"USER_ID_FIELD": "sub",
"USER_ID_CLAIM": "sub",
}
LOGGING = values.DictValue(
{
"version": 1,
@@ -521,3 +568,22 @@ class PreProduction(Production):
nota bene: it should inherit from the Production environment.
"""
class Demo(Production):
"""
Demonstration environment settings
nota bene: it should inherit from the Production environment.
"""
CSRF_TRUSTED_ORIGINS = ["http://localhost:8082"]
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
+10 -10
View File
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.34.39",
"boto3==1.34.51",
"Brotli==1.1.0",
"celery[redis]==5.3.6",
"django-configurations==2.5",
@@ -35,10 +35,9 @@ dependencies = [
"django-storages==1.14.2",
"django-timezone-field>=5.1",
"django==5.0.2",
"djangorestframework-simplejwt[crypto]==5.3.1",
"djangorestframework==3.14.0",
"drf_spectacular==0.27.1",
"dockerflow==2024.1.0",
"dockerflow==2024.2.0",
"easy_thumbnails==2.8.5",
"factory_boy==3.3.0",
"gunicorn==21.2.0",
@@ -47,9 +46,10 @@ dependencies = [
"psycopg[binary]==3.1.18",
"PyJWT==2.8.0",
"requests==2.31.0",
"sentry-sdk==1.40.3",
"sentry-sdk==1.40.6",
"url-normalize==1.4.3",
"whitenoise==6.6.0",
"mozilla-django-oidc==4.0.0",
]
[project.urls]
@@ -63,18 +63,18 @@ dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.2.1",
"ipdb==0.13.13",
"ipython==8.21.0",
"ipython==8.22.1",
"pyfakefs==5.3.5",
"pylint-django==2.5.5",
"pylint==3.0.3",
"pylint==3.1.0",
"pytest-cov==4.1.0",
"pytest-django==4.8.0",
"pytest==8.0.0",
"pytest==8.0.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.5.0",
"responses==0.24.1",
"ruff==0.2.1",
"types-requests==2.31.0.20240125",
"responses==0.25.0",
"ruff==0.2.2",
"types-requests==2.31.0.20240218",
]
[tool.setuptools]
-4
View File
@@ -1,5 +1 @@
NEXT_PUBLIC_API_URL=http://localhost:8071/api/v1.0/
NEXT_PUBLIC_KEYCLOAK_URL=http://localhost:8080/
NEXT_PUBLIC_KEYCLOAK_REALM=people
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=people-front
NEXT_PUBLIC_KEYCLOAK_LOGIN=true
+71 -17
View File
@@ -3,6 +3,8 @@ const config = {
default: {
theme: {
colors: {
'card-border': '#DDDDDD',
'primary-bg': '#FAFAFA',
'primary-100': '#EDF5FA',
'primary-150': '#E5EEFA',
'info-150': '#E5EEFA',
@@ -76,6 +78,7 @@ const config = {
'forms-labelledbox': {
'label-color': {
small: 'var(--c--theme--colors--primary-500)',
'small-disabled': 'var(--c--theme--colors--greyscale-400)',
big: {
disabled: 'var(--c--theme--colors--greyscale-400)',
},
@@ -83,6 +86,8 @@ const config = {
},
'forms-select': {
'border-color': 'var(--c--theme--colors--primary-500)',
'border-color-disabled-hover':
'var(--c--theme--colors--greyscale-200)',
'border-radius': {
hover: 'var(--c--components--forms-select--border-radius)',
focus: 'var(--c--components--forms-select--border-radius)',
@@ -175,6 +180,7 @@ const config = {
dsfr: {
theme: {
colors: {
'card-border': '#DDDDDD',
'primary-text': '#000091',
'primary-100': '#f5f5fe',
'primary-200': '#ececfe',
@@ -195,10 +201,11 @@ const config = {
'secondary-700': '#3b2424',
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-000': '#cecece',
'greyscale-100': '#f6f6f6',
'greyscale-200': '#eeeeee',
'greyscale-300': '#e5e5e5',
'greyscale-text': '#303C4B',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
@@ -258,28 +265,75 @@ const config = {
'border-radius': '0',
},
button: {
'border-radius': '2px',
'border-radius': '4px',
primary: {
background: {
color: 'var(--c--theme--colors--primary-text)',
'color-hover': 'var(--c--theme--colors--primary-700)',
'color-active': 'var(--c--theme--colors--primary-900)',
},
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
},
secondary: {
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-active': 'var(--c--theme--colors--primary-200)',
},
border: {
'color-hover': 'var(--c--theme--colors--primary-300)',
},
color: 'var(--c--theme--colors--primary-text)',
},
},
datagrid: {
header: {
color: 'var(--c--theme--colors--primary-text)',
size: 'var(--c--theme--font--sizes--s)',
},
body: {
'background-color': 'transparent',
'background-color-hover': '#F4F4FD',
},
pagination: {
'background-color': 'transparent',
'background-color-active': 'var(--c--theme--colors--primary-300)',
},
},
'forms-checkbox': {
'border-radius': '0',
},
'forms-switch': {
'handle-border-radius': '2px',
'rail-border-radius': '4px',
},
'forms-input': {
'border-radius': '0',
},
'forms-select': {
'border-radius': '0',
},
'forms-datepicker': {
'border-radius': '0',
},
'forms-textarea': {
'forms-fileuploader': {
'border-radius': '0',
},
'forms-fileuploader': {
'forms-input': {
'border-radius': '4px',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
},
'forms-labelledbox': {
'label-color': {
big: 'var(--c--theme--colors--primary-text)',
},
},
'forms-select': {
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
},
'forms-switch': {
'handle-border-radius': '2px',
'rail-border-radius': '4px',
},
'forms-textarea': {
'border-radius': '0',
},
},
+7
View File
@@ -1,6 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
images: {
unoptimized: true,
},
compiler: {
// Enables the styled-components SWC transform
styledComponents: true,
},
webpack(config) {
// Grab the existing rule that handles SVG imports
const fileLoaderRule = config.module.rules.find((rule) =>
+11 -10
View File
@@ -16,30 +16,31 @@
},
"dependencies": {
"@openfun/cunningham-react": "2.4.0",
"@tanstack/react-query": "5.18.1",
"i18next": "23.7.16",
"next": "14.1.0",
"@tanstack/react-query": "5.24.8",
"i18next": "23.10.0",
"luxon": "3.4.4",
"next": "14.1.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-i18next": "14.0.0",
"react-i18next": "14.0.5",
"styled-components": "6.1.8",
"zustand": "4.5.0"
"zustand": "4.5.2"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.18.1",
"@testing-library/jest-dom": "6.4.1",
"@tanstack/react-query-devtools": "5.24.8",
"@testing-library/jest-dom": "6.4.2",
"@testing-library/react": "14.2.1",
"@types/jest": "29.5.12",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.2.53",
"@types/react": "18.2.61",
"@types/react-dom": "18.2.18",
"dotenv": "16.4.1",
"dotenv": "16.4.5",
"eslint-config-people": "*",
"fetch-mock": "9.11.0",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"keycloak-js": "23.0.6",
"node-fetch": "2.7.0",
"prettier": "3.2.5",
"stylelint": "16.2.1",

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -3,16 +3,16 @@ import { render, screen } from '@testing-library/react';
import { AppWrapper } from '@/tests/utils';
import Page from '../page';
import Page from '../pages';
describe('Page', () => {
it('checks Page rendering', () => {
render(<Page />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
'Hello Desk !',
);
expect(
screen.getByRole('button', {
name: /Create a new team/i,
}),
).toBeInTheDocument();
});
});
@@ -0,0 +1,17 @@
interface IAPIError {
status: number;
cause?: string[];
}
export class APIError extends Error implements IAPIError {
public status: number;
public cause?: string[];
constructor(message: string, { status, cause }: IAPIError) {
super(message);
this.name = 'APIError';
this.status = status;
this.cause = cause;
}
}
@@ -1,8 +1,7 @@
import fetchMock from 'fetch-mock';
import { useAuthStore } from '@/features/';
import { fetchAPI } from '../fetchApi';
import { fetchAPI } from '@/api';
import { useAuthStore } from '@/features/auth';
describe('fetchAPI', () => {
beforeEach(() => {
@@ -20,29 +19,40 @@ describe('fetchAPI', () => {
);
});
it('adds the BEARER automatically', () => {
useAuthStore.setState({ token: 'my-token' });
it('adds the credentials automatically', () => {
fetchMock.mock('http://some.api.url/api/v1.0/some/url', 200);
void fetchAPI('some/url', { body: 'some body' });
expect(fetchMock.lastOptions()).toEqual({
body: 'some body',
credentials: 'include',
headers: {
Authorization: 'Bearer my-token',
'Content-Type': 'application/json',
},
});
});
it('logout if 401 response', async () => {
useAuthStore.setState({ token: 'my-token' });
const mockReplace = jest.fn();
Object.defineProperty(window, 'location', {
configurable: true,
enumerable: true,
value: {
replace: mockReplace,
},
});
useAuthStore.setState({ userData: { email: 'test@test.com' } });
fetchMock.mock('http://some.api.url/api/v1.0/some/url', 401);
await fetchAPI('some/url');
expect(useAuthStore.getState().token).toBeNull();
expect(useAuthStore.getState().userData).toBeUndefined();
expect(mockReplace).toHaveBeenCalledWith(
'http://some.api.url/api/v1.0/authenticate/',
);
});
});
+26 -4
View File
@@ -1,19 +1,41 @@
import { useAuthStore } from '@/features/';
import { login, useAuthStore } from '@/features/auth';
/**
* Retrieves the CSRF token from the document's cookies.
*
* @returns {string|null} The CSRF token if found in the cookies, or null if not present.
*/
function getCSRFToken() {
return document.cookie
.split(';')
.filter((cookie) => cookie.trim().startsWith('csrftoken='))
.map((cookie) => cookie.split('=')[1])
.pop();
}
export const fetchAPI = async (input: string, init?: RequestInit) => {
const apiUrl = `${process.env.NEXT_PUBLIC_API_URL}${input}`;
const { token, logout } = useAuthStore.getState();
const { logout } = useAuthStore.getState();
const csrfToken = getCSRFToken();
const response = await fetch(apiUrl, {
...init,
credentials: 'include',
headers: {
...init?.headers,
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(csrfToken && { 'X-CSRFToken': csrfToken }),
},
});
response.status === 401 && logout();
// todo - handle 401, redirect to login screen
// todo - please have a look to this documentation page https://mozilla-django-oidc.readthedocs.io/en/stable/xhr.html
if (response.status === 401) {
logout();
// Fix - force re-logging the user, will be refactored
login();
}
return response;
};
+2
View File
@@ -1,2 +1,4 @@
export * from './APIError';
export * from './fetchApi';
export * from './types';
export * from './utils';
+17
View File
@@ -0,0 +1,17 @@
export const errorCauses = async (response: Response) => {
const errorsBody = (await response.json()) as Record<
string,
string | string[]
> | null;
const causes = errorsBody
? Object.entries(errorsBody)
.map(([, value]) => value)
.flat()
: undefined;
return {
status: response.status,
cause: causes,
};
};
@@ -1,25 +0,0 @@
import { Box } from '@/components';
import { HEADER_HEIGHT, Header, Menu } from '@/features/';
export default function InnerLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<Box as="main" $direction="column" $height="100vh" $css="overflow:hidden;">
<Header />
<Box $css="flex: 1;">
<Menu />
<Box
$direction="column"
$height={`calc(100vh - ${HEADER_HEIGHT})`}
$width="100%"
$css="overflow: auto;"
>
{children}
</Box>
</Box>
</Box>
);
}
@@ -1,7 +0,0 @@
'use client';
import { Box } from '@/components';
export default function Contacts() {
return <Box>Contacts</Box>;
}
@@ -1,7 +0,0 @@
'use client';
import { Box } from '@/components';
export default function Favorite() {
return <Box>Favorite</Box>;
}
@@ -1,6 +0,0 @@
@import url('../cunningham/cunningham-style.css');
body {
margin: 0;
padding: 0;
}
@@ -1,7 +0,0 @@
'use client';
import { Box } from '@/components';
export default function Groups() {
return <Box>Groups</Box>;
}
@@ -1,7 +0,0 @@
'use client';
import { Box } from '@/components';
export default function Help() {
return <Box>Help</Box>;
}
-37
View File
@@ -1,37 +0,0 @@
'use client';
import { CunninghamProvider } from '@openfun/cunningham-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useCunninghamTheme } from '@/cunningham';
import { Auth } from '@/features/auth/Auth';
import '@/i18n/initI18n';
import InnerLayout from './InnerLayout';
import './globals.css';
const queryClient = new QueryClient();
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const { theme } = useCunninghamTheme();
return (
<html lang="en">
<body suppressHydrationWarning={process.env.NODE_ENV === 'development'}>
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools />
<CunninghamProvider theme={theme}>
<Auth>
<InnerLayout>{children}</InnerLayout>
</Auth>
</CunninghamProvider>
</QueryClientProvider>
</body>
</html>
);
}
-17
View File
@@ -1,17 +0,0 @@
'use client';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { Teams } from '@/features';
export default function Home() {
const { t } = useTranslation();
return (
<Box $direction="column" className="p-b">
<h1>{t('Hello Desk !')}</h1>
<Teams />
</Box>
);
}
@@ -1,7 +0,0 @@
'use client';
import { Box } from '@/components';
export default function Recent() {
return <Box>Recent</Box>;
}

Before

Width:  |  Height:  |  Size: 1020 B

After

Width:  |  Height:  |  Size: 1020 B

@@ -0,0 +1,29 @@
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_179_19260)">
<path
d="M12.6406 26.02C14.5606 26.06 16.3406 27.02 17.5406 28.7C19.0006 30.76 21.4206 32 24.0006 32C26.5806 32 29.0006 30.76 30.4606 28.68C31.6606 27 33.4406 26.04 35.3606 26C33.9206 23.56 28.1606 22 24.0006 22C19.8606 22 14.0806 23.56 12.6406 26.02Z"
fill="currentColor"
/>
<path
d="M8 26C11.32 26 14 23.32 14 20C14 16.68 11.32 14 8 14C4.68 14 2 16.68 2 20C2 23.32 4.68 26 8 26Z"
fill="currentColor"
/>
<path
d="M40 26C43.32 26 46 23.32 46 20C46 16.68 43.32 14 40 14C36.68 14 34 16.68 34 20C34 23.32 36.68 26 40 26Z"
fill="currentColor"
/>
<path
d="M24 20C27.32 20 30 17.32 30 14C30 10.68 27.32 8 24 8C20.68 8 18 10.68 18 14C18 17.32 20.68 20 24 20Z"
fill="currentColor"
/>
<path
d="M42 28H35.46C33.92 28 32.76 28.9 32.1 29.84C32.02 29.96 29.38 34 24 34C21.14 34 17.94 32.72 15.9 29.84C15.12 28.74 13.9 28 12.54 28H6C3.8 28 2 29.8 2 32V40H16V35.48C18.3 37.08 21.08 38 24 38C26.92 38 29.7 37.08 32 35.48V40H46V32C46 29.8 44.2 28 42 28Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_179_19260">
<rect width="48" height="48" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,6 @@
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M20 0C8.96 0 0 8.96 0 20C0 31.04 8.96 40 20 40C31.04 40 40 31.04 40 20C40 8.96 31.04 0 20 0ZM20 6C23.32 6 26 8.68 26 12C26 15.32 23.32 18 20 18C16.68 18 14 15.32 14 12C14 8.68 16.68 6 20 6ZM20 34.4C15 34.4 10.58 31.84 8 27.96C8.06 23.98 16 21.8 20 21.8C23.98 21.8 31.94 23.98 32 27.96C29.42 31.84 25 34.4 20 34.4Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 439 B

+21 -1
View File
@@ -1,7 +1,9 @@
import { ReactHTML } from 'react';
import { ComponentPropsWithRef, ReactHTML } from 'react';
import styled from 'styled-components';
import { CSSProperties } from 'styled-components/dist/types';
import { hideEffect, showEffect } from './Effect';
export interface BoxProps {
as?: keyof ReactHTML;
$align?: CSSProperties['alignItems'];
@@ -10,17 +12,24 @@ export interface BoxProps {
$css?: string;
$direction?: CSSProperties['flexDirection'];
$display?: CSSProperties['display'];
$effect?: 'show' | 'hide';
$flex?: boolean;
$gap?: CSSProperties['gap'];
$height?: CSSProperties['height'];
$justify?: CSSProperties['justifyContent'];
$overflow?: CSSProperties['overflow'];
$position?: CSSProperties['position'];
$radius?: CSSProperties['borderRadius'];
$width?: CSSProperties['width'];
$maxWidth?: CSSProperties['maxWidth'];
$minWidth?: CSSProperties['minWidth'];
}
export type BoxType = ComponentPropsWithRef<typeof Box>;
export const Box = styled('div')<BoxProps>`
display: flex;
flex-direction: column;
${({ $align }) => $align && `align-items: ${$align};`}
${({ $background }) => $background && `background: ${$background};`}
${({ $color }) => $color && `color: ${$color};`}
@@ -30,8 +39,19 @@ export const Box = styled('div')<BoxProps>`
${({ $gap }) => $gap && `gap: ${$gap};`}
${({ $height }) => $height && `height: ${$height};`}
${({ $justify }) => $justify && `justify-content: ${$justify};`}
${({ $overflow }) => $overflow && `overflow: ${$overflow};`}
${({ $position }) => $position && `position: ${$position};`}
${({ $radius }) => $radius && `border-radius: ${$radius};`}
${({ $width }) => $width && `width: ${$width};`}
${({ $maxWidth }) => $maxWidth && `max-width: ${$maxWidth};`}
${({ $minWidth }) => $minWidth && `min-width: ${$minWidth};`}
${({ $css }) => $css && `${$css};`}
${({ $effect }) => {
switch ($effect) {
case 'show':
return showEffect;
case 'hide':
return hideEffect;
}
}}
`;
@@ -0,0 +1,28 @@
import { PropsWithChildren } from 'react';
import { useCunninghamTheme } from '@/cunningham';
import { Box, BoxType } from '.';
export const Card = ({
children,
$css,
...props
}: PropsWithChildren<BoxType>) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$background="white"
$radius="4px"
$css={`
box-shadow: 2px 2px 5px ${colorsTokens()['primary-300']}88;
border: 1px solid ${colorsTokens()['card-border']};
${$css}
`}
{...props}
>
{children}
</Box>
);
};
@@ -0,0 +1,20 @@
import { css, keyframes } from 'styled-components';
const show = keyframes`
0% { transform: scaleY(0); opacity: 0; }
100% { transform: scaleY(1); opacity: 1;}
`;
const hide = keyframes`
0% { transform: scaleY(1); opacity: 1;}
100% { display:none; transform: scaleY(0); opacity: 0 }
`;
export const showEffect = css`
animation: ${show} 0.3s ease-in-out;
`;
export const hideEffect = css`
animation: ${hide} 0.3s ease-in-out;
animation-fill-mode: forwards;
`;
@@ -0,0 +1,55 @@
import { PropsWithChildren, useEffect, useRef } from 'react';
import { Box, BoxType } from '@/components';
interface InfiniteScrollProps extends BoxType {
hasMore: boolean;
isLoading: boolean;
next: () => void;
scrollContainer: HTMLElement | null;
}
export const InfiniteScroll = ({
children,
hasMore,
isLoading,
next,
scrollContainer,
...boxProps
}: PropsWithChildren<InfiniteScrollProps>) => {
const timeout = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (!scrollContainer) {
return;
}
const nextHandle = () => {
if (!hasMore || isLoading) {
return;
}
// To not wait until the end of the scroll to load more data
const heightFromBottom = 150;
const { scrollTop, clientHeight, scrollHeight } = scrollContainer;
if (scrollTop + clientHeight >= scrollHeight - heightFromBottom) {
next();
}
};
const handleScroll = () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(nextHandle, 50);
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
};
}, [hasMore, isLoading, next, scrollContainer]);
return <Box {...boxProps}>{children}</Box>;
};
@@ -0,0 +1,11 @@
import Link from 'next/link';
import styled from 'styled-components';
export const StyledLink = styled(Link)`
text-decoration: none;
color: #ffffff33;
&[aria-current='page'] {
color: #ffffff;
}
display: flex;
`;
@@ -14,6 +14,7 @@ export interface TextProps extends BoxProps {
'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
>;
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
// eslint-disable-next-line @typescript-eslint/ban-types
$size?: TextSizes | (string & {});
$theme?:
@@ -37,7 +38,10 @@ export interface TextProps extends BoxProps {
| '900';
}
export type TextType = ComponentPropsWithRef<typeof Text>;
export const TextStyled = styled(Box)<TextProps>`
${({ $textAlign }) => $textAlign && `text-align: ${$textAlign};`}
${({ $weight }) => $weight && `font-weight: ${$weight};`}
${({ $size }) =>
$size &&
@@ -53,7 +57,7 @@ export const Text = ({
return (
<TextStyled
as="span"
$theme="primary"
$theme="greyscale"
$variation="text"
{...props}
></TextStyled>
@@ -0,0 +1,62 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, TextType } from '@/components';
interface TextErrorsProps extends TextType {
causes?: string[];
defaultMessage?: string;
duration?: number;
}
/**
* Display a list of errors
* @param causes - List of causes
* @param defaultMessage - Default message if no causes
* @param duration -- Duration in milliseconds of the error message
* - Set to `0` or `undefined` for unlimited duration
* @param textProps - Text props
*/
export const TextErrors = ({
causes,
defaultMessage,
duration = 6000, // 6 seconds
...textProps
}: TextErrorsProps) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(true);
if (duration) {
setTimeout(() => {
setVisible(false);
}, duration);
}
return (
<Box $effect={visible ? 'show' : 'hide'}>
{causes &&
causes.map((cause, i) => (
<Text
key={`causes-${i}`}
className="mt-s"
$theme="danger"
$textAlign="center"
{...textProps}
>
{cause}
</Text>
))}
{!causes && (
<Text
className="mt-s"
$theme="danger"
$textAlign="center"
{...textProps}
>
{defaultMessage || t('Something bad happens, please retry.')}
</Text>
)}
</Box>
);
};
@@ -1,2 +1,5 @@
export * from './Box';
export * from './Text';
export * from './Link';
export * from './TextErrors';
export * from './Card';
@@ -25,7 +25,4 @@
--c--components--forms-select--value-color--disabled: var(
--c--theme--colors--greyscale-400
);
--c--components--forms-labelledbox--label-color--big: var(
--c--theme--colors--primary-500
);
}
@@ -17,11 +17,11 @@
}
.labelled-box label {
color: var(--c--theme--colors--primary-500);
color: var(--c--theme--colors--primary-text);
}
.labelled-box--disabled label {
color: var(--c--components--forms-labelledbox--label-color--small--disabled);
color: var(--c--components--forms-labelledbox--label-color--small-disabled);
}
.c__field :not(.c__textarea__wrapper, div) .labelled-box label.placeholder {
@@ -53,7 +53,7 @@
.c__input__wrapper:hover,
.c__textarea__wrapper:hover {
box-shadow: var(--c--theme--colors--primary-500) 0 0 0 2px;
box-shadow: var(--c--components--forms-input--box-shadow-color) 0 0 0 2px;
}
.c__textarea__wrapper--disabled:hover,
@@ -120,7 +120,16 @@ input:-webkit-autofill:focus {
}
.c__select:not(.c__select--disabled) .c__select__wrapper:hover {
box-shadow: var(--c--theme--colors--primary-500) 0 0 0 2px;
box-shadow: var(--c--components--forms-input--box-shadow-color) 0 0 0 2px;
}
.c__select__wrapper:hover {
border-radius: var(--c--components--forms-select--border-radius-hover);
border-color: var(--c--components--forms-select--border-color-hover);
}
.c__select--disabled .c__select__wrapper:hover {
border-color: var(--c--components--forms-select--border-color-disabled-hover);
}
.c__select__menu__item {
@@ -135,7 +144,7 @@ input:-webkit-autofill:focus {
}
.c__select__wrapper:focus-within .labelled-box--disabled label {
color: var(--c--components--forms-labelledbox--label-color--small--disabled);
color: var(--c--components--forms-labelledbox--label-color--small-disabled);
}
.c__select__wrapper .labelled-box {
@@ -173,28 +182,60 @@ input:-webkit-autofill:focus {
/**
* DataGrid
*/
.c__datagrid > table td {
max-width: 10rem;
white-space: normal;
color: var(--c--components--datagrid--cell--color);
font-size: var(--c--components--datagrid--cell--size);
.c__datagrid__table__container {
overflow: auto;
}
.c__datagrid > table th .c__datagrid__header {
color: var(--c--theme--colors--primary-500);
.c__datagrid__table__container > table th .c__datagrid__header {
color: var(--c--components--datagrid--header--color);
font-weight: var(--c--components--datagrid--header--weight);
font-size: var(--c--components--datagrid--header--size);
padding-block: 2rem;
}
.c__datagrid > table tbody tr {
border: 1px var(--c--theme--colors--primary-100) solid;
.c__datagrid__table__container > table tbody tr {
border: none;
border-top: 1px var(--c--theme--colors--greyscale-300) solid;
border-bottom: 1px var(--c--theme--colors--greyscale-300) solid;
}
.c__datagrid__table__container > table tbody {
background-color: var(--c--components--datagrid--body--background-color);
}
.c__datagrid__table__container > table tbody tr:hover {
background-color: var(
--c--components--datagrid--body--background-color-hover
);
}
.c__datagrid__table__container > table th:first-child,
.c__datagrid__table__container > table td:first-child {
padding-left: 2rem;
}
.c__datagrid > .c__pagination {
padding-top: 1rem;
padding-right: 1rem;
justify-content: flex-end;
}
.c__pagination__list {
gap: 3px;
border-radius: 4px;
background: var(--c--components--datagrid--pagination--background-color);
}
.c__pagination__list .c__button--tertiary.c__button--active {
background-color: var(
--c--components--datagrid--pagination--background-color-active
);
color: var(--c--theme--colors--greyscale-800);
}
.c__pagination__list .c__button--tertiary:disabled {
display: none;
}
@media (width <= 380px) {
.c__datagrid > .c__pagination {
flex-direction: column;
@@ -272,6 +313,13 @@ input:-webkit-autofill:focus {
color: var(--c--components--button--primary--color);
}
.c__button--primary:hover {
background-color: var(
--c--components--button--primary--background--color-hover
);
color: var(--c--components--button--primary--color-hover);
}
.c__button--primary:active,
.c__button--primary.c__button--active {
background-color: var(
@@ -318,6 +366,7 @@ input:-webkit-autofill:focus {
--c--components--button--secondary--background--color-hover
);
color: var(--c--components--button--secondary--color-hover);
border: 1px solid var(--c--components--button--secondary--border--color-hover);
}
.c__button--tertiary {
@@ -69,6 +69,8 @@
--c--theme--colors--success-text: var(--c--theme--colors--greyscale-000);
--c--theme--colors--warning-text: var(--c--theme--colors--greyscale-000);
--c--theme--colors--danger-text: var(--c--theme--colors--greyscale-000);
--c--theme--colors--card-border: #ddd;
--c--theme--colors--primary-bg: #fafafa;
--c--theme--colors--primary-150: #e5eefa;
--c--theme--colors--info-150: #e5eefa;
--c--theme--font--sizes--h1: 2.2rem;
@@ -158,12 +160,18 @@
--c--components--forms-labelledbox--label-color--small: var(
--c--theme--colors--primary-500
);
--c--components--forms-labelledbox--label-color--small-disabled: var(
--c--theme--colors--greyscale-400
);
--c--components--forms-labelledbox--label-color--big--disabled: var(
--c--theme--colors--greyscale-400
);
--c--components--forms-select--border-color: var(
--c--theme--colors--primary-500
);
--c--components--forms-select--border-color-disabled-hover: var(
--c--theme--colors--greyscale-200
);
--c--components--forms-select--border-radius--hover: var(
--c--components--forms-select--border-radius
);
@@ -313,6 +321,7 @@
}
.cunningham-theme--dsfr {
--c--theme--colors--card-border: #ddd;
--c--theme--colors--primary-text: #000091;
--c--theme--colors--primary-100: #f5f5fe;
--c--theme--colors--primary-200: #ececfe;
@@ -333,10 +342,11 @@
--c--theme--colors--secondary-700: #3b2424;
--c--theme--colors--secondary-800: #341f1f;
--c--theme--colors--secondary-900: #2b1919;
--c--theme--colors--greyscale-000: #cecece;
--c--theme--colors--greyscale-100: #f6f6f6;
--c--theme--colors--greyscale-200: #eee;
--c--theme--colors--greyscale-300: #e5e5e5;
--c--theme--colors--greyscale-text: #303c4b;
--c--theme--colors--greyscale-000: #f6f6f6;
--c--theme--colors--greyscale-100: #eee;
--c--theme--colors--greyscale-200: #e5e5e5;
--c--theme--colors--greyscale-300: #e1e1e1;
--c--theme--colors--greyscale-400: #ddd;
--c--theme--colors--greyscale-500: #cecece;
--c--theme--colors--greyscale-600: #7b7b7b;
@@ -386,15 +396,70 @@
--c--theme--font--families--accent: marianne;
--c--theme--font--families--base: marianne;
--c--components--alert--border-radius: 0;
--c--components--button--border-radius: 2px;
--c--components--button--border-radius: 4px;
--c--components--button--primary--background--color: var(
--c--theme--colors--primary-text
);
--c--components--button--primary--background--color-hover: var(
--c--theme--colors--primary-700
);
--c--components--button--primary--background--color-active: var(
--c--theme--colors--primary-900
);
--c--components--button--primary--color: #fff;
--c--components--button--primary--color-hover: #fff;
--c--components--button--primary--color-active: #fff;
--c--components--button--secondary--background--color-hover: var(
--c--theme--colors--primary-100
);
--c--components--button--secondary--background--color-active: var(
--c--theme--colors--primary-200
);
--c--components--button--secondary--border--color-hover: var(
--c--theme--colors--primary-300
);
--c--components--button--secondary--color: var(
--c--theme--colors--primary-text
);
--c--components--datagrid--header--color: var(
--c--theme--colors--primary-text
);
--c--components--datagrid--header--size: var(--c--theme--font--sizes--s);
--c--components--datagrid--body--background-color: transparent;
--c--components--datagrid--body--background-color-hover: #f4f4fd;
--c--components--datagrid--pagination--background-color: transparent;
--c--components--datagrid--pagination--background-color-active: var(
--c--theme--colors--primary-300
);
--c--components--forms-checkbox--border-radius: 0;
--c--components--forms-datepicker--border-radius: 0;
--c--components--forms-fileuploader--border-radius: 0;
--c--components--forms-input--border-radius: 4px;
--c--components--forms-input--background-color: #fff;
--c--components--forms-input--border-color: var(
--c--theme--colors--primary-text
);
--c--components--forms-input--box-shadow-color: var(
--c--theme--colors--primary-text
);
--c--components--forms-labelledbox--label-color--big: var(
--c--theme--colors--primary-text
);
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius-hover: 4px;
--c--components--forms-select--background-color: #fff;
--c--components--forms-select--border-color: var(
--c--theme--colors--primary-text
);
--c--components--forms-select--border-color-hover: var(
--c--theme--colors--primary-text
);
--c--components--forms-select--box-shadow-color: var(
--c--theme--colors--primary-text
);
--c--components--forms-switch--handle-border-radius: 2px;
--c--components--forms-switch--rail-border-radius: 4px;
--c--components--forms-input--border-radius: 0;
--c--components--forms-select--border-radius: 0;
--c--components--forms-datepicker--border-radius: 0;
--c--components--forms-textarea--border-radius: 0;
--c--components--forms-fileuploader--border-radius: 0;
}
.clr-secondary-text {
@@ -677,6 +742,14 @@
color: var(--c--theme--colors--danger-text);
}
.clr-card-border {
color: var(--c--theme--colors--card-border);
}
.clr-primary-bg {
color: var(--c--theme--colors--primary-bg);
}
.clr-primary-150 {
color: var(--c--theme--colors--primary-150);
}
@@ -965,6 +1038,14 @@
background-color: var(--c--theme--colors--danger-text);
}
.bg-card-border {
background-color: var(--c--theme--colors--card-border);
}
.bg-primary-bg {
background-color: var(--c--theme--colors--primary-bg);
}
.bg-primary-150 {
background-color: var(--c--theme--colors--primary-150);
}
@@ -73,6 +73,8 @@ export const tokens = {
'success-text': '#FFFFFF',
'warning-text': '#FFFFFF',
'danger-text': '#FFFFFF',
'card-border': '#DDDDDD',
'primary-bg': '#FAFAFA',
'primary-150': '#E5EEFA',
'info-150': '#E5EEFA',
},
@@ -181,11 +183,14 @@ export const tokens = {
'forms-labelledbox': {
'label-color': {
small: 'var(--c--theme--colors--primary-500)',
'small-disabled': 'var(--c--theme--colors--greyscale-400)',
big: { disabled: 'var(--c--theme--colors--greyscale-400)' },
},
},
'forms-select': {
'border-color': 'var(--c--theme--colors--primary-500)',
'border-color-disabled-hover':
'var(--c--theme--colors--greyscale-200)',
'border-radius': {
hover: 'var(--c--components--forms-select--border-radius)',
focus: 'var(--c--components--forms-select--border-radius)',
@@ -321,6 +326,7 @@ export const tokens = {
dsfr: {
theme: {
colors: {
'card-border': '#DDDDDD',
'primary-text': '#000091',
'primary-100': '#f5f5fe',
'primary-200': '#ececfe',
@@ -341,10 +347,11 @@ export const tokens = {
'secondary-700': '#3b2424',
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-000': '#cecece',
'greyscale-100': '#f6f6f6',
'greyscale-200': '#eeeeee',
'greyscale-300': '#e5e5e5',
'greyscale-text': '#303C4B',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
@@ -396,17 +403,66 @@ export const tokens = {
},
components: {
alert: { 'border-radius': '0' },
button: { 'border-radius': '2px' },
button: {
'border-radius': '4px',
primary: {
background: {
color: 'var(--c--theme--colors--primary-text)',
'color-hover': 'var(--c--theme--colors--primary-700)',
'color-active': 'var(--c--theme--colors--primary-900)',
},
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
},
secondary: {
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-active': 'var(--c--theme--colors--primary-200)',
},
border: { 'color-hover': 'var(--c--theme--colors--primary-300)' },
color: 'var(--c--theme--colors--primary-text)',
},
},
datagrid: {
header: {
color: 'var(--c--theme--colors--primary-text)',
size: 'var(--c--theme--font--sizes--s)',
},
body: {
'background-color': 'transparent',
'background-color-hover': '#F4F4FD',
},
pagination: {
'background-color': 'transparent',
'background-color-active': 'var(--c--theme--colors--primary-300)',
},
},
'forms-checkbox': { 'border-radius': '0' },
'forms-datepicker': { 'border-radius': '0' },
'forms-fileuploader': { 'border-radius': '0' },
'forms-input': {
'border-radius': '4px',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
},
'forms-labelledbox': {
'label-color': { big: 'var(--c--theme--colors--primary-text)' },
},
'forms-select': {
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
},
'forms-switch': {
'handle-border-radius': '2px',
'rail-border-radius': '4px',
},
'forms-input': { 'border-radius': '0' },
'forms-select': { 'border-radius': '0' },
'forms-datepicker': { 'border-radius': '0' },
'forms-textarea': { 'border-radius': '0' },
'forms-fileuploader': { 'border-radius': '0' },
},
},
},
-4
View File
@@ -20,9 +20,5 @@ declare module '*.svg?url' {
namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_API_URL?: string;
NEXT_PUBLIC_KEYCLOAK_URL?: string;
NEXT_PUBLIC_KEYCLOAK_REALM?: string;
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID?: string;
NEXT_PUBLIC_KEYCLOAK_LOGIN?: string;
}
}
@@ -6,15 +6,11 @@ import { Box } from '@/components';
import { useAuthStore } from './useAuthStore';
export const Auth = ({ children }: PropsWithChildren) => {
const { initAuth, authenticated, initialized } = useAuthStore();
const { authenticated, initAuth } = useAuthStore();
useEffect(() => {
if (initialized) {
return;
}
initAuth();
}, [initAuth, initialized]);
}, [initAuth]);
if (!authenticated) {
return (
@@ -0,0 +1,31 @@
import { fetchAPI } from '@/api';
/**
* Represents user data retrieved from the API.
* This interface is incomplete, and will be
* refactored in a near future.
*
* @interface UserData
* @property {string} email - The email of the user.
*/
export interface UserData {
email: string;
}
/**
* Asynchronously retrieves the current user's data from the API.
* This function is called during frontend initialization to check
* the user's authentication status through a session cookie.
*
* @async
* @function getMe
* @throws {Error} Throws an error if the API request fails.
* @returns {Promise<UserData>} A promise that resolves to the user data.
*/
export const getMe = async (): Promise<UserData> => {
const response = await fetchAPI(`users/me/`);
if (!response.ok) {
throw new Error(`Couldn't fetch user data: ${response.statusText}`);
}
return response.json() as Promise<UserData>;
};
@@ -0,0 +1 @@
export * from './getMe';
@@ -1,23 +0,0 @@
import Keycloak, { KeycloakConfig } from 'keycloak-js';
const keycloakConfig: KeycloakConfig = {
url: process.env.NEXT_PUBLIC_KEYCLOAK_URL,
realm: process.env.NEXT_PUBLIC_KEYCLOAK_REALM || '',
clientId: process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID || '',
};
export const initKeycloak = (onAuthSuccess: (_token?: string) => void) => {
const keycloak = new Keycloak(keycloakConfig);
keycloak
.init({
onLoad: 'login-required',
})
.then((authenticated) => {
if (authenticated) {
onAuthSuccess(keycloak.token);
}
})
.catch(() => {
throw new Error('Failed to initialize Keycloak.');
});
};
@@ -1,40 +1,39 @@
import { create } from 'zustand';
import { initKeycloak } from './keycloak';
import { UserData, getMe } from '@/features/auth/api';
export const login = () => {
window.location.replace(
new URL('authenticate/', process.env.NEXT_PUBLIC_API_URL).href,
);
};
interface AuthStore {
authenticated: boolean;
initAuth: () => void;
initialized: boolean;
logout: () => void;
token: string | null;
userData?: UserData;
}
const initialState = {
authenticated: false,
initialized: false,
token: null,
userData: undefined,
};
export const useAuthStore = create<AuthStore>((set) => ({
authenticated: initialState.authenticated,
initialized: initialState.initialized,
token: initialState.token,
initAuth: () =>
set((state) => {
if (process.env.NEXT_PUBLIC_KEYCLOAK_LOGIN && !state.initialized) {
initKeycloak((token) => set({ authenticated: true, token }));
return { initialized: true };
}
/**
* TODO: Implement OIDC production authentication
*/
return {};
}),
userData: initialState.userData,
initAuth: () => {
getMe()
.then((data: UserData) => {
set({ authenticated: true, userData: data });
})
.catch(() => {
// todo - implement a proper login screen to prevent automatic navigation.
login();
});
},
logout: () => {
set(initialState);
},
@@ -10,7 +10,6 @@ import { LanguagePicker } from '../language/';
import { default as IconCells } from './assets/icon-cells.svg?url';
import { default as IconDesk } from './assets/icon-desk.svg?url';
import { default as IconFAQ } from './assets/icon-faq.svg?url';
import { default as IconGouv } from './assets/icon-gouv.svg?url';
import { default as IconMarianne } from './assets/icon-marianne.svg?url';
import IconMyAccount from './assets/icon-my-account.png';
@@ -42,18 +41,23 @@ export const Header = () => {
return (
<StyledHeader>
<RedStripe />
<Box className="ml-bx mr-bx" $align="center" $justify="space-between">
<Box $direction="column">
<Box
className="ml-bx mr-bx"
$align="center"
$justify="space-between"
$direction="row"
>
<Box>
<Image priority src={IconMarianne} alt={t('Marianne Logo')} />
<Box $align="center" $gap="6rem">
<Box $align="center" $gap="6rem" $direction="row">
<Image
priority
src={IconGouv}
alt={t('Freedom Equality Fraternity Logo')}
/>
<Box $align="center" $gap="1rem">
<Box $align="center" $gap="1rem" $direction="row">
<Image priority src={IconDesk} alt={t('Desk Logo')} />
<Text className="m-0" as="h2">
<Text className="m-0" as="h2" $theme="primary">
{t('Desk')}
</Text>
</Box>
@@ -68,27 +72,22 @@ export const Header = () => {
`}
$gap="5rem"
$justify="flex-end"
$direction="row"
>
<Box $align="center">
<Button
aria-label={t('Access to FAQ')}
icon={<Image priority src={IconFAQ} alt={t('FAQ Icon')} />}
className="m-s c__button-no-bg p-0"
>
{t('FAQ')}
</Button>
<Box $align="center" $direction="row">
<LanguagePicker />
</Box>
<Box>
<Box $direction="row">
<Box $direction="row" $align="center" $gap="1rem">
<Text $weight="bold">John Doe</Text>
<Text $weight="bold" $theme="primary">
John Doe
</Text>
<Image
width={58}
height={58}
priority
src={IconMyAccount}
alt={t(`Profile picture`)}
unoptimized
/>
</Box>
<Button
@@ -1,14 +0,0 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M21 3C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.455L2 22.5V4C2 3.44772 2.44772 3 3 3H21ZM20 5H4V18.385L5.763 17H20V5ZM13 14V16H11V14H13ZM12.4592 6.02822C14.2875 6.27003 15.6148 7.88878 15.4936 9.72908C15.3724 11.5694 13.8443 13.0001 12 13H11V11H12C12.7902 11 13.4448 10.387 13.4967 9.59851C13.5486 8.81006 12.9799 8.11655 12.1966 8.01295C11.4133 7.90935 10.6839 8.43117 10.529 9.206L8.567 8.813C8.92838 7.00447 10.6308 5.78641 12.4592 6.02822Z"
fill="#000091"
/>
</svg>

Before

Width:  |  Height:  |  Size: 663 B

@@ -1,5 +0,0 @@
export * from './auth/';
export * from './header/';
export * from './language/';
export * from './menu/';
export * from './teams/';
@@ -49,7 +49,7 @@ export const LanguagePicker = () => {
$align="center"
>
<Image priority src={IconLanguage} alt={t('Language Icon')} />
<Text>{lang.toUpperCase()}</Text>
<Text $theme="primary">{lang.toUpperCase()}</Text>
</Box>
),
}));
@@ -1,13 +1,13 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import IconGroup from '@/assets/icons/icon-group.svg';
import { Box } from '@/components/';
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import MenuItem from './MenuItems';
import IconRecent from './assets/icon-clock.svg';
import IconContacts from './assets/icon-contacts.svg';
import IconGroup from './assets/icon-group.svg';
import IconSearch from './assets/icon-search.svg';
import IconFavorite from './assets/icon-stars.svg';
@@ -21,9 +21,7 @@ export const Menu = () => {
className="m-0 p-0"
$background={colorsTokens()['primary-800']}
$height="100%"
$width="3.75rem"
$justify="space-between"
$direction="column"
>
<Box className="pt-b" $direction="column" $gap="0.8rem">
<MenuItem Icon={IconSearch} label={t('Search')} href="/" />
@@ -1,24 +1,14 @@
import { Button } from '@openfun/cunningham-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { Box } from '@/components/';
import { Box, StyledLink } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { SVGComponent } from '@/types/components';
import { Tooltip } from './Tooltip';
const StyledLink = styled(Link)`
text-decoration: none;
color: #ffffff33;
&[aria-current='page'] {
color: #ffffff;
}
`;
interface MenuItemProps {
Icon: SVGComponent;
label: string;
@@ -29,7 +19,7 @@ const MenuItem = ({ Icon, label, href }: MenuItemProps) => {
const { t } = useTranslation();
const pathname = usePathname();
const { colorsTokens } = useCunninghamTheme();
const buttonRef = useRef(null);
const parentRef = useRef(null);
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const isActive = pathname === href;
@@ -51,9 +41,10 @@ const MenuItem = ({ Icon, label, href }: MenuItemProps) => {
<StyledLink
href={href}
aria-current={isActive && 'page'}
ref={buttonRef}
ref={parentRef}
onMouseOver={() => setIsTooltipOpen(true)}
onMouseOut={() => setIsTooltipOpen(false)}
style={{ display: 'block' }}
>
<Box
className="m-st p-t"
@@ -86,7 +77,7 @@ const MenuItem = ({ Icon, label, href }: MenuItemProps) => {
</Box>
{isTooltipOpen && (
<Tooltip
buttonRef={buttonRef}
parentRef={parentRef}
label={label}
backgroundColor={backgroundTooltip}
textColor={colorTooltip}
@@ -4,14 +4,14 @@ import React, { CSSProperties, useEffect, useState } from 'react';
import { Box, Text } from '@/components/';
interface TooltipProps {
buttonRef: React.MutableRefObject<null>;
parentRef: React.MutableRefObject<null>;
textColor: CSSProperties['color'];
backgroundColor: CSSProperties['color'];
label: string;
}
export const Tooltip = ({
buttonRef,
parentRef,
backgroundColor,
textColor,
label,
@@ -23,7 +23,7 @@ export const Tooltip = ({
}, []);
return (
<Popover parentRef={buttonRef} onClickOutside={() => ''} borderless>
<Popover parentRef={parentRef} onClickOutside={() => ''} borderless>
<Box
aria-label="tooltip"
className="ml-t p-t"
@@ -0,0 +1,130 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { AppWrapper } from '@/tests/utils';
import { TeamList } from '../components/Panel/TeamList';
window.HTMLElement.prototype.scroll = function () {};
jest.mock('next/router', () => ({
...jest.requireActual('next/router'),
useRouter: () => ({
query: {},
}),
}));
describe('PanelTeams', () => {
afterEach(() => {
fetchMock.restore();
});
it('renders with no team to display', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
count: 0,
results: [],
});
render(<TeamList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByText(
'Create your first team by clicking on the "Create a new team" button.',
),
).toBeInTheDocument();
});
it('renders an empty team', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Team 1',
accesses: [],
},
],
});
render(<TeamList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByLabelText('Empty teams icon'),
).toBeInTheDocument();
});
it('renders a team with only 1 member', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Team 1',
accesses: [
{
id: '1',
role: 'owner',
},
],
},
],
});
render(<TeamList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByLabelText('Empty teams icon'),
).toBeInTheDocument();
});
it('renders a non-empty team', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Team 1',
accesses: [
{
id: '1',
role: 'admin',
},
{
id: '2',
role: 'member',
},
],
},
],
});
render(<TeamList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByLabelText('Teams icon')).toBeInTheDocument();
});
it('renders the error', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
status: 500,
});
render(<TeamList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByText(
'Something bad happens, please refresh the page.',
),
).toBeInTheDocument();
});
});
@@ -1,41 +0,0 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { AppWrapper } from '@/tests/utils';
import { Teams } from '..';
describe('Teams', () => {
afterEach(() => {
fetchMock.restore();
});
it('checks Teams rendering', async () => {
fetchMock.mock(`/api/teams/`, {
results: [
{
id: '1',
name: 'Team 1',
},
{
id: '2',
name: 'Team 2',
},
],
});
render(<Teams />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByRole('button', {
name: 'Create Team',
}),
).toBeInTheDocument();
expect(screen.getByText(/Team 1/)).toBeInTheDocument();
expect(screen.getByText(/Team 2/)).toBeInTheDocument();
});
});
@@ -0,0 +1,5 @@
export * from './types';
export * from './useCreateTeam';
export * from './useTeam';
export * from './useTeams';
export * from './useTeamsAccesses';
@@ -0,0 +1,25 @@
export enum Role {
MEMBER = 'member',
ADMIN = 'administrator',
OWNER = 'owner',
}
export interface Access {
id: string;
role: Role;
user: User;
}
export interface Team {
id: string;
name: string;
accesses: Access[];
created_at: string;
updated_at: string;
}
export interface User {
id: string;
email: string;
name?: string;
}
@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchAPI } from '@/api';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_TEAM } from './useTeams';
@@ -8,11 +8,8 @@ type CreateTeamResponse = {
id: string;
name: string;
};
export interface CreateTeamResponseError {
detail: string;
}
export const createTeam = async (name: string) => {
export const createTeam = async (name: string): Promise<CreateTeamResponse> => {
const response = await fetchAPI(`teams/`, {
method: 'POST',
body: JSON.stringify({
@@ -21,21 +18,28 @@ export const createTeam = async (name: string) => {
});
if (!response.ok) {
throw new Error(`Couldn't create team: ${response.statusText}`);
throw new APIError(
'Failed to create the team',
await errorCauses(response),
);
}
return response.json();
return response.json() as Promise<CreateTeamResponse>;
};
export function useCreateTeam() {
interface CreateTeamProps {
onSuccess: (data: CreateTeamResponse) => void;
}
export function useCreateTeam({ onSuccess }: CreateTeamProps) {
const queryClient = useQueryClient();
return useMutation<CreateTeamResponse, CreateTeamResponseError, string>({
return useMutation<CreateTeamResponse, APIError, string>({
mutationFn: createTeam,
onSuccess: () => {
onSuccess: (data) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_TEAM],
exact: true,
});
onSuccess(data);
},
});
}
@@ -0,0 +1,32 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Team } from './types';
export type TeamParams = {
id: string;
};
export const getTeam = async ({ id }: TeamParams): Promise<Team> => {
const response = await fetchAPI(`teams/${id}`);
if (!response.ok) {
throw new APIError('Failed to get the team', await errorCauses(response));
}
return response.json() as Promise<Team>;
};
export const KEY_TEAM = 'team';
export function useTeam(
param: TeamParams,
queryConfig?: UseQueryOptions<Team, APIError, Team>,
) {
return useQuery<Team, APIError, Team>({
queryKey: [KEY_TEAM, param],
queryFn: () => getTeam(param),
...queryConfig,
});
}
@@ -1,38 +1,71 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import {
DefinedInitialDataInfiniteOptions,
InfiniteData,
QueryKey,
useInfiniteQuery,
} from '@tanstack/react-query';
import { APIList, fetchAPI } from '@/api';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
interface TeamResponse {
id: string;
name: string;
import { Team } from './types';
export enum TeamsOrdering {
BY_CREATED_ON = 'created_at',
BY_CREATED_ON_DESC = '-created_at',
}
type TeamsResponse = APIList<TeamResponse>;
export interface TeamsResponseError {
detail: string;
}
export type TeamsParams = {
ordering: TeamsOrdering;
};
type TeamsAPIParams = TeamsParams & {
page: number;
};
export const getTeams = async () => {
const response = await fetchAPI(`teams/`);
type TeamsResponse = APIList<Team>;
export const getTeams = async ({
ordering,
page,
}: TeamsAPIParams): Promise<TeamsResponse> => {
const orderingQuery = ordering ? `&ordering=${ordering}` : '';
const response = await fetchAPI(`teams/?page=${page}${orderingQuery}`);
if (!response.ok) {
throw new Error(`Couldn't fetch teams: ${response.statusText}`);
throw new APIError('Failed to get the teams', await errorCauses(response));
}
return response.json();
return response.json() as Promise<TeamsResponse>;
};
export const KEY_LIST_TEAM = 'teams';
export function useTeams(
queryConfig?: UseQueryOptions<
param: TeamsParams,
queryConfig?: DefinedInitialDataInfiniteOptions<
TeamsResponse,
TeamsResponseError,
TeamsResponse
APIError,
InfiniteData<TeamsResponse>,
QueryKey,
number
>,
) {
return useQuery<TeamsResponse, TeamsResponseError, TeamsResponse>({
queryKey: [KEY_LIST_TEAM],
queryFn: getTeams,
return useInfiniteQuery<
TeamsResponse,
APIError,
InfiniteData<TeamsResponse>,
QueryKey,
number
>({
initialPageParam: 1,
queryKey: [KEY_LIST_TEAM, param],
queryFn: ({ pageParam }) =>
getTeams({
...param,
page: pageParam,
}),
getNextPageParam(lastPage, allPages) {
return lastPage.next ? allPages.length + 1 : undefined;
},
...queryConfig,
});
}
@@ -0,0 +1,41 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { Access } from './types';
export type TeamAccessesAPIParams = {
page: number;
teamId: string;
};
type AccessesResponse = APIList<Access>;
export const getTeamAccesses = async ({
page,
teamId,
}: TeamAccessesAPIParams): Promise<AccessesResponse> => {
const response = await fetchAPI(`teams/${teamId}/accesses/?page=${page}`);
if (!response.ok) {
throw new APIError(
'Failed to get the team accesses',
await errorCauses(response),
);
}
return response.json() as Promise<AccessesResponse>;
};
export const KEY_LIST_TEAM_ACCESSES = 'teams-accesses';
export function useTeamAccesses(
params: TeamAccessesAPIParams,
queryConfig?: UseQueryOptions<AccessesResponse, APIError, AccessesResponse>,
) {
return useQuery<AccessesResponse, APIError, AccessesResponse>({
queryKey: [KEY_LIST_TEAM_ACCESSES, params],
queryFn: () => getTeamAccesses(params),
...queryConfig,
});
}
@@ -0,0 +1,13 @@
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_178_17838)">
<path
d="M16.25 8.75H13.75V13.75H8.75V16.25H13.75V21.25H16.25V16.25H21.25V13.75H16.25V8.75ZM15 2.5C8.1 2.5 2.5 8.1 2.5 15C2.5 21.9 8.1 27.5 15 27.5C21.9 27.5 27.5 21.9 27.5 15C27.5 8.1 21.9 2.5 15 2.5ZM15 25C9.4875 25 5 20.5125 5 15C5 9.4875 9.4875 5 15 5C20.5125 5 25 9.4875 25 15C25 20.5125 20.5125 25 15 25Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_178_17838">
<rect width="30" height="30" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 604 B

@@ -0,0 +1,13 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_508_5524)">
<path
d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM4 12C4 7.58 7.58 4 12 4C13.85 4 15.55 4.63 16.9 5.69L5.69 16.9C4.63 15.55 4 13.85 4 12ZM12 20C10.15 20 8.45 19.37 7.1 18.31L18.31 7.1C19.37 8.45 20 10.15 20 12C20 16.42 16.42 20 12 20Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_508_5524">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 578 B

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