Compare commits

...

11 Commits

Author SHA1 Message Date
Lebaud Antoine 64150da81a wip 2024-02-15 19:17:30 +01:00
Lebaud Antoine 6c0acd0596 ✏️(project) fix typos
Found typos, fixed them.
2024-02-15 16:48:00 +01:00
Lebaud Antoine 625e98515b 🔧(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-15 16:48:00 +01:00
Lebaud Antoine 297007162a (frontend) introduce frontend Docker Image
To facilitate deployment on Kubernetes, we've introduced a Docker image for the
frontend. The Next project is built, and its static output is served using an
Nginx reverse proxy.

Since DevOps lacks a certified cold storage solution (e.g., S3) for serving
static files, we've opted to containerize the frontend as a quick workaround for
deploying staging environments.
2024-02-15 16:48:00 +01:00
Lebaud Antoine 8fa947f24d ⬆️(project) upgrade base Docker image for mail-builder
Updated to Node Image version 20 to align with the upcoming frontend image.
2024-02-15 16:48:00 +01:00
Lebaud Antoine ee580ad258 🐛(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-15 16:48:00 +01:00
Lebaud Antoine 155043e0eb wip replace Implicit flow by Authorization Code flow in frontend
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

Todo in a dedicated PR:
* Implement the home screen with the SSO login buton
* Handle Logout properly
* Intercept 401 and 403 error properly
2024-02-15 16:46:32 +01:00
Lebaud Antoine df03534fb1 wip handle Authorization code flow with Django
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

Todo in dedicated PR:
* Configure redis to manage these session cookies
* Test and adjust code if necessary when the session cookie expires
* Test and adjust code if necessary when the user logout
2024-02-15 16:46:16 +01:00
Lebaud Antoine 714d8188f5 wip 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-15 12:03:05 +01:00
Lebaud Antoine 65b36c6116 wip 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-15 12:02:53 +01:00
Lebaud Antoine 52d8969602 wip update keycloak config
* 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-14 22:35:15 +01:00
38 changed files with 904 additions and 696 deletions
+13 -3
View File
@@ -35,10 +35,20 @@ jobs:
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Build and push
name: Build and push backend
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
target: production
push: true
tags: app-${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Build and push frontend
uses: docker/build-push-action@v5
with:
context: .
target: frontend
push: true
tags: frontend-${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+35 -1
View File
@@ -11,6 +11,40 @@ RUN apt-get update && \
apt-get -y upgrade && \
rm -rf /var/lib/apt/lists/*
### ---- Front-end builder image ----
FROM node:20 as front-builder
# Copy frontend app sources
COPY ./src/frontend /builder
WORKDIR /builder/apps/desk
RUN yarn install --frozen-lockfile && \
yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.25 as frontend
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=front-builder \
/builder/apps/desk/out \
/usr/share/nginx/html
COPY ./docker/files/etc/nginx/conf.d /etc/nginx/conf.d:ro
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["nginx", "-g", "daemon off;"]
# ---- Back-end builder image ----
FROM base as back-builder
@@ -24,7 +58,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:18 as mail-builder
FROM node:20 as mail-builder
COPY ./src/mail /mail/app
+3 -1
View File
@@ -92,8 +92,10 @@ bootstrap: \
.PHONY: bootstrap
# -- Docker/compose
build: ## build the app-dev container
build: ## build the dev and demo containers
@$(COMPOSE) build app-dev
@$(COMPOSE) build nginx
@$(COMPOSE) build app
.PHONY: build
down: ## stop and remove containers, networks, images, and volumes
+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/).
+17 -6
View File
@@ -66,10 +66,13 @@ services:
user: ${DOCKER_USER:-1000}
image: people:production
environment:
- DJANGO_CONFIGURATION=ContinuousIntegration
- DJANGO_CONFIGURATION=Demo
- DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:8088
env_file:
- env.d/development/common
- env.d/development/postgresql
ports:
- "8082:8000"
volumes:
- ./data/media:/data/media
depends_on:
@@ -81,7 +84,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
@@ -89,16 +92,18 @@ services:
- app
nginx:
image: nginx:1.25
build:
context: .
target: frontend
image: people:frontend
ports:
- "8082:8082"
- "8088:8088"
- "8083:8083"
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
- ./src/frontend/apps/desk/out:/home/desk
- ./data/media:/data/media:ro
depends_on:
- app
- keycloak
dockerize:
image: jwilder/dockerize
@@ -152,6 +157,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 +171,7 @@ services:
KC_DB_PASSWORD: pass
KC_DB_USERNAME: people
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: true
ports:
- "8080:8080"
depends_on:
+32 -12
View File
@@ -313,7 +313,6 @@
],
"security-admin-console": [],
"admin-cli": [],
"people-front": [],
"account-console": [],
"broker": [
{
@@ -326,6 +325,7 @@
"attributes": {}
}
],
"people": [],
"account": [
{
"id": "63b1a4e1-a594-4571-99c3-7c5c3efd61ce",
@@ -580,7 +580,9 @@
"publicClient": true,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@@ -618,7 +620,9 @@
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@@ -638,7 +642,7 @@
},
{
"id": "869481d0-5774-4e64-bc30-fedc7c58958f",
"clientId": "people-front",
"clientId": "people",
"name": "",
"description": "",
"rootUrl": "",
@@ -648,9 +652,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 +671,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 +732,9 @@
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@@ -887,7 +905,8 @@
"consentRequired": false,
"config": {
"id.token.claim": "true",
"access.token.claim": "true"
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
@@ -1207,6 +1226,7 @@
"consentRequired": false,
"config": {
"multivalued": "true",
"userinfo.token.claim": "true",
"user.attribute": "foo",
"id.token.claim": "true",
"access.token.claim": "true",
+16 -20
View File
@@ -1,27 +1,9 @@
server {
listen 8082;
server_name localhost;
charset utf-8;
location /media {
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;
# }
}
server {
listen 8088;
server_name localhost;
root /home/desk;
root /usr/share/nginx/html;
location / {
try_files $uri index.html $uri/ =404;
}
@@ -32,3 +14,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"}
+91 -39
View File
@@ -1,59 +1,111 @@
"""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
import logging
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 rest_framework_simplejwt.exceptions import InvalidToken
from .models import Identity
LOGGER = logging.getLogger(__name__)
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, as well as the userinfo endpoint behavior in Agent Connect.
"""
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: Userinfo endpoint returns a signed JWT, and not 'application/json'. This
constraint comes from Agent Connect. 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."""
email = user_info.get("email")
sub = user_info.get("sub")
target_class = "core.authentication.DelegatedJWTAuthentication"
name = "DelegatedJWTAuthentication"
if sub is None:
raise InvalidToken(
_("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"))
.distinct()
.first()
)
if user:
if email and email != user.identity_email:
Identity.objects.filter(sub=sub).update(email=email)
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
elif self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(user_info)
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.
"""
return user
def authenticate_header(self, request):
return "Session"
def create_user(self, claims):
"""Return a newly created User instance."""
email = claims.get("email")
sub = claims.get("sub")
class OpenApiSessionAuthenticationExtension(SessionScheme):
"""Extension for specifying session authentication schemes."""
if sub is None:
raise InvalidToken(
_("Claims contained no recognizable user identification")
)
target_class = "core.api.authentication.SessionAuthenticationWithAuthenticateHeader"
user = self.UserModel.objects.create(password="!", email=email) # noqa: S106
Identity.objects.create(user=user, sub=sub, email=email)
return user
+1 -40
View File
@@ -15,8 +15,6 @@ 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__))
@@ -227,7 +225,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)
@@ -450,40 +448,3 @@ class TeamAccess(BaseModel):
"put": bool(set_role_to),
"set_role_to": set_role_to,
}
def oidc_user_getter(validated_token):
"""
Given a valid OIDC token , retrieve, create or update corresponding user/contact/email from db.
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()
)
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"])
return user
@@ -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
@@ -32,7 +29,9 @@ def test_api_teams_list_authenticated():
"""Authenticated users should be able to list teams they are an owner/administrator/member of."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
expected_ids = {
str(access.team.id)
@@ -40,8 +39,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 +57,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 +67,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 +83,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,14 +103,16 @@ 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
@@ -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,14 +47,16 @@ 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()
@@ -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,18 +102,19 @@ 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
@@ -132,7 +133,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,11 +143,10 @@ 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
@@ -165,18 +167,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 +197,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 +207,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
@@ -10,8 +10,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
@@ -34,18 +32,18 @@ def test_api_team_accesses_list_authenticated_unrelated():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(3, team=team)
client = APIClient()
client.force_login(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)
response = APIClient().get(
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
assert response.json() == {
@@ -63,7 +61,9 @@ def test_api_team_accesses_list_authenticated_related():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory()
user_access = models.TeamAccess.objects.create(team=team, user=user) # random role
@@ -73,9 +73,8 @@ def test_api_team_accesses_list_authenticated_related():
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
response = APIClient().get(
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
@@ -129,15 +128,14 @@ def test_api_team_accesses_retrieve_authenticated_unrelated():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(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}",
)
response = client.get(f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
@@ -148,9 +146,8 @@ def test_api_team_accesses_retrieve_authenticated_unrelated():
factories.TeamAccessFactory(),
factories.TeamAccessFactory(user=user),
]:
response = APIClient().get(
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 404
@@ -164,14 +161,15 @@ def test_api_team_accesses_retrieve_authenticated_related():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[user])
access = factories.TeamAccessFactory(team=team)
response = APIClient().get(
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
@@ -211,18 +209,19 @@ def test_api_team_accesses_create_authenticated_unrelated():
"""
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()
response = APIClient().post(
response = client.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
@@ -236,21 +235,21 @@ 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)
client = APIClient()
client.force_login(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(
response = 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
@@ -267,22 +266,21 @@ def test_api_team_accesses_create_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")])
other_user = factories.UserFactory()
api_client = APIClient()
# It should not be allowed to create an owner access
response = api_client.post(
response = 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
@@ -295,14 +293,13 @@ def test_api_team_accesses_create_authenticated_administrator():
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = api_client.post(
response = 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
@@ -322,21 +319,22 @@ def test_api_team_accesses_create_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")])
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = APIClient().post(
response = 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
@@ -382,7 +380,9 @@ def test_api_team_accesses_update_authenticated_unrelated():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
@@ -393,13 +393,11 @@ def test_api_team_accesses_update_authenticated_unrelated():
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
response = 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
@@ -412,7 +410,9 @@ 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)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
@@ -424,13 +424,11 @@ def test_api_team_accesses_update_authenticated_member():
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
response = 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
@@ -446,7 +444,9 @@ def test_api_team_accesses_update_administrator_except_owner():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
@@ -461,14 +461,12 @@ def test_api_team_accesses_update_administrator_except_owner():
"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(
response = 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 (
@@ -493,7 +491,9 @@ def test_api_team_accesses_update_administrator_from_owner():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
@@ -506,13 +506,11 @@ def test_api_team_accesses_update_administrator_from_owner():
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
response = 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()
@@ -527,7 +525,9 @@ def test_api_team_accesses_update_administrator_to_owner():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
@@ -544,14 +544,12 @@ def test_api_team_accesses_update_administrator_to_owner():
"role": "owner",
}
api_client = APIClient()
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = api_client.put(
response = 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"]:
@@ -571,7 +569,9 @@ def test_api_team_accesses_update_owner_except_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")])
factories.UserFactory()
@@ -587,14 +587,12 @@ def test_api_team_accesses_update_owner_except_owner():
"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(
response = 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 (
@@ -620,7 +618,9 @@ def test_api_team_accesses_update_owner_for_owners():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
@@ -632,13 +632,11 @@ def test_api_team_accesses_update_owner_for_owners():
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
response = 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()
@@ -653,19 +651,19 @@ def test_api_team_accesses_update_owner_self():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(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(
response = 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
@@ -675,11 +673,10 @@ def test_api_team_accesses_update_owner_self():
# Add another owner and it should now work
factories.TeamAccessFactory(team=team, role="owner")
response = api_client.put(
response = 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
@@ -709,13 +706,14 @@ def test_api_team_accesses_delete_authenticated():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
access = factories.TeamAccessFactory()
response = APIClient().delete(
response = client.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
@@ -729,7 +727,9 @@ def test_api_team_accesses_delete_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")])
access = factories.TeamAccessFactory(team=team)
@@ -737,9 +737,8 @@ def test_api_team_accesses_delete_member():
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = APIClient().delete(
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
@@ -753,7 +752,9 @@ def test_api_team_accesses_delete_administrators():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
@@ -763,9 +764,8 @@ def test_api_team_accesses_delete_administrators():
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = APIClient().delete(
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 204
@@ -779,7 +779,9 @@ def test_api_team_accesses_delete_owners_except_owners():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(
@@ -789,9 +791,8 @@ def test_api_team_accesses_delete_owners_except_owners():
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = APIClient().delete(
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 204
@@ -805,7 +806,9 @@ def test_api_team_accesses_delete_owners_for_owners():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
@@ -813,9 +816,8 @@ def test_api_team_accesses_delete_owners_for_owners():
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = APIClient().delete(
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
@@ -828,15 +830,16 @@ def test_api_team_accesses_delete_owners_last_owner():
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
assert models.TeamAccess.objects.count() == 1
response = APIClient().delete(
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
+91 -76
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,7 +54,9 @@ 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)
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(email="david.bowman@work.com")
nicole = factories.IdentityFactory(email="nicole_foole@work.com")
@@ -62,9 +64,8 @@ def test_api_users_authenticated_list_by_email():
factories.IdentityFactory(email="heywood_floyd@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
@@ -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,9 +88,7 @@ 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"]]
@@ -106,16 +101,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,7 +127,9 @@ 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()
@@ -142,9 +140,8 @@ def test_api_users_authenticated_list_multiple_identities_multiple_users():
factories.IdentityFactory(user=prudence, 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
@@ -157,14 +154,15 @@ 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 +170,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 +183,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 +199,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 +212,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 +228,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 +244,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 +264,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
@@ -291,7 +293,9 @@ 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()
user = identity.user
jwt_token = OIDCToken.for_user(user)
client = APIClient()
client.force_login(user)
# Define profile contact
contact = factories.ContactFactory(owner=user)
@@ -299,8 +303,8 @@ 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
@@ -334,10 +338,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 +355,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 +388,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 +436,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 +464,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 +518,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 +528,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 +547,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 +558,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 +586,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 +613,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 +627,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,164 @@
"""Unit tests for the `get_or_create_user` function."""
from unittest import mock
import pytest
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework_simplejwt.tokens import AccessToken
from core import factories, 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()
# 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=AccessToken(), 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,
it should update the email on the identity but not on the user.
"""
klass = OIDCAuthenticationBackend()
identity = IdentityFactory()
# Create multiple identities for a user
for _ in range(5):
IdentityFactory(user=identity.user)
user_email = identity.user.email
assert models.User.objects.count() == 1
def get_userinfo_mocked(*args):
return {"sub": identity.sub, "email": identity.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# Only 1 query if the email has not changed
with django_assert_num_queries(1):
user = klass.get_or_create_user(
access_token=AccessToken(), id_token=None, payload=None
)
new_email = "test@fooo.com"
def get_userinfo_mocked(*args):
return {"sub": identity.sub, "email": new_email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# Additional update query if the email has changed
with django_assert_num_queries(2):
user = klass.get_or_create_user(
access_token=AccessToken(), id_token=None, payload=None
)
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_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=AccessToken(), 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 info contains an email, created user's email should be set.
"""
klass = OIDCAuthenticationBackend()
email = "people@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token=AccessToken(), id_token=None, payload=None
)
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, 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(
InvalidToken, match="User info contained no recognizable user identification"
):
user = klass.get_or_create_user(
access_token=AccessToken(), 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:
@@ -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)
@@ -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 subfield 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 subfield 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 subfield 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 subfield 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 subfield should accept non ASCII letters."""
identity = factories.IdentityFactory(sub="rené")
assert identity.sub == "rené"
@@ -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
+1 -1
View File
@@ -1,4 +1,4 @@
"""Tokens for Magnify's core app."""
"""Tokens for People's core app."""
from rest_framework_simplejwt.settings import api_settings
from rest_framework_simplejwt.tokens import Token
+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),
+81 -36
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",
@@ -242,34 +247,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)
@@ -284,7 +261,7 @@ class Base(Configuration):
# CORS
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False)
CORS_ALLOWED_ORIGINS = values.ListValue([])
CORS_ALLOWED_ORIGINS = values.ListValue([], environ_name="CORS_ALLOWED_ORIGINS")
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
# Sentry
@@ -299,6 +276,61 @@ 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
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -394,10 +426,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 +549,20 @@ 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.
"""
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
+1
View File
@@ -50,6 +50,7 @@ dependencies = [
"sentry-sdk==1.40.3",
"url-normalize==1.4.3",
"whitenoise==6.6.0",
"mozilla-django-oidc==4.0.0",
]
[project.urls]
-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
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=http://kubernetes-service-name-wip:8071/api/v1.0/
-1
View File
@@ -39,7 +39,6 @@
"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",
@@ -1,9 +1,8 @@
import fetchMock from 'fetch-mock';
import { fetchAPI } from '@/api';
import { useAuthStore } from '@/features/';
import { fetchAPI } from '../fetchApi';
describe('fetchAPI', () => {
beforeEach(() => {
process.env.NEXT_PUBLIC_API_URL = 'http://some.api.url/api/v1.0/';
@@ -20,29 +19,25 @@ 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' });
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();
});
});
+4 -2
View File
@@ -2,17 +2,19 @@ import { useAuthStore } from '@/features/';
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 response = await fetch(apiUrl, {
...init,
credentials: 'include',
headers: {
...init?.headers,
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
// 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
response.status === 401 && logout();
return response;
@@ -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,47 @@
import { create } from 'zustand';
import { initKeycloak } from './keycloak';
import { UserData, getMe } from '@/features/auth/api';
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,
userData: initialState.userData,
initAuth: () =>
set((state) => {
if (process.env.NEXT_PUBLIC_KEYCLOAK_LOGIN && !state.initialized) {
initKeycloak((token) => set({ authenticated: true, token }));
return { initialized: true };
if (state.initialized) {
return {};
}
/**
* TODO: Implement OIDC production authentication
*/
getMe()
.then((data: UserData) => {
set({ initialized: true, authenticated: true, userData: data });
return { initialized: true };
})
.catch(() => {
// todo - implement a proper login screen to prevent automatic navigation.
window.location.replace(
new URL('authenticate', process.env.NEXT_PUBLIC_API_URL).href,
);
});
return {};
}),
// todo - implement a proper logout to end session
// todo - please follow AC instructions here https://github.com/france-connect/Documentation-AgentConnect/blob/26685161a2031f7145e02bede8c4d0aae97ba105/doc_fs/technique_fca/endpoints.md?plain=1#L324
logout: () => {
set(initialState);
},
-24
View File
@@ -3866,11 +3866,6 @@ balanced-match@^2.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9"
integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==
base64-js@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
boolbase@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
@@ -6533,11 +6528,6 @@ jest@29.7.0:
import-local "^3.0.2"
jest-cli "^29.7.0"
js-sha256@^0.10.1:
version "0.10.1"
resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.10.1.tgz#b40104ba1368e823fdd5f41b66b104b15a0da60d"
integrity sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw==
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@@ -6663,20 +6653,6 @@ jsonfile@^6.0.1:
object.assign "^4.1.4"
object.values "^1.1.6"
jwt-decode@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-4.0.0.tgz#2270352425fd413785b2faf11f6e755c5151bd4b"
integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==
keycloak-js@23.0.6:
version "23.0.6"
resolved "https://registry.yarnpkg.com/keycloak-js/-/keycloak-js-23.0.6.tgz#a494cc1eddf5462322a9f2247b381bc22fb43747"
integrity sha512-Pn7iIEHPn7BcQFCbViKRv+8+v9l82oWNRVQr9wQGjp2BNEl9JpTsXjp84xQjwzaLKghG7QV7VwZrWBhiXJeM0Q==
dependencies:
base64-js "^1.5.1"
js-sha256 "^0.10.1"
jwt-decode "^4.0.0"
keyv@^4.5.3, keyv@^4.5.4:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"