Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ac11f27fb | |||
| d983397a34 | |||
| b010a7b5a7 | |||
| e16f51ca20 | |||
| 9d30bc88f1 | |||
| 1da978e121 | |||
| 3bf8965209 | |||
| 5b9d2cccc5 | |||
| 81243cfc9a | |||
| 70b1b996df | |||
| 29d274ab7c | |||
| f17771fc9b | |||
| 65e78cde68 | |||
| 33288ab225 | |||
| a3c0069697 | |||
| b307b373bb | |||
| f21740e5e5 | |||
| 035a7a1fcc | |||
| 3f7e5c88bc | |||
| 51064ec236 | |||
| 195e738c3c | |||
| 54497c1261 | |||
| 8d7c545d1a | |||
| 8cbfb38cc4 | |||
| 4bd8095975 | |||
| fc8dc24ba2 | |||
| 95219a33b3 | |||
| 26fbe9fbe7 | |||
| 4cacfd3a45 | |||
| 38c4d33791 | |||
| ec28c28d47 | |||
| 927d0e5a22 | |||
| 699854e76b | |||
| 63e059a4e6 | |||
| 5113eb013b | |||
| 45d05873e2 | |||
| 1f3ab759d7 | |||
| 8b5f5bf092 | |||
| cd2efbe40d | |||
| 8b0c20dbdc | |||
| d0562029e8 | |||
| 77efb1a89c | |||
| 4566e132e1 | |||
| f818715a45 | |||
| 7d90092020 | |||
| 469903c9eb | |||
| 1f1253ab21 | |||
| 6620932371 | |||
| 8e537d962c | |||
| 6080af961a | |||
| aba376702f | |||
| 1e38174c1b | |||
| 0ab9f16cb3 | |||
| c71463a385 | |||
| 47ffa60a94 | |||
| fafffd2391 | |||
| 36e2dc2378 | |||
| 4f0465fd32 | |||
| 148ea81aa9 |
@@ -115,6 +115,7 @@ jobs:
|
||||
- name: Set services env variables
|
||||
run: |
|
||||
make create-env-files
|
||||
cat env.d/development/common.e2e.dist >> env.d/development/common
|
||||
|
||||
- name: Build and Start Docker Servers
|
||||
env:
|
||||
@@ -124,7 +125,7 @@ jobs:
|
||||
docker-compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
|
||||
make run
|
||||
|
||||
- name: Apply DRH migrations
|
||||
- name: Apply DRF migrations
|
||||
run: |
|
||||
make migrate
|
||||
|
||||
@@ -216,7 +217,7 @@ jobs:
|
||||
DJANGO_CONFIGURATION: Test
|
||||
DJANGO_SETTINGS_MODULE: people.settings
|
||||
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
|
||||
DJANGO_JWT_PRIVATE_SIGNING_KEY: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
|
||||
DB_HOST: localhost
|
||||
DB_NAME: people
|
||||
DB_USER: dinum
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -66,7 +66,7 @@ services:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:production
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=ContinuousIntegration
|
||||
- DJANGO_CONFIGURATION=Demo
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
@@ -81,7 +81,7 @@ services:
|
||||
image: people:production
|
||||
command: ["celery", "-A", "people.celery_app", "worker", "-l", "INFO"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=ContinuousIntegration
|
||||
- DJANGO_CONFIGURATION=Demo
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
@@ -92,6 +92,7 @@ services:
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
- "8082:8082"
|
||||
- "8083:8083"
|
||||
- "8088:8088"
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
@@ -99,6 +100,7 @@ services:
|
||||
- ./data/media:/data/media:ro
|
||||
depends_on:
|
||||
- app
|
||||
- keycloak
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
@@ -152,6 +154,11 @@ services:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
- --import-realm
|
||||
- --proxy=edge
|
||||
- --hostname-url=http://localhost:8083
|
||||
- --hostname-admin-url=http://localhost:8083/
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
@@ -161,6 +168,7 @@ services:
|
||||
KC_DB_PASSWORD: pass
|
||||
KC_DB_USERNAME: people
|
||||
KC_DB_SCHEMA: public
|
||||
PROXY_ADDRESS_FORWARDING: 'true'
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
"users": [
|
||||
{
|
||||
"username": "people",
|
||||
"email": "people@people.world",
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
@@ -56,12 +59,43 @@
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "user-e2e",
|
||||
"username": "user-e2e-chromium",
|
||||
"email": "user@chromium.e2e",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Chromium",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e"
|
||||
"value": "password-e2e-chromium"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-webkit",
|
||||
"email": "user@webkit.e2e",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Webkit",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-webkit"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-firefox",
|
||||
"email": "user@firefox.e2e",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Firefox",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-firefox"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
@@ -313,7 +347,6 @@
|
||||
],
|
||||
"security-admin-console": [],
|
||||
"admin-cli": [],
|
||||
"people-front": [],
|
||||
"account-console": [],
|
||||
"broker": [
|
||||
{
|
||||
@@ -326,6 +359,7 @@
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"people": [],
|
||||
"account": [
|
||||
{
|
||||
"id": "63b1a4e1-a594-4571-99c3-7c5c3efd61ce",
|
||||
@@ -580,7 +614,9 @@
|
||||
"publicClient": true,
|
||||
"frontchannelLogout": false,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {},
|
||||
"attributes": {
|
||||
"post.logout.redirect.uris": "+"
|
||||
},
|
||||
"authenticationFlowBindingOverrides": {},
|
||||
"fullScopeAllowed": false,
|
||||
"nodeReRegistrationTimeout": 0,
|
||||
@@ -618,7 +654,9 @@
|
||||
"publicClient": false,
|
||||
"frontchannelLogout": false,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {},
|
||||
"attributes": {
|
||||
"post.logout.redirect.uris": "+"
|
||||
},
|
||||
"authenticationFlowBindingOverrides": {},
|
||||
"fullScopeAllowed": false,
|
||||
"nodeReRegistrationTimeout": 0,
|
||||
@@ -638,7 +676,7 @@
|
||||
},
|
||||
{
|
||||
"id": "869481d0-5774-4e64-bc30-fedc7c58958f",
|
||||
"clientId": "people-front",
|
||||
"clientId": "people",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"rootUrl": "",
|
||||
@@ -648,9 +686,10 @@
|
||||
"enabled": true,
|
||||
"alwaysDisplayInConsole": false,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
|
||||
"redirectUris": [
|
||||
"",
|
||||
"http://localhost:8070/*",
|
||||
"http://localhost:8071/*",
|
||||
"http://localhost:3200/*",
|
||||
"http://localhost:8088/*",
|
||||
"http://localhost:3000/*"
|
||||
@@ -666,18 +705,29 @@
|
||||
"consentRequired": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": true,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"publicClient": true,
|
||||
"publicClient": false,
|
||||
"frontchannelLogout": true,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"access.token.lifespan": "-1",
|
||||
"client.secret.creation.time": "1707820779",
|
||||
"user.info.response.signature.alg": "RS256",
|
||||
"post.logout.redirect.uris": "http://localhost:8070/*##http://localhost:3200/*##http://localhost:3000/*",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"use.jwks.url": "false",
|
||||
"backchannel.logout.revoke.offline.tokens": "false",
|
||||
"use.refresh.tokens": "true",
|
||||
"tls-client-certificate-bound-access-tokens": "false",
|
||||
"oidc.ciba.grant.enabled": "false",
|
||||
"backchannel.logout.session.required": "true",
|
||||
"post.logout.redirect.uris": "http://localhost:8070/*##http://localhost:3200/*##http://localhost:3000/*",
|
||||
"client_credentials.use_refresh_token": "false",
|
||||
"acr.loa.map": "{}",
|
||||
"require.pushed.authorization.requests": "false",
|
||||
"display.on.consent.screen": "false",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"backchannel.logout.revoke.offline.tokens": "false"
|
||||
"client.session.idle.timeout": "-1",
|
||||
"token.response.type.bearer.lower-case": "false"
|
||||
},
|
||||
"authenticationFlowBindingOverrides": {},
|
||||
"fullScopeAllowed": true,
|
||||
@@ -716,7 +766,9 @@
|
||||
"publicClient": false,
|
||||
"frontchannelLogout": false,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {},
|
||||
"attributes": {
|
||||
"post.logout.redirect.uris": "+"
|
||||
},
|
||||
"authenticationFlowBindingOverrides": {},
|
||||
"fullScopeAllowed": false,
|
||||
"nodeReRegistrationTimeout": 0,
|
||||
@@ -887,7 +939,8 @@
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true"
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1057,7 +1110,7 @@
|
||||
},
|
||||
{
|
||||
"id": "79712bcf-b7f7-4ca3-b97c-418f48fded9b",
|
||||
"name": "given name",
|
||||
"name": "first name",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
@@ -1066,7 +1119,7 @@
|
||||
"user.attribute": "firstName",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "given_name",
|
||||
"claim.name": "first_name",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
@@ -1087,7 +1140,7 @@
|
||||
},
|
||||
{
|
||||
"id": "7f741e96-41fe-4021-bbfd-506e7eb94e69",
|
||||
"name": "family name",
|
||||
"name": "last name",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
@@ -1096,7 +1149,7 @@
|
||||
"user.attribute": "lastName",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "family_name",
|
||||
"claim.name": "last_name",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
@@ -1207,6 +1260,7 @@
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"multivalued": "true",
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "foo",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
|
||||
@@ -8,12 +8,12 @@ server {
|
||||
alias /data/media;
|
||||
}
|
||||
|
||||
# location / {
|
||||
# proxy_pass http://app:8000;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# }
|
||||
location / {
|
||||
proxy_pass http://app:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
@@ -32,3 +32,17 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
server {
|
||||
listen 8083;
|
||||
server_name localhost;
|
||||
charset utf-8;
|
||||
|
||||
location / {
|
||||
proxy_pass http://keycloak:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# For the CI job test-e2e
|
||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||
BURST_THROTTLE_RATES="200/minute"
|
||||
@@ -13,6 +13,20 @@
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": ["node", "node-fetch", "i18next-parser"]
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js @types/react-dom@18.2.19",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": ["@types/react-dom"],
|
||||
"allowedVersions": ">18.2.19"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js @openfun/cunningham-react@2.5.0",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": ["@openfun/cunningham-react"],
|
||||
"allowedVersions": ">2.5.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -82,6 +82,15 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
),
|
||||
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"classes": ("wide",),
|
||||
"fields": ("email", "password1", "password2"),
|
||||
},
|
||||
),
|
||||
)
|
||||
inlines = (IdentityInline, TeamAccessInline)
|
||||
list_display = (
|
||||
"email",
|
||||
@@ -110,3 +119,31 @@ class TeamAdmin(admin.ModelAdmin):
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(models.Invitation)
|
||||
class InvitationAdmin(admin.ModelAdmin):
|
||||
"""Admin interface to handle invitations."""
|
||||
|
||||
fields = (
|
||||
"email",
|
||||
"team",
|
||||
"role",
|
||||
"created_at",
|
||||
"issuer",
|
||||
)
|
||||
readonly_fields = (
|
||||
"created_at",
|
||||
"is_expired",
|
||||
"issuer",
|
||||
)
|
||||
list_display = (
|
||||
"email",
|
||||
"team",
|
||||
"created_at",
|
||||
"is_expired",
|
||||
)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
obj.issuer = request.user
|
||||
obj.save()
|
||||
|
||||
@@ -26,28 +26,66 @@ class ContactSerializer(serializers.ModelSerializer):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
A ModelSerializer that takes an additional `fields` argument that
|
||||
controls which fields should be displayed.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Don't pass the 'fields' arg up to the superclass
|
||||
fields = kwargs.pop("fields", None)
|
||||
|
||||
# Instantiate the superclass normally
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if fields is not None:
|
||||
# Drop any fields that are not specified in the `fields` argument.
|
||||
allowed = set(fields)
|
||||
existing = set(self.fields)
|
||||
for field_name in existing - allowed:
|
||||
self.fields.pop(field_name)
|
||||
|
||||
|
||||
class UserSerializer(DynamicFieldsModelSerializer):
|
||||
"""Serialize users."""
|
||||
|
||||
data = serializers.SerializerMethodField(read_only=True)
|
||||
timezone = TimeZoneSerializerField(use_pytz=False, required=True)
|
||||
name = serializers.SerializerMethodField(read_only=True)
|
||||
email = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"data",
|
||||
"language",
|
||||
"timezone",
|
||||
"is_device",
|
||||
"is_staff",
|
||||
]
|
||||
read_only_fields = ["id", "email", "data", "is_device", "is_staff"]
|
||||
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
|
||||
|
||||
def get_data(self, user) -> dict:
|
||||
"""Return contact data for the user."""
|
||||
return user.profile_contact.data if user.profile_contact else {}
|
||||
def _get_main_identity_attr(self, obj, attribute_name):
|
||||
"""Return the specified attribute of the main identity."""
|
||||
try:
|
||||
return getattr(obj.main_identity[0], attribute_name)
|
||||
except TypeError:
|
||||
return getattr(obj.main_identity, attribute_name)
|
||||
except IndexError:
|
||||
main_identity = obj.identities.filter(is_main=True).first()
|
||||
return getattr(obj.main_identity, attribute_name) if main_identity else None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
def get_name(self, obj):
|
||||
"""Return main identity's name."""
|
||||
return self._get_main_identity_attr(obj, "name")
|
||||
|
||||
def get_email(self, obj):
|
||||
"""Return main identity's email."""
|
||||
return self._get_main_identity_attr(obj, "email")
|
||||
|
||||
|
||||
class TeamAccessSerializer(serializers.ModelSerializer):
|
||||
@@ -126,17 +164,52 @@ class TeamAccessSerializer(serializers.ModelSerializer):
|
||||
return attrs
|
||||
|
||||
|
||||
class TeamAccessReadOnlySerializer(TeamAccessSerializer):
|
||||
"""Serialize team accesses for list and retrieve actions."""
|
||||
|
||||
user = UserSerializer(read_only=True, fields=["id", "name", "email"])
|
||||
|
||||
class Meta:
|
||||
model = models.TeamAccess
|
||||
fields = [
|
||||
"id",
|
||||
"user",
|
||||
"role",
|
||||
"abilities",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"user",
|
||||
"role",
|
||||
"abilities",
|
||||
]
|
||||
|
||||
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
"""Serialize teams."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
accesses = TeamAccessSerializer(many=True, read_only=True)
|
||||
slug = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.Team
|
||||
fields = ["id", "name", "accesses", "abilities", "slug"]
|
||||
read_only_fields = ["id", "accesses", "abilities", "slug"]
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def get_abilities(self, team) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
@@ -148,3 +221,12 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
def get_slug(self, instance):
|
||||
"""Return slug from the team's name."""
|
||||
return instance.get_slug()
|
||||
|
||||
|
||||
class InvitationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize invitations."""
|
||||
|
||||
class Meta:
|
||||
model = models.Invitation
|
||||
fields = ["email", "team", "role", "issuer"]
|
||||
read_only_fields = ["team", "issuer"]
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
"""
|
||||
Utils that can be useful throughout the People core app
|
||||
"""
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
|
||||
def get_tokens_for_user(user):
|
||||
"""Get JWT tokens for user authentication."""
|
||||
refresh = RefreshToken.for_user(user)
|
||||
|
||||
return {
|
||||
"refresh": str(refresh),
|
||||
"access": str(refresh.access_token),
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
"""API endpoints"""
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.db.models import Func, Max, OuterRef, Q, Subquery, Value
|
||||
from django.db.models import Func, Max, OuterRef, Prefetch, Q, Subquery, Value
|
||||
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
exceptions,
|
||||
filters,
|
||||
mixins,
|
||||
pagination,
|
||||
response,
|
||||
@@ -185,7 +186,7 @@ class UserViewSet(
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.all().select_related("profile_contact")
|
||||
queryset = models.User.objects.all()
|
||||
serializer_class = serializers.UserSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
pagination_class = Pagination
|
||||
@@ -198,6 +199,12 @@ class UserViewSet(
|
||||
# Exclude inactive contacts
|
||||
queryset = queryset.filter(
|
||||
is_active=True,
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
"identities",
|
||||
queryset=models.Identity.objects.filter(is_main=True),
|
||||
to_attr="main_identity",
|
||||
)
|
||||
)
|
||||
|
||||
# Search by case-insensitive and accent-insensitive trigram similarity
|
||||
@@ -226,9 +233,12 @@ class UserViewSet(
|
||||
"""
|
||||
Return information on currently logged user
|
||||
"""
|
||||
context = {"request": request}
|
||||
user = request.user
|
||||
user.main_identity = models.Identity.objects.filter(
|
||||
user=user, is_main=True
|
||||
).first()
|
||||
return response.Response(
|
||||
self.serializer_class(request.user, context=context).data
|
||||
self.serializer_class(user, context={"request": request}).data
|
||||
)
|
||||
|
||||
|
||||
@@ -244,6 +254,9 @@ class TeamViewSet(
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
serializer_class = serializers.TeamSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["created_at"]
|
||||
ordering = ["-created_at"]
|
||||
queryset = models.Team.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -301,7 +314,8 @@ class TeamAccessViewSet(
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
queryset = models.TeamAccess.objects.all().select_related("user")
|
||||
serializer_class = serializers.TeamAccessSerializer
|
||||
list_serializer_class = serializers.TeamAccessReadOnlySerializer
|
||||
detail_serializer_class = serializers.TeamAccessSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
"""User only needs to be authenticated to list team accesses"""
|
||||
@@ -318,22 +332,35 @@ class TeamAccessViewSet(
|
||||
context["team_id"] = self.kwargs["team_id"]
|
||||
return context
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in {"list", "retrieve"}:
|
||||
return self.list_serializer_class
|
||||
return self.detail_serializer_class
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
queryset = super().get_queryset()
|
||||
queryset = queryset.filter(team=self.kwargs["team_id"])
|
||||
|
||||
if self.action == "list":
|
||||
if self.action in {"list", "retrieve"}:
|
||||
# Limit to team access instances related to a team THAT also has a team access
|
||||
# instance for the logged-in user (we don't want to list only the team access
|
||||
# instances pointing to the logged-in user)
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
team__accesses__user=self.request.user
|
||||
).values("role")[:1]
|
||||
|
||||
queryset = (
|
||||
queryset.filter(
|
||||
team__accesses__user=self.request.user,
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"user__identities",
|
||||
queryset=models.Identity.objects.filter(is_main=True),
|
||||
to_attr="main_identity",
|
||||
)
|
||||
)
|
||||
.annotate(user_role=Subquery(user_role_query))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@@ -1,59 +1,121 @@
|
||||
"""Authentication for the People core app."""
|
||||
from django.conf import settings
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from django.utils.module_loading import import_string
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from drf_spectacular.authentication import SessionScheme, TokenScheme
|
||||
from drf_spectacular.plumbing import build_bearer_security_scheme_object
|
||||
from rest_framework import authentication
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
import requests
|
||||
from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from .models import Identity
|
||||
|
||||
|
||||
class DelegatedJWTAuthentication(JWTAuthentication):
|
||||
"""Override JWTAuthentication to create missing users on the fly."""
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
|
||||
def get_user(self, validated_token):
|
||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
||||
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
||||
"""
|
||||
|
||||
def get_userinfo(self, access_token, id_token, payload):
|
||||
"""Return user details dictionary.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The id token (unused).
|
||||
- payload (dict): The token payload (unused).
|
||||
|
||||
Note: The id_token and payload parameters are unused in this implementation,
|
||||
but were kept to preserve base method signature.
|
||||
|
||||
Note: It handles signed and/or encrypted UserInfo Response. It is required by
|
||||
Agent Connect, which follows the OIDC standard. It forces us to override the
|
||||
base method, which deal with 'application/json' response.
|
||||
|
||||
Returns:
|
||||
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
|
||||
"""
|
||||
Return the user related to the given validated token, creating or updating it if necessary.
|
||||
|
||||
user_response = requests.get(
|
||||
self.OIDC_OP_USER_ENDPOINT,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
verify=self.get_settings("OIDC_VERIFY_SSL", True),
|
||||
timeout=self.get_settings("OIDC_TIMEOUT", None),
|
||||
proxies=self.get_settings("OIDC_PROXY", None),
|
||||
)
|
||||
user_response.raise_for_status()
|
||||
userinfo = self.verify_token(user_response.text)
|
||||
return userinfo
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The ID token.
|
||||
- payload (dict): The user payload.
|
||||
|
||||
Returns:
|
||||
- User: An existing or newly created User instance.
|
||||
|
||||
Raises:
|
||||
- Exception: Raised when user creation is not allowed and no existing user is found.
|
||||
"""
|
||||
get_user = import_string(settings.JWT_USER_GETTER)
|
||||
return SimpleLazyObject(lambda: get_user(validated_token))
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
|
||||
class OpenApiJWTAuthenticationExtension(TokenScheme):
|
||||
"""Extension for specifying JWT authentication schemes."""
|
||||
# Compute user name from OIDC name fields as defined in settings
|
||||
names_list = [
|
||||
user_info[field]
|
||||
for field in settings.USER_OIDC_FIELDS_TO_NAME
|
||||
if user_info.get(field)
|
||||
]
|
||||
user_info["name"] = " ".join(names_list) or None
|
||||
|
||||
target_class = "core.authentication.DelegatedJWTAuthentication"
|
||||
name = "DelegatedJWTAuthentication"
|
||||
sub = user_info.get("sub")
|
||||
if sub is None:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
"""Return the security definition for JWT authentication."""
|
||||
return build_bearer_security_scheme_object(
|
||||
header_name="Authorization",
|
||||
token_prefix="Bearer", # noqa S106
|
||||
user = (
|
||||
self.UserModel.objects.filter(identities__sub=sub)
|
||||
.annotate(
|
||||
identity_email=models.F("identities__email"),
|
||||
identity_name=models.F("identities__name"),
|
||||
)
|
||||
.distinct()
|
||||
.first()
|
||||
)
|
||||
if user:
|
||||
email = user_info.get("email")
|
||||
name = user_info.get("name")
|
||||
if (
|
||||
email
|
||||
and email != user.identity_email
|
||||
or name
|
||||
and name != user.identity_name
|
||||
):
|
||||
Identity.objects.filter(sub=sub).update(email=email, name=name)
|
||||
|
||||
elif self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = self.create_user(user_info)
|
||||
|
||||
return user
|
||||
|
||||
def create_user(self, claims):
|
||||
"""Return a newly created User instance."""
|
||||
sub = claims.get("sub")
|
||||
if sub is None:
|
||||
raise SuspiciousOperation(
|
||||
_("Claims contained no recognizable user identification")
|
||||
)
|
||||
|
||||
user = self.UserModel.objects.create(password="!") # noqa: S106
|
||||
Identity.objects.create(
|
||||
user=user, sub=sub, email=claims.get("email"), name=claims.get("name")
|
||||
)
|
||||
|
||||
|
||||
class SessionAuthenticationWithAuthenticateHeader(authentication.SessionAuthentication):
|
||||
"""
|
||||
This class is needed, because REST Framework's default SessionAuthentication does
|
||||
never return 401's, because they cannot fill the WWW-Authenticate header with a
|
||||
valid value in the 401 response. As a result, we cannot distinguish calls that are
|
||||
not unauthorized (401 unauthorized) and calls for which the user does not have
|
||||
permission (403 forbidden).
|
||||
See https://github.com/encode/django-rest-framework/issues/5968
|
||||
|
||||
We do set authenticate_header function in SessionAuthentication, so that a value
|
||||
for the WWW-Authenticate header can be retrieved and the response code is
|
||||
automatically set to 401 in case of unauthenticated requests.
|
||||
"""
|
||||
|
||||
def authenticate_header(self, request):
|
||||
return "Session"
|
||||
|
||||
|
||||
class OpenApiSessionAuthenticationExtension(SessionScheme):
|
||||
"""Extension for specifying session authentication schemes."""
|
||||
|
||||
target_class = "core.api.authentication.SessionAuthenticationWithAuthenticateHeader"
|
||||
return user
|
||||
|
||||
@@ -139,6 +139,7 @@ class IdentityFactory(factory.django.DjangoModelFactory):
|
||||
user = factory.SubFactory(UserFactory)
|
||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||
email = factory.Faker("email")
|
||||
name = factory.Faker("name")
|
||||
|
||||
|
||||
class TeamFactory(factory.django.DjangoModelFactory):
|
||||
@@ -170,3 +171,15 @@ class TeamAccessFactory(factory.django.DjangoModelFactory):
|
||||
team = factory.SubFactory(TeamFactory)
|
||||
user = factory.SubFactory(UserFactory)
|
||||
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||
|
||||
|
||||
class InvitationFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create invitations for a user"""
|
||||
|
||||
class Meta:
|
||||
model = models.Invitation
|
||||
|
||||
email = factory.Faker("email")
|
||||
team = factory.SubFactory(TeamFactory)
|
||||
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
|
||||
issuer = factory.SubFactory(UserFactory)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Generated by Django 5.0.1 on 2024-02-06 15:08
|
||||
# Generated by Django 5.0.2 on 2024-02-23 06:46
|
||||
|
||||
import core.models
|
||||
import django.contrib.auth.models
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import timezone_field.fields
|
||||
@@ -27,7 +27,7 @@ class Migration(migrations.Migration):
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('slug', models.SlugField(max_length=100, unique=True)),
|
||||
('slug', models.SlugField(editable=False, max_length=100, unique=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Team',
|
||||
@@ -60,7 +60,7 @@ class Migration(migrations.Migration):
|
||||
'db_table': 'people_user',
|
||||
},
|
||||
managers=[
|
||||
('objects', core.models.UserManager()),
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@@ -95,6 +95,7 @@ class Migration(migrations.Migration):
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('sub', models.CharField(help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='email address')),
|
||||
('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='name')),
|
||||
('is_main', models.BooleanField(default=False, help_text='Designates whether the email is the main one.', verbose_name='main')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identities', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
@@ -105,6 +106,21 @@ class Migration(migrations.Migration):
|
||||
'ordering': ('-is_main', 'email'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Invitation',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='email address')),
|
||||
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
|
||||
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.team')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TeamAccess',
|
||||
fields=[
|
||||
|
||||
@@ -4,19 +4,19 @@ Declare and configure the models for the People core application
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import models as auth_models
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.core import exceptions, mail, validators
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import jsonschema
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken
|
||||
from rest_framework_simplejwt.settings import api_settings
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -147,16 +147,6 @@ class Contact(BaseModel):
|
||||
raise exceptions.ValidationError({"data": [error_message]}) from e
|
||||
|
||||
|
||||
class UserManager(auth_models.UserManager):
|
||||
"""
|
||||
Override user manager to get the related contact in the same query by default (Contact model)
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""Always select the related contact when doing a query on users."""
|
||||
return super().get_queryset().select_related("profile_contact")
|
||||
|
||||
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
@@ -200,7 +190,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
),
|
||||
)
|
||||
|
||||
objects = UserManager()
|
||||
objects = auth_models.UserManager()
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
REQUIRED_FIELDS = []
|
||||
@@ -227,7 +217,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
)
|
||||
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
"""Send an email to this user."""
|
||||
"""Email this user."""
|
||||
main_identity = self.identities.get(is_main=True)
|
||||
mail.send_mail(subject, message, from_email, [main_identity.email], **kwargs)
|
||||
|
||||
@@ -264,6 +254,7 @@ class Identity(BaseModel):
|
||||
validators=[sub_validator],
|
||||
)
|
||||
email = models.EmailField(_("email address"), null=True, blank=True)
|
||||
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
|
||||
is_main = models.BooleanField(
|
||||
_("main"),
|
||||
default=False,
|
||||
@@ -288,7 +279,8 @@ class Identity(BaseModel):
|
||||
|
||||
def __str__(self):
|
||||
main_str = "[main]" if self.is_main else ""
|
||||
return f"{self.email:s}{main_str:s}"
|
||||
id_str = self.email or self.sub
|
||||
return f"{id_str:s}{main_str:s}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Ensure users always have one and only one main identity."""
|
||||
@@ -452,38 +444,29 @@ class TeamAccess(BaseModel):
|
||||
}
|
||||
|
||||
|
||||
def oidc_user_getter(validated_token):
|
||||
"""
|
||||
Given a valid OIDC token , retrieve, create or update corresponding user/contact/email from db.
|
||||
class Invitation(BaseModel):
|
||||
"""User invitation to teams."""
|
||||
|
||||
The token is expected to have the following fields in payload:
|
||||
- sub
|
||||
- email
|
||||
- ...
|
||||
"""
|
||||
try:
|
||||
user_id = validated_token[api_settings.USER_ID_CLAIM]
|
||||
except KeyError as exc:
|
||||
raise InvalidToken(
|
||||
_("Token contained no recognizable user identification")
|
||||
) from exc
|
||||
|
||||
try:
|
||||
email_param = {"email": validated_token["email"]}
|
||||
except KeyError:
|
||||
email_param = {}
|
||||
|
||||
user = (
|
||||
User.objects.filter(identities__sub=user_id)
|
||||
.annotate(identity_email=models.F("identities__email"))
|
||||
.distinct()
|
||||
.first()
|
||||
email = models.EmailField(_("email address"), null=False, blank=False)
|
||||
team = models.ForeignKey(
|
||||
Team,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
)
|
||||
role = models.CharField(
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
|
||||
)
|
||||
issuer = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
)
|
||||
|
||||
if user is None:
|
||||
user = User.objects.create(password="!", **email_param) # noqa: S106
|
||||
Identity.objects.create(user=user, sub=user_id, **email_param)
|
||||
elif email_param and validated_token["email"] != user.identity_email:
|
||||
Identity.objects.filter(sub=user_id).update(email=validated_token["email"])
|
||||
def __str__(self):
|
||||
return f"{self.email} invited to {self.team}"
|
||||
|
||||
return user
|
||||
@property
|
||||
def is_expired(self):
|
||||
"""Calculate if invitation is still valid or has expired."""
|
||||
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
|
||||
return timezone.now() > (self.created_at + validity_duration)
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Test for team accesses API endpoints in People's core app : create
|
||||
"""
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create team accesses."""
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(user.id),
|
||||
"team": str(team.id),
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
assert models.TeamAccess.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to create team accesses for a team to
|
||||
which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
team = factories.TeamFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage accesses for this team."
|
||||
}
|
||||
assert not models.TeamAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_member():
|
||||
"""Members of a team should not be allowed to create team accesses."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for role in [role[0] for role in models.RoleChoices.choices]:
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage accesses for this team."
|
||||
}
|
||||
|
||||
assert not models.TeamAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_administrator():
|
||||
"""
|
||||
Administrators of a team should be able to create team accesses except for the "owner" role.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# It should not be allowed to create an owner access
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": "owner",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Only owners of a team can assign other users as owners."
|
||||
}
|
||||
|
||||
# It should be allowed to create a lower access
|
||||
role = random.choice(
|
||||
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
|
||||
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"abilities": new_team_access.get_abilities(user),
|
||||
"id": str(new_team_access.id),
|
||||
"role": role,
|
||||
"user": str(other_user.id),
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_owner():
|
||||
"""
|
||||
Owners of a team should be able to create team accesses whatever the role.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
|
||||
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"abilities": new_team_access.get_abilities(user),
|
||||
"id": str(new_team_access.id),
|
||||
"role": role,
|
||||
"user": str(other_user.id),
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Test for team accesses API endpoints in People's core app : delete
|
||||
"""
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a team access."""
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a team access for a
|
||||
team to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_member():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a team access for a
|
||||
team in which they are a simple member.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_administrators():
|
||||
"""
|
||||
Users who are administrators in a team should be allowed to delete an access
|
||||
from the team provided it is not ownership.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team, role=random.choice(["member", "administrator"])
|
||||
)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_owners_except_owners():
|
||||
"""
|
||||
Users should be able to delete the team access of another user
|
||||
for a team of which they are owner provided it is not an owner access.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team, role=random.choice(["member", "administrator"])
|
||||
)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_owners_for_owners():
|
||||
"""
|
||||
Users should not be allowed to delete the team access of another owner
|
||||
even for a team in which they are direct owner.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(team=team, role="owner")
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_owners_last_owner():
|
||||
"""
|
||||
It should not be possible to delete the last owner access from a team
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
|
||||
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Test for team accesses API endpoints in People's core app : list
|
||||
"""
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list team accesses."""
|
||||
team = factories.TeamFactory()
|
||||
factories.TeamAccessFactory.create_batch(2, team=team)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/teams/{team.id!s}/accesses/")
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to list team accesses for a team
|
||||
to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
factories.TeamAccessFactory.create_batch(3, team=team)
|
||||
|
||||
# Accesses for other teams to which the user is related should not be listed either
|
||||
other_access = factories.TeamAccessFactory(user=user)
|
||||
factories.TeamAccessFactory(team=other_access.team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_related():
|
||||
"""
|
||||
Authenticated users should be able to list team accesses for a team
|
||||
to which they are related, whatever their role in the team.
|
||||
"""
|
||||
identity = factories.IdentityFactory(is_main=True)
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
user_access = models.TeamAccess.objects.create(team=team, user=user) # random role
|
||||
|
||||
# other team members should appear
|
||||
other_member = factories.UserFactory()
|
||||
other_member_identity = factories.IdentityFactory(is_main=True, user=other_member)
|
||||
access1 = factories.TeamAccessFactory.create(team=team, user=other_member)
|
||||
|
||||
# Accesses for other teams to which the user is related should not be listed either
|
||||
other_access = factories.TeamAccessFactory(user=user)
|
||||
factories.TeamAccessFactory(team=other_access.team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 2
|
||||
assert sorted(response.json()["results"], key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": {
|
||||
"id": str(user_access.user.id),
|
||||
"email": str(identity.email),
|
||||
"name": str(identity.name),
|
||||
},
|
||||
"role": str(user_access.role),
|
||||
"abilities": user_access.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": {
|
||||
"id": str(access1.user.id),
|
||||
"email": str(other_member_identity.email),
|
||||
"name": str(other_member_identity.name),
|
||||
},
|
||||
"role": str(access1.role),
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_main_identity():
|
||||
"""
|
||||
Name and email should be returned from main identity only
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory(user=user, is_main=True)
|
||||
factories.IdentityFactory(user=user) # additional non-main identity
|
||||
|
||||
team = factories.TeamFactory()
|
||||
models.TeamAccess.objects.create(team=team, user=user) # random role
|
||||
|
||||
# other team members should appear, with correct identity
|
||||
other_user = factories.UserFactory()
|
||||
other_main_identity = factories.IdentityFactory(is_main=True, user=other_user)
|
||||
factories.IdentityFactory(user=other_user)
|
||||
factories.TeamAccessFactory.create(team=team, user=other_user)
|
||||
|
||||
# Accesses for other teams to which the user is related should not be listed either
|
||||
other_access = factories.TeamAccessFactory(user=user)
|
||||
factories.TeamAccessFactory(team=other_access.team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 2
|
||||
users_info = [
|
||||
(access["user"]["email"], access["user"]["name"])
|
||||
for access in response.json()["results"]
|
||||
]
|
||||
# user information should be returned from main identity
|
||||
assert sorted(users_info) == sorted(
|
||||
[
|
||||
(str(identity.email), str(identity.name)),
|
||||
(str(other_main_identity.email), str(other_main_identity.name)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_constant_numqueries(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""
|
||||
The number of queries should not depend on the amount of fetched accesses.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
factories.IdentityFactory(user=user, is_main=True)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
models.TeamAccess.objects.create(team=team, user=user) # random role
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
# Only 4 queries are needed to efficiently fetch team accesses,
|
||||
# related users and identities :
|
||||
# - query retrieving logged-in user for user_role annotation
|
||||
# - count from pagination
|
||||
# - query prefetching users' main identity
|
||||
# - distinct from viewset
|
||||
with django_assert_num_queries(4):
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
|
||||
# create 20 new team members
|
||||
for _ in range(20):
|
||||
extra_user = factories.IdentityFactory(is_main=True).user
|
||||
factories.TeamAccessFactory(team=team, user=extra_user)
|
||||
|
||||
# num queries should still be 4
|
||||
with django_assert_num_queries(4):
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 21
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Test for team accesses API endpoints in People's core app : retrieve
|
||||
"""
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_retrieve_anonymous():
|
||||
"""
|
||||
Anonymous users should not be allowed to retrieve a team access.
|
||||
"""
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_retrieve_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a team access for
|
||||
a team to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
access = factories.TeamAccessFactory(team=factories.TeamFactory())
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
|
||||
# Accesses related to another team should be excluded even if the user is related to it
|
||||
for other_access in [
|
||||
factories.TeamAccessFactory(),
|
||||
factories.TeamAccessFactory(user=user),
|
||||
]:
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{other_access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
|
||||
|
||||
def test_api_team_accesses_retrieve_authenticated_related():
|
||||
"""
|
||||
A user who is related to a team should be allowed to retrieve the
|
||||
associated team user accesses.
|
||||
"""
|
||||
identity = factories.IdentityFactory(is_main=True)
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(access.id),
|
||||
"user": {
|
||||
"id": str(access.user.id),
|
||||
"email": str(identity.email),
|
||||
"name": str(identity.name),
|
||||
},
|
||||
"role": str(access.role),
|
||||
"abilities": access.get_abilities(user),
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
"""
|
||||
Test for team accesses API endpoints in People's core app : update
|
||||
"""
|
||||
import random
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a team access."""
|
||||
access = factories.TeamAccessFactory()
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to update a team access for a team to which
|
||||
they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
access = factories.TeamAccessFactory()
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_authenticated_member():
|
||||
"""Members of a team should not be allowed to update its accesses."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_administrator_except_owner():
|
||||
"""
|
||||
A user who is an administrator in a team should be allowed to update a user
|
||||
access for this team, as long as they don't try to set the role to owner.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team,
|
||||
role=random.choice(["administrator", "member"]),
|
||||
)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(["administrator", "member"]),
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_administrator_from_owner():
|
||||
"""
|
||||
A user who is an administrator in a team, should not be allowed to update
|
||||
the user access of an "owner" for this team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=other_user, role="owner")
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_administrator_to_owner():
|
||||
"""
|
||||
A user who is an administrator in a team, should not be allowed to update
|
||||
the user access of another user to grant team ownership.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team,
|
||||
user=other_user,
|
||||
role=random.choice(["administrator", "member"]),
|
||||
)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": "owner",
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
# We are not allowed or not really updating the role
|
||||
if field == "role" or new_data["role"] == old_values["role"]:
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_owner_except_owner():
|
||||
"""
|
||||
A user who is an owner in a team should be allowed to update
|
||||
a user access for this team except for existing "owner" accesses.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team,
|
||||
role=random.choice(["administrator", "member"]),
|
||||
)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_owner_for_owners():
|
||||
"""
|
||||
A user who is "owner" of a team should not be allowed to update
|
||||
an existing owner access for this team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(team=team, role="owner")
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, field: value},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_owner_self():
|
||||
"""
|
||||
A user who is owner of a team should be allowed to update
|
||||
their own user access provided there are other owners in the team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
new_role = random.choice(["administrator", "member"])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, "role": new_role},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
assert access.role == "owner"
|
||||
|
||||
# Add another owner and it should now work
|
||||
factories.TeamAccessFactory(team=team, role="owner")
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, "role": new_role},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
access.refresh_from_db()
|
||||
assert access.role == new_role
|
||||
@@ -11,7 +11,6 @@ from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import IdentityFactory, TeamFactory
|
||||
from core.models import Team
|
||||
from core.tests.utils import OIDCToken
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -36,15 +35,16 @@ def test_api_teams_create_authenticated():
|
||||
"""
|
||||
identity = IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
response = APIClient().post(
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
@@ -58,12 +58,12 @@ def test_api_teams_create_authenticated_slugify_name():
|
||||
Creating teams should automatically generate a slug.
|
||||
"""
|
||||
identity = IdentityFactory()
|
||||
jwt_token = OIDCToken.for_user(identity.user)
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = APIClient().post(
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{"name": "my team"},
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
@@ -87,14 +87,15 @@ def test_api_teams_create_authenticated_expected_slug(param):
|
||||
Creating teams should automatically create unaccented, no unicode, lower-case slug.
|
||||
"""
|
||||
identity = IdentityFactory()
|
||||
jwt_token = OIDCToken.for_user(identity.user)
|
||||
|
||||
response = APIClient().post(
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{
|
||||
"name": param[0],
|
||||
},
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
@@ -109,14 +110,15 @@ def test_api_teams_create_authenticated_unique_slugs():
|
||||
"""
|
||||
TeamFactory(name="existing team")
|
||||
identity = IdentityFactory()
|
||||
jwt_token = OIDCToken.for_user(identity.user)
|
||||
|
||||
response = APIClient().post(
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{
|
||||
"name": "èxisting team",
|
||||
},
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
|
||||
@@ -11,7 +11,6 @@ from rest_framework.status import (
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.tests.utils import OIDCToken
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -34,13 +33,14 @@ def test_api_teams_delete_authenticated_unrelated():
|
||||
related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
@@ -55,12 +55,14 @@ def test_api_teams_delete_authenticated_member():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{team.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
@@ -77,12 +79,14 @@ def test_api_teams_delete_authenticated_administrator():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{team.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
@@ -99,12 +103,14 @@ def test_api_teams_delete_authenticated_owner():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{team.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_204_NO_CONTENT
|
||||
|
||||
@@ -8,10 +8,7 @@ from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
|
||||
from ..utils import OIDCToken
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -29,10 +26,15 @@ def test_api_teams_list_anonymous():
|
||||
|
||||
|
||||
def test_api_teams_list_authenticated():
|
||||
"""Authenticated users should be able to list teams they are an owner/administrator/member of."""
|
||||
"""
|
||||
Authenticated users should be able to list teams
|
||||
they are an owner/administrator/member of.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
expected_ids = {
|
||||
str(access.team.id)
|
||||
@@ -40,8 +42,8 @@ def test_api_teams_list_authenticated():
|
||||
}
|
||||
factories.TeamFactory.create_batch(2) # Other teams
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/teams/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
@@ -58,7 +60,9 @@ def test_api_teams_list_pagination(
|
||||
"""Pagination should work as expected."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team_ids = [
|
||||
str(access.team.id)
|
||||
@@ -66,8 +70,8 @@ def test_api_teams_list_pagination(
|
||||
]
|
||||
|
||||
# Get page 1
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/teams/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
@@ -82,8 +86,8 @@ def test_api_teams_list_pagination(
|
||||
team_ids.remove(item["id"])
|
||||
|
||||
# Get page 2
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/teams/?page=2", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/?page=2",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
@@ -102,17 +106,77 @@ def test_api_teams_list_authenticated_distinct():
|
||||
"""A team with several related users should only be listed once."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[user, other_user])
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/teams/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["results"][0]["id"] == str(team.id)
|
||||
|
||||
|
||||
def test_api_teams_order():
|
||||
"""
|
||||
Test that the endpoint GET teams is sorted in 'created_at' descending order by default.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team_ids = [
|
||||
str(team.id) for team in factories.TeamFactory.create_batch(5, users=[user])
|
||||
]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
response_team_ids = [team["id"] for team in response_data["results"]]
|
||||
|
||||
team_ids.reverse()
|
||||
assert (
|
||||
response_team_ids == team_ids
|
||||
), "created_at values are not sorted from newest to oldest"
|
||||
|
||||
|
||||
def test_api_teams_order_param():
|
||||
"""
|
||||
Test that the 'created_at' field is sorted in ascending order
|
||||
when the 'ordering' query parameter is set.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team_ids = [
|
||||
str(team.id) for team in factories.TeamFactory.create_batch(5, users=[user])
|
||||
]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/?ordering=created_at",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
|
||||
response_team_ids = [team["id"] for team in response_data["results"]]
|
||||
|
||||
assert (
|
||||
response_team_ids == team_ids
|
||||
), "created_at values are not sorted from oldest to newest"
|
||||
|
||||
@@ -6,7 +6,6 @@ from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_404_N
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.tests.utils import OIDCToken
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -28,13 +27,14 @@ def test_api_teams_retrieve_authenticated_unrelated():
|
||||
not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/teams/{team.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
@@ -47,37 +47,30 @@ def test_api_teams_retrieve_authenticated_related():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access1 = factories.TeamAccessFactory(team=team, user=user)
|
||||
access2 = factories.TeamAccessFactory(team=team)
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/teams/{team.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted(
|
||||
assert sorted(response.json().pop("accesses")) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": str(user.id),
|
||||
"role": access1.role,
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access2.id),
|
||||
"user": str(access2.user.id),
|
||||
"role": access2.role,
|
||||
"abilities": access2.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["user"],
|
||||
str(access1.id),
|
||||
str(access2.id),
|
||||
]
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(team.id),
|
||||
"name": team.name,
|
||||
"slug": team.slug,
|
||||
"abilities": team.get_abilities(user),
|
||||
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api import serializers
|
||||
from core.tests.utils import OIDCToken
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -46,18 +45,18 @@ def test_api_teams_update_authenticated_unrelated():
|
||||
Authenticated users should not be allowed to update a team to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
response = APIClient().put(
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
@@ -75,17 +74,18 @@ def test_api_teams_update_authenticated_members():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
response = APIClient().put(
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
@@ -102,26 +102,29 @@ def test_api_teams_update_authenticated_administrators():
|
||||
"""Administrators of a team should be allowed to update it."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
initial_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
# generate new random values
|
||||
new_values = serializers.TeamSerializer(instance=factories.TeamFactory.build()).data
|
||||
response = APIClient().put(
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
new_values,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
team.refresh_from_db()
|
||||
final_values = serializers.TeamSerializer(instance=team).data
|
||||
for key, value in final_values.items():
|
||||
if key in ["id", "accesses"]: # pylint: disable=R1733
|
||||
if key in ["id", "accesses", "created_at"]:
|
||||
assert value == initial_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > initial_values[key]
|
||||
else:
|
||||
# name, slug and abilities successfully modified
|
||||
assert value == new_values[key]
|
||||
@@ -132,7 +135,9 @@ def test_api_teams_update_authenticated_owners():
|
||||
apart from read-only fields."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
@@ -140,19 +145,20 @@ def test_api_teams_update_authenticated_owners():
|
||||
new_team_values = serializers.TeamSerializer(
|
||||
instance=factories.TeamFactory.build()
|
||||
).data
|
||||
response = APIClient().put(
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
for key, value in team_values.items():
|
||||
if key in ["id", "accesses"]:
|
||||
if key in ["id", "accesses", "created_at"]:
|
||||
assert value == old_team_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > old_team_values[key]
|
||||
else:
|
||||
# name, slug and abilities successfully modified
|
||||
assert value == new_team_values[key]
|
||||
@@ -165,18 +171,19 @@ def test_api_teams_update_administrator_or_owner_of_another():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.TeamFactory(users=[(user, random.choice(["administrator", "owner"]))])
|
||||
team = factories.TeamFactory(name="Old name")
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
response = APIClient().put(
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
@@ -194,7 +201,9 @@ def test_api_teams_update_existing_slug_should_return_error():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.TeamFactory(name="Existing team", users=[(user, "administrator")])
|
||||
my_team = factories.TeamFactory(name="New team", users=[(user, "administrator")])
|
||||
@@ -202,11 +211,10 @@ def test_api_teams_update_existing_slug_should_return_error():
|
||||
updated_values = serializers.TeamSerializer(instance=my_team).data
|
||||
# Update my team's name for existing team. Creates a duplicate slug
|
||||
updated_values["name"] = "existing team"
|
||||
response = APIClient().put(
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{my_team.id!s}/",
|
||||
updated_values,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["slug"] == ["Team with this Slug already exists."]
|
||||
|
||||
@@ -9,8 +9,6 @@ from rest_framework.test import APIClient
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
|
||||
from .utils import OIDCToken
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@@ -75,7 +73,6 @@ def test_api_contacts_list_authenticated_no_query():
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
# Let's have 5 contacts in database:
|
||||
assert user.profile_contact is not None # Excluded because profile contact
|
||||
@@ -87,9 +84,10 @@ def test_api_contacts_list_authenticated_no_query():
|
||||
base=base_contact, owner=user, full_name="Bernard"
|
||||
) # Included
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/contacts/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [
|
||||
@@ -111,7 +109,6 @@ def test_api_contacts_list_authenticated_by_full_name():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="David Bowman")
|
||||
nicole = factories.BaseContactFactory(full_name="Nicole Foole")
|
||||
@@ -119,36 +116,31 @@ def test_api_contacts_list_authenticated_by_full_name():
|
||||
factories.BaseContactFactory(full_name="Heywood Floyd")
|
||||
|
||||
# Full query should work
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/contacts/?q=David%20Bowman", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=David%20Bowman")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Partial query should work
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/contacts/?q=ank", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
response = client.get("/api/v1.0/contacts/?q=ank")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.id)]
|
||||
|
||||
# Result that matches a trigram twice ranks better than result that matches once
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/contacts/?q=ole", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
response = client.get("/api/v1.0/contacts/?q=ole")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
# "Nicole Foole" matches twice on "ole"
|
||||
assert contact_ids == [str(nicole.id), str(frank.id)]
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/contacts/?q=ool", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
response = client.get("/api/v1.0/contacts/?q=ool")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
@@ -159,23 +151,21 @@ def test_api_contacts_list_authenticated_uppercase_content():
|
||||
"""Upper case content should be found by lower case query."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="EEE", short_name="AAA")
|
||||
|
||||
# Unaccented full name
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/contacts/?q=eee", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=eee")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/contacts/?q=aaa", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
response = client.get("/api/v1.0/contacts/?q=aaa")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
@@ -186,23 +176,21 @@ def test_api_contacts_list_authenticated_capital_query():
|
||||
"""Upper case query should find lower case content."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/contacts/?q=EEE", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
response = client.get("/api/v1.0/contacts/?q=EEE")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/contacts/?q=AAA", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
response = client.get("/api/v1.0/contacts/?q=AAA")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
@@ -213,23 +201,21 @@ def test_api_contacts_list_authenticated_accented_content():
|
||||
"""Accented content should be found by unaccented query."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="ééé", short_name="ààà")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/contacts/?q=eee", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
response = client.get("/api/v1.0/contacts/?q=eee")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/contacts/?q=aaa", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
response = client.get("/api/v1.0/contacts/?q=aaa")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
@@ -240,23 +226,21 @@ def test_api_contacts_list_authenticated_accented_query():
|
||||
"""Accented query should find unaccented content."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/contacts/?q=ééé", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
response = client.get("/api/v1.0/contacts/?q=ééé")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/contacts/?q=ààà", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
response = client.get("/api/v1.0/contacts/?q=ààà")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
@@ -281,13 +265,13 @@ def test_api_contacts_retrieve_authenticated_owned():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
@@ -305,13 +289,12 @@ def test_api_contacts_retrieve_authenticated_public():
|
||||
Authenticated users should be able to retrieve public contacts.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
jwt_token = OIDCToken.for_user(identity.user)
|
||||
|
||||
contact = factories.BaseContactFactory()
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
@@ -328,13 +311,12 @@ def test_api_contacts_retrieve_authenticated_other():
|
||||
Authenticated users should not be allowed to retrieve another user's contacts.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
jwt_token = OIDCToken.for_user(identity.user)
|
||||
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
@@ -357,17 +339,17 @@ def test_api_contacts_create_anonymous_forbidden():
|
||||
def test_api_contacts_create_authenticated_missing_base():
|
||||
"""Anonymous users should be able to create users."""
|
||||
identity = factories.IdentityFactory(user__profile_contact=None)
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
response = APIClient().post(
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"full_name": "David Bowman",
|
||||
"short_name": "Dave",
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert models.Contact.objects.exists() is False
|
||||
@@ -379,14 +361,15 @@ def test_api_contacts_create_authenticated_successful():
|
||||
"""Authenticated users should be able to create contacts."""
|
||||
identity = factories.IdentityFactory(user__profile_contact=None)
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
base_contact = factories.BaseContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Existing override for another user should not interfere
|
||||
factories.ContactFactory(base=base_contact)
|
||||
|
||||
response = APIClient().post(
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"base": str(base_contact.id),
|
||||
@@ -395,7 +378,6 @@ def test_api_contacts_create_authenticated_successful():
|
||||
"data": CONTACT_DATA,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
@@ -426,12 +408,14 @@ def test_api_contacts_create_authenticated_existing_override():
|
||||
"""
|
||||
identity = factories.IdentityFactory(user__profile_contact=None)
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
base_contact = factories.BaseContactFactory()
|
||||
factories.ContactFactory(base=base_contact, owner=user)
|
||||
|
||||
response = APIClient().post(
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"base": str(base_contact.id),
|
||||
@@ -440,7 +424,6 @@ def test_api_contacts_create_authenticated_existing_override():
|
||||
"data": CONTACT_DATA,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
@@ -481,7 +464,9 @@ def test_api_contacts_update_authenticated_owned():
|
||||
"""
|
||||
identity = factories.IdentityFactory(user__profile_contact=None)
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory(owner=user) # Owned by the logged-in user
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
@@ -491,11 +476,10 @@ def test_api_contacts_update_authenticated_owned():
|
||||
).data
|
||||
new_contact_values["base"] = str(factories.ContactFactory().id)
|
||||
|
||||
response = APIClient().put(
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -511,11 +495,13 @@ def test_api_contacts_update_authenticated_owned():
|
||||
|
||||
def test_api_contacts_update_authenticated_profile():
|
||||
"""
|
||||
Authenticated users should be allowed to update their prodile contact.
|
||||
Authenticated users should be allowed to update their profile contact.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
user.profile_contact = contact
|
||||
@@ -527,11 +513,10 @@ def test_api_contacts_update_authenticated_profile():
|
||||
).data
|
||||
new_contact_values["base"] = str(factories.ContactFactory().id)
|
||||
|
||||
response = APIClient().put(
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -550,7 +535,9 @@ def test_api_contacts_update_authenticated_other():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory() # owned by another user
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
@@ -560,11 +547,10 @@ def test_api_contacts_update_authenticated_other():
|
||||
).data
|
||||
new_contact_values["base"] = str(factories.ContactFactory().id)
|
||||
|
||||
response = APIClient().put(
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -588,13 +574,13 @@ def test_api_contacts_delete_list_authenticated():
|
||||
"""Authenticated users should not be allowed to delete a list of contacts."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.ContactFactory.create_batch(2)
|
||||
|
||||
response = APIClient().delete(
|
||||
"/api/v1.0/contacts/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
response = client.delete("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 405
|
||||
assert models.Contact.objects.count() == 4
|
||||
@@ -617,13 +603,14 @@ def test_api_contacts_delete_authenticated_public():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.BaseContactFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -636,13 +623,13 @@ def test_api_contacts_delete_authenticated_owner():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
|
||||
response = APIClient().delete(
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
@@ -656,15 +643,15 @@ def test_api_contacts_delete_authenticated_profile():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
contact = factories.ContactFactory(owner=user, base=None)
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
|
||||
response = APIClient().delete(
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
@@ -677,13 +664,13 @@ def test_api_contacts_delete_authenticated_other():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -1,843 +0,0 @@
|
||||
"""
|
||||
Test team accesses API endpoints for users in People's core app.
|
||||
"""
|
||||
import random
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
|
||||
from .utils import OIDCToken
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list team accesses."""
|
||||
team = factories.TeamFactory()
|
||||
factories.TeamAccessFactory.create_batch(2, team=team)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/teams/{team.id!s}/accesses/")
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to list team accesses for a team
|
||||
to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
factories.TeamAccessFactory.create_batch(3, team=team)
|
||||
|
||||
# Accesses for other teams to which the user is related should not be listed either
|
||||
other_access = factories.TeamAccessFactory(user=user)
|
||||
factories.TeamAccessFactory(team=other_access.team)
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_related():
|
||||
"""
|
||||
Authenticated users should be able to list team accesses for a team
|
||||
to which they are related, whatever their role in the team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
user_access = models.TeamAccess.objects.create(team=team, user=user) # random role
|
||||
access1, access2 = factories.TeamAccessFactory.create_batch(2, team=team)
|
||||
|
||||
# Accesses for other teams to which the user is related should not be listed either
|
||||
other_access = factories.TeamAccessFactory(user=user)
|
||||
factories.TeamAccessFactory(team=other_access.team)
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 3
|
||||
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": str(user.id),
|
||||
"role": user_access.role,
|
||||
"abilities": user_access.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": str(access1.user.id),
|
||||
"role": access1.role,
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access2.id),
|
||||
"user": str(access2.user.id),
|
||||
"role": access2.role,
|
||||
"abilities": access2.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
|
||||
|
||||
def test_api_team_accesses_retrieve_anonymous():
|
||||
"""
|
||||
Anonymous users should not be allowed to retrieve a team access.
|
||||
"""
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_retrieve_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a team access for
|
||||
a team to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
# Accesses related to another team should be excluded even if the user is related to it
|
||||
for access in [
|
||||
factories.TeamAccessFactory(),
|
||||
factories.TeamAccessFactory(user=user),
|
||||
]:
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
|
||||
|
||||
def test_api_team_accesses_retrieve_authenticated_related():
|
||||
"""
|
||||
A user who is related to a team should be allowed to retrieve the
|
||||
associated team user accesses.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory(users=[user])
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(access.id),
|
||||
"user": str(access.user.id),
|
||||
"role": access.role,
|
||||
"abilities": access.get_abilities(user),
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create team accesses."""
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(user.id),
|
||||
"team": str(team.id),
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
assert models.TeamAccess.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to create team accesses for a team to
|
||||
which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
team = factories.TeamFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage accesses for this team."
|
||||
}
|
||||
assert not models.TeamAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_member():
|
||||
"""Members of a team should not be allowed to create team accesses."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
api_client = APIClient()
|
||||
for role in [role[0] for role in models.RoleChoices.choices]:
|
||||
response = api_client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage accesses for this team."
|
||||
}
|
||||
|
||||
assert not models.TeamAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_administrator():
|
||||
"""
|
||||
Administrators of a team should be able to create team accesses except for the "owner" role.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
api_client = APIClient()
|
||||
|
||||
# It should not be allowed to create an owner access
|
||||
response = api_client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": "owner",
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Only owners of a team can assign other users as owners."
|
||||
}
|
||||
|
||||
# It should be allowed to create a lower access
|
||||
role = random.choice(
|
||||
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
|
||||
)
|
||||
|
||||
response = api_client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
|
||||
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"abilities": new_team_access.get_abilities(user),
|
||||
"id": str(new_team_access.id),
|
||||
"role": role,
|
||||
"user": str(other_user.id),
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_owner():
|
||||
"""
|
||||
Owners of a team should be able to create team accesses whatever the role.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
|
||||
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"abilities": new_team_access.get_abilities(user),
|
||||
"id": str(new_team_access.id),
|
||||
"role": role,
|
||||
"user": str(other_user.id),
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a team access."""
|
||||
access = factories.TeamAccessFactory()
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
api_client = APIClient()
|
||||
for field, value in new_values.items():
|
||||
response = api_client.put(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to update a team access for a team to which
|
||||
they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
access = factories.TeamAccessFactory()
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
api_client = APIClient()
|
||||
for field, value in new_values.items():
|
||||
response = api_client.put(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_authenticated_member():
|
||||
"""Members of a team should not be allowed to update its accesses."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
api_client = APIClient()
|
||||
for field, value in new_values.items():
|
||||
response = api_client.put(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_administrator_except_owner():
|
||||
"""
|
||||
A user who is an administrator in a team should be allowed to update a user
|
||||
access for this team, as long as they don't try to set the role to owner.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team,
|
||||
role=random.choice(["administrator", "member"]),
|
||||
)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(["administrator", "member"]),
|
||||
}
|
||||
|
||||
api_client = APIClient()
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = api_client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_administrator_from_owner():
|
||||
"""
|
||||
A user who is an administrator in a team, should not be allowed to update
|
||||
the user access of an "owner" for this team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=other_user, role="owner")
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
api_client = APIClient()
|
||||
for field, value in new_values.items():
|
||||
response = api_client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, field: value},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_administrator_to_owner():
|
||||
"""
|
||||
A user who is an administrator in a team, should not be allowed to update
|
||||
the user access of another user to grant team ownership.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team,
|
||||
user=other_user,
|
||||
role=random.choice(["administrator", "member"]),
|
||||
)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": "owner",
|
||||
}
|
||||
|
||||
api_client = APIClient()
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = api_client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
# We are not allowed or not really updating the role
|
||||
if field == "role" or new_data["role"] == old_values["role"]:
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_owner_except_owner():
|
||||
"""
|
||||
A user who is an owner in a team should be allowed to update
|
||||
a user access for this team except for existing "owner" accesses.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team,
|
||||
role=random.choice(["administrator", "member"]),
|
||||
)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
api_client = APIClient()
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = api_client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_owner_for_owners():
|
||||
"""
|
||||
A user who is "owner" of a team should not be allowed to update
|
||||
an existing owner access for this team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(team=team, role="owner")
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
api_client = APIClient()
|
||||
for field, value in new_values.items():
|
||||
response = api_client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, field: value},
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_owner_self():
|
||||
"""
|
||||
A user who is owner of a team should be allowed to update
|
||||
their own user access provided there are other owners in the team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
new_role = random.choice(["administrator", "member"])
|
||||
|
||||
api_client = APIClient()
|
||||
response = api_client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, "role": new_role},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
assert access.role == "owner"
|
||||
|
||||
# Add another owner and it should now work
|
||||
factories.TeamAccessFactory(team=team, role="owner")
|
||||
|
||||
response = api_client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, "role": new_role},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
access.refresh_from_db()
|
||||
assert access.role == new_role
|
||||
|
||||
|
||||
# Delete
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a team access."""
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a team access for a
|
||||
team to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_member():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a team access for a
|
||||
team in which they are a simple member.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_administrators():
|
||||
"""
|
||||
Users who are administrators in a team should be allowed to delete an access
|
||||
from the team provided it is not ownership.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team, role=random.choice(["member", "administrator"])
|
||||
)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_owners_except_owners():
|
||||
"""
|
||||
Users should be able to delete the team access of another user
|
||||
for a team of which they are owner provided it is not an owner access.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team, role=random.choice(["member", "administrator"])
|
||||
)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_owners_for_owners():
|
||||
"""
|
||||
Users should not be allowed to delete the team access of another owner
|
||||
even for a team in which they are direct owner.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(team=team, role="owner")
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_owners_last_owner():
|
||||
"""
|
||||
It should not be possible to delete the last owner access from a team
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
|
||||
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
@@ -16,8 +16,6 @@ from core import factories, models
|
||||
from core.api import serializers
|
||||
from core.api.viewsets import Pagination
|
||||
|
||||
from .utils import OIDCToken
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@@ -37,11 +35,13 @@ def test_api_users_list_authenticated():
|
||||
Authenticated users should be able to list all users.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
jwt_token = OIDCToken.for_user(identity.user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/users/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.get(
|
||||
"/api/v1.0/users/",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert len(response.json()["results"]) == 3
|
||||
@@ -54,17 +54,18 @@ def test_api_users_authenticated_list_by_email():
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.email)
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
dave = factories.IdentityFactory(email="david.bowman@work.com")
|
||||
nicole = factories.IdentityFactory(email="nicole_foole@work.com")
|
||||
frank = factories.IdentityFactory(email="frank_poole@work.com")
|
||||
factories.IdentityFactory(email="heywood_floyd@work.com")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.IdentityFactory(email="david.bowman@work.com", is_main=True)
|
||||
nicole = factories.IdentityFactory(email="nicole_foole@work.com", is_main=True)
|
||||
frank = factories.IdentityFactory(email="frank_poole@work.com", is_main=True)
|
||||
factories.IdentityFactory(email="heywood_floyd@work.com", is_main=True)
|
||||
|
||||
# Full query should work
|
||||
response = APIClient().get(
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
@@ -72,18 +73,14 @@ def test_api_users_authenticated_list_by_email():
|
||||
assert user_ids[0] == str(dave.user.id)
|
||||
|
||||
# Partial query should work
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/users/?q=fran", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
response = client.get("/api/v1.0/users/?q=fran")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(frank.user.id)
|
||||
|
||||
# Result that matches a trigram twice ranks better than result that matches once
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/users/?q=ole", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
response = client.get("/api/v1.0/users/?q=ole")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
@@ -91,13 +88,29 @@ def test_api_users_authenticated_list_by_email():
|
||||
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
|
||||
|
||||
# Even with a low similarity threshold, query should match expected emails
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/users/?q=ool", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
response = client.get("/api/v1.0/users/?q=ool")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(nicole.user.id),
|
||||
"email": nicole.email,
|
||||
"name": nicole.name,
|
||||
"is_device": nicole.user.is_device,
|
||||
"is_staff": nicole.user.is_staff,
|
||||
"language": nicole.user.language,
|
||||
"timezone": str(nicole.user.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(frank.user.id),
|
||||
"email": frank.email,
|
||||
"name": frank.name,
|
||||
"is_device": frank.user.is_device,
|
||||
"is_staff": frank.user.is_staff,
|
||||
"language": frank.user.language,
|
||||
"timezone": str(frank.user.timezone),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_multiple_identities_single_user():
|
||||
@@ -106,16 +119,17 @@ def test_api_users_authenticated_list_multiple_identities_single_user():
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.email)
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.UserFactory()
|
||||
factories.IdentityFactory(user=dave, email="david.bowman@work.com")
|
||||
factories.IdentityFactory(user=dave, email="david.bowman@fun.fr")
|
||||
|
||||
# Full query should work
|
||||
response = APIClient().get(
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
@@ -131,40 +145,73 @@ def test_api_users_authenticated_list_multiple_identities_multiple_users():
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.email)
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.UserFactory()
|
||||
davina = factories.UserFactory()
|
||||
prudence = factories.UserFactory()
|
||||
factories.IdentityFactory(user=dave, email="david.bowman@work.com")
|
||||
dave_identity = factories.IdentityFactory(
|
||||
user=dave, email="david.bowman@work.com", is_main=True
|
||||
)
|
||||
factories.IdentityFactory(user=dave, email="babibou@ehehe.com")
|
||||
factories.IdentityFactory(user=davina, email="davina.bowan@work.com")
|
||||
factories.IdentityFactory(user=prudence, email="prudence.crandall@work.com")
|
||||
davina_identity = factories.IdentityFactory(
|
||||
user=factories.UserFactory(), email="davina.bowan@work.com"
|
||||
)
|
||||
prue_identity = factories.IdentityFactory(
|
||||
user=factories.UserFactory(), email="prudence.crandall@work.com"
|
||||
)
|
||||
|
||||
# Full query should work
|
||||
response = APIClient().get(
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 3
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id), str(davina.id), str(prudence.id)]
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(dave.id),
|
||||
"email": dave_identity.email,
|
||||
"name": dave_identity.name,
|
||||
"is_device": dave_identity.user.is_device,
|
||||
"is_staff": dave_identity.user.is_staff,
|
||||
"language": dave_identity.user.language,
|
||||
"timezone": str(dave_identity.user.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(davina_identity.user.id),
|
||||
"email": davina_identity.email,
|
||||
"name": davina_identity.name,
|
||||
"is_device": davina_identity.user.is_device,
|
||||
"is_staff": davina_identity.user.is_staff,
|
||||
"language": davina_identity.user.language,
|
||||
"timezone": str(davina_identity.user.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(prue_identity.user.id),
|
||||
"email": prue_identity.email,
|
||||
"name": prue_identity.name,
|
||||
"is_device": prue_identity.user.is_device,
|
||||
"is_staff": prue_identity.user.is_staff,
|
||||
"language": prue_identity.user.language,
|
||||
"timezone": str(prue_identity.user.timezone),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_uppercase_content():
|
||||
"""Upper case content should be found by lower case query."""
|
||||
user = factories.UserFactory(email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.email)
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.IdentityFactory(email="DAVID.BOWMAN@INTENSEWORK.COM")
|
||||
|
||||
# Unaccented full address
|
||||
response = APIClient().get(
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@intensework.com",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
@@ -172,8 +219,8 @@ def test_api_users_authenticated_list_uppercase_content():
|
||||
assert user_ids == [str(dave.user.id)]
|
||||
|
||||
# Partial query
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/users/?q=david", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
@@ -185,14 +232,15 @@ def test_api_users_list_authenticated_capital_query():
|
||||
"""Upper case query should find lower case content."""
|
||||
user = factories.UserFactory(email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.email)
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.IdentityFactory(email="david.bowman@work.com")
|
||||
|
||||
# Full uppercase query
|
||||
response = APIClient().get(
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=DAVID.BOWMAN@WORK.COM",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
@@ -200,8 +248,8 @@ def test_api_users_list_authenticated_capital_query():
|
||||
assert user_ids == [str(dave.user.id)]
|
||||
|
||||
# Partial uppercase email
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/users/?q=DAVID", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=DAVID",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
@@ -213,14 +261,15 @@ def test_api_contacts_list_authenticated_accented_query():
|
||||
"""Accented content should be found by unaccented query."""
|
||||
user = factories.UserFactory(email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.email)
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
helene = factories.IdentityFactory(email="helene.bowman@work.com")
|
||||
|
||||
# Accented full query
|
||||
response = APIClient().get(
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=hélène.bowman@work.com",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
@@ -228,8 +277,8 @@ def test_api_contacts_list_authenticated_accented_query():
|
||||
assert user_ids == [str(helene.user.id)]
|
||||
|
||||
# Unaccented partial email
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/users/?q=hélène", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=hélène",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
@@ -244,13 +293,15 @@ def test_api_users_list_pagination(
|
||||
"""Pagination should work as expected."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory.create_batch(4)
|
||||
|
||||
# Get page 1
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/users/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.get(
|
||||
"/api/v1.0/users/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
@@ -262,8 +313,8 @@ def test_api_users_list_pagination(
|
||||
assert content["previous"] is None
|
||||
|
||||
# Get page 2
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/users/?page=2", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?page=2",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
@@ -289,9 +340,11 @@ def test_api_users_retrieve_me_anonymous():
|
||||
|
||||
def test_api_users_retrieve_me_authenticated():
|
||||
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
|
||||
identity = factories.IdentityFactory()
|
||||
identity = factories.IdentityFactory(is_main=True)
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Define profile contact
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
@@ -299,19 +352,19 @@ def test_api_users_retrieve_me_authenticated():
|
||||
user.save()
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/users/me/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.get(
|
||||
"/api/v1.0/users/me/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(user.id),
|
||||
"email": str(user.email),
|
||||
"name": str(identity.name),
|
||||
"email": str(identity.email),
|
||||
"language": user.language,
|
||||
"timezone": str(user.timezone),
|
||||
"is_device": False,
|
||||
"is_staff": False,
|
||||
"data": user.profile_contact.data,
|
||||
}
|
||||
|
||||
|
||||
@@ -334,10 +387,12 @@ def test_api_users_retrieve_authenticated_self():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/users/{user.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
)
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.json() == {"detail": 'Method "GET" not allowed.'}
|
||||
@@ -349,12 +404,14 @@ def test_api_users_retrieve_authenticated_other():
|
||||
limited information.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
jwt_token = OIDCToken.for_user(identity.user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/users/{other_user.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/{other_user.id!s}/",
|
||||
)
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.json() == {"detail": 'Method "GET" not allowed.'}
|
||||
@@ -380,16 +437,17 @@ def test_api_users_create_authenticated():
|
||||
"""Authenticated users should not be able to create users via the API."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
response = APIClient().post(
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/users/",
|
||||
{
|
||||
"language": "fr-fr",
|
||||
"password": "mypassword",
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.json() == {"detail": 'Method "POST" not allowed.'}
|
||||
@@ -427,18 +485,19 @@ def test_api_users_update_authenticated_self():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
new_user_values = dict(
|
||||
serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
)
|
||||
|
||||
response = APIClient().put(
|
||||
response = client.put(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
new_user_values,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
@@ -454,17 +513,18 @@ def test_api_users_update_authenticated_self():
|
||||
def test_api_users_update_authenticated_other():
|
||||
"""Authenticated users should not be allowed to update other users."""
|
||||
identity = factories.IdentityFactory()
|
||||
jwt_token = OIDCToken.for_user(identity.user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
user = factories.UserFactory()
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
|
||||
response = APIClient().put(
|
||||
response = client.put(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
new_user_values,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
@@ -507,7 +567,9 @@ def test_api_users_patch_authenticated_self():
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
jwt_token = OIDCToken.for_user(user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
new_user_values = dict(
|
||||
@@ -515,11 +577,10 @@ def test_api_users_patch_authenticated_self():
|
||||
)
|
||||
|
||||
for key, new_value in new_user_values.items():
|
||||
response = APIClient().patch(
|
||||
response = client.patch(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
{key: new_value},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
@@ -535,7 +596,9 @@ def test_api_users_patch_authenticated_self():
|
||||
def test_api_users_patch_authenticated_other():
|
||||
"""Authenticated users should not be allowed to patch other users."""
|
||||
identity = factories.IdentityFactory()
|
||||
jwt_token = OIDCToken.for_user(identity.user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
user = factories.UserFactory()
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
@@ -544,11 +607,10 @@ def test_api_users_patch_authenticated_other():
|
||||
)
|
||||
|
||||
for key, new_value in new_user_values.items():
|
||||
response = APIClient().put(
|
||||
response = client.put(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
{key: new_value},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
|
||||
@@ -573,11 +635,12 @@ def test_api_users_delete_list_authenticated():
|
||||
"""Authenticated users should not be allowed to delete a list of users."""
|
||||
factories.UserFactory.create_batch(2)
|
||||
identity = factories.IdentityFactory()
|
||||
jwt_token = OIDCToken.for_user(identity.user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.delete(
|
||||
"/api/v1.0/users/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
"/api/v1.0/users/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
@@ -599,12 +662,12 @@ def test_api_users_delete_authenticated():
|
||||
Authenticated users should not be allowed to delete a user other than themselves.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
jwt_token = OIDCToken.for_user(identity.user)
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/users/{other_user.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.delete(f"/api/v1.0/users/{other_user.id!s}/")
|
||||
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert models.User.objects.count() == 2
|
||||
@@ -613,11 +676,12 @@ def test_api_users_delete_authenticated():
|
||||
def test_api_users_delete_self():
|
||||
"""Authenticated users should not be able to delete their own user."""
|
||||
identity = factories.IdentityFactory()
|
||||
jwt_token = OIDCToken.for_user(identity.user)
|
||||
|
||||
response = APIClient().delete(
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/users/{identity.user.id!s}/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Unit tests for the `get_or_create_user` function."""
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import pytest
|
||||
|
||||
from core import models
|
||||
from core.authentication import OIDCAuthenticationBackend
|
||||
from core.factories import IdentityFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user matches the user's info sub, the user should be returned.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
# Create a user and its identity
|
||||
identity = IdentityFactory(name=None)
|
||||
|
||||
# Create multiple identities for a user
|
||||
for _ in range(5):
|
||||
IdentityFactory(user=identity.user)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": identity.sub}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
identity.refresh_from_db()
|
||||
assert user == identity.user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_with_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
When the user's info contains an email and targets an existing user,
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
identity = IdentityFactory(name="John Doe")
|
||||
|
||||
# Create multiple identities for a user
|
||||
for _ in range(5):
|
||||
IdentityFactory(user=identity.user)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": identity.sub,
|
||||
"email": identity.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# Only 1 query because email and names have not changed
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert models.User.objects.get() == user
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"first_name, last_name, email",
|
||||
[
|
||||
("Jack", "Doe", "john.doe@example.com"),
|
||||
("John", "Duy", "john.doe@example.com"),
|
||||
("John", "Doe", "jack.duy@example.com"),
|
||||
("Jack", "Duy", "jack.duy@example.com"),
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_existing_user_change_fields(
|
||||
first_name, last_name, email, django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
It should update the email or name fields on the identity when they change.
|
||||
The email on the user should not be changed.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
identity = IdentityFactory(name="John Doe", email="john.doe@example.com")
|
||||
user_email = identity.user.email
|
||||
|
||||
# Create multiple identities for a user
|
||||
for _ in range(5):
|
||||
IdentityFactory(user=identity.user)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": identity.sub,
|
||||
"email": email,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(2):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
identity.refresh_from_db()
|
||||
assert identity.email == email
|
||||
assert identity.name == f"{first_name:s} {last_name:s}"
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
assert user == identity.user
|
||||
assert user.email == user_email
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
User's info doesn't contain an email, created user's email should be empty.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
identity = user.identities.get()
|
||||
assert identity.sub == "123"
|
||||
assert identity.email is None
|
||||
|
||||
assert user.email is None
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
User's email and name should be set on the identity.
|
||||
The "email" field on the User model should not be set as it is reserved for staff users.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
email = "people@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
identity = user.identities.get()
|
||||
assert identity.sub == "123"
|
||||
assert identity.email == email
|
||||
assert identity.name == "John Doe"
|
||||
|
||||
assert user.email is None
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkeypatch):
|
||||
"""The user's info doesn't contain a sub."""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"test": "123",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(0), pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info contained no recognizable user identification",
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
@@ -52,7 +52,7 @@ def test_models_contacts_base_to_base():
|
||||
|
||||
|
||||
def test_models_contacts_owner_base_unique():
|
||||
"""Their should be only one contact deriving from a given base contact for a given owner."""
|
||||
"""There should be only one contact deriving from a given base contact for a given owner."""
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
|
||||
@@ -70,19 +70,19 @@ def test_models_identities_is_main_switch():
|
||||
|
||||
|
||||
def test_models_identities_email_not_required():
|
||||
"""The "email" field can be blank."""
|
||||
"""The 'email' field can be blank."""
|
||||
user = factories.UserFactory()
|
||||
models.Identity.objects.create(user=user, sub="123", email=None)
|
||||
|
||||
|
||||
def test_models_identities_user_required():
|
||||
"""The "user" field is required."""
|
||||
"""The 'user' field is required."""
|
||||
with pytest.raises(models.User.DoesNotExist, match="Identity has no user."):
|
||||
models.Identity.objects.create(user=None, email="david@example.com")
|
||||
|
||||
|
||||
def test_models_identities_email_unique_same_user():
|
||||
"""The "email" field should be unique for a given user."""
|
||||
"""The 'email' field should be unique for a given user."""
|
||||
email = factories.IdentityFactory()
|
||||
|
||||
with pytest.raises(
|
||||
@@ -93,13 +93,13 @@ def test_models_identities_email_unique_same_user():
|
||||
|
||||
|
||||
def test_models_identities_email_unique_different_users():
|
||||
"""The "email" field should not be unique among users."""
|
||||
"""The 'email' field should not be unique among users."""
|
||||
email = factories.IdentityFactory()
|
||||
factories.IdentityFactory(email=email.email)
|
||||
|
||||
|
||||
def test_models_identities_email_normalization():
|
||||
"""The email field should be automatically normalized upon saving."""
|
||||
"""The 'email' field should be automatically normalized upon saving."""
|
||||
email = factories.IdentityFactory()
|
||||
email.email = "Thomas.Jefferson@Example.com"
|
||||
email.save()
|
||||
@@ -107,7 +107,7 @@ def test_models_identities_email_normalization():
|
||||
|
||||
|
||||
def test_models_identities_ordering():
|
||||
"""Identitys should be returned ordered by main status then by their email address."""
|
||||
"""Identities should be returned ordered by main status then by their email address."""
|
||||
user = factories.UserFactory()
|
||||
factories.IdentityFactory.create_batch(5, user=user)
|
||||
|
||||
@@ -120,21 +120,21 @@ def test_models_identities_ordering():
|
||||
|
||||
|
||||
def test_models_identities_sub_null():
|
||||
"""The "sub" field should not be null."""
|
||||
"""The 'sub' field should not be null."""
|
||||
user = factories.UserFactory()
|
||||
with pytest.raises(ValidationError, match="This field cannot be null."):
|
||||
models.Identity.objects.create(user=user, sub=None)
|
||||
|
||||
|
||||
def test_models_identities_sub_blank():
|
||||
"""The "sub" field should not be blank."""
|
||||
"""The 'sub' field should not be blank."""
|
||||
user = factories.UserFactory()
|
||||
with pytest.raises(ValidationError, match="This field cannot be blank."):
|
||||
models.Identity.objects.create(user=user, email="david@example.com", sub="")
|
||||
|
||||
|
||||
def test_models_identities_sub_unique():
|
||||
"""The "sub" field should be unique."""
|
||||
"""The 'sub' field should be unique."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
with pytest.raises(ValidationError, match="Identity with this Sub already exists."):
|
||||
@@ -142,7 +142,7 @@ def test_models_identities_sub_unique():
|
||||
|
||||
|
||||
def test_models_identities_sub_max_length():
|
||||
"""The sub field should be 255 characters maximum."""
|
||||
"""The 'sub' field should be 255 characters maximum."""
|
||||
factories.IdentityFactory(sub="a" * 255)
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
factories.IdentityFactory(sub="a" * 256)
|
||||
@@ -154,13 +154,13 @@ def test_models_identities_sub_max_length():
|
||||
|
||||
|
||||
def test_models_identities_sub_special_characters():
|
||||
"""The sub field should accept periods, dashes, +, @ and underscores."""
|
||||
"""The 'sub' field should accept periods, dashes, +, @ and underscores."""
|
||||
identity = factories.IdentityFactory(sub="dave.bowman-1+2@hal_9000")
|
||||
assert identity.sub == "dave.bowman-1+2@hal_9000"
|
||||
|
||||
|
||||
def test_models_identities_sub_spaces():
|
||||
"""The sub field should not accept spaces."""
|
||||
"""The 'sub' field should not accept spaces."""
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
factories.IdentityFactory(sub="a b")
|
||||
|
||||
@@ -171,12 +171,12 @@ def test_models_identities_sub_spaces():
|
||||
|
||||
|
||||
def test_models_identities_sub_upper_case():
|
||||
"""The sub field should accept upper case characters."""
|
||||
"""The 'sub' field should accept upper case characters."""
|
||||
identity = factories.IdentityFactory(sub="John")
|
||||
assert identity.sub == "John"
|
||||
|
||||
|
||||
def test_models_identities_sub_ascii():
|
||||
"""The sub field should accept non ASCII letters."""
|
||||
"""The 'sub' field should accept non ASCII letters."""
|
||||
identity = factories.IdentityFactory(sub="rené")
|
||||
assert identity.sub == "rené"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Unit tests for the Invitation model
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_models_invitations_email_no_empty_mail():
|
||||
"""The "email" field should not be empty."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be blank"):
|
||||
factories.InvitationFactory(email="")
|
||||
|
||||
|
||||
def test_models_invitations_email_no_null_mail():
|
||||
"""The "email" field is required."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be null"):
|
||||
factories.InvitationFactory(email=None)
|
||||
|
||||
|
||||
def test_models_invitations_team_required():
|
||||
"""The "team" field is required."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be null"):
|
||||
factories.InvitationFactory(team=None)
|
||||
|
||||
|
||||
def test_models_invitations_team_should_be_team_instance():
|
||||
"""The "team" field should be a team instance."""
|
||||
with pytest.raises(ValueError, match='Invitation.team" must be a "Team" instance'):
|
||||
factories.InvitationFactory(team="ee")
|
||||
|
||||
|
||||
def test_models_invitations_role_required():
|
||||
"""The "role" field is required."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be blank"):
|
||||
factories.InvitationFactory(role="")
|
||||
|
||||
|
||||
def test_models_invitations_role_among_choices():
|
||||
"""The "role" field should be a valid choice."""
|
||||
with pytest.raises(ValidationError, match="Value 'boss' is not a valid choice"):
|
||||
factories.InvitationFactory(role="boss")
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Unit tests for the `oidc_user_getter` function."""
|
||||
import pytest
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken
|
||||
from rest_framework_simplejwt.tokens import AccessToken
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_models_oidc_user_getter_existing_user_no_email(django_assert_num_queries):
|
||||
"""
|
||||
When a valid token is passed, an existing user matching the token's sub should be returned.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
factories.IdentityFactory(user=identity.user) # another identity for the user
|
||||
token = AccessToken()
|
||||
token["sub"] = str(identity.sub)
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = models.oidc_user_getter(token)
|
||||
|
||||
identity.refresh_from_db()
|
||||
assert user == identity.user
|
||||
|
||||
|
||||
def test_models_oidc_user_getter_existing_user_with_email(django_assert_num_queries):
|
||||
"""
|
||||
When the valid token passed contains an email and targets an existing user,
|
||||
it should update the email on the identity but not on the user.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
factories.IdentityFactory(user=identity.user) # another identity for the user
|
||||
user_email = identity.user.email
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
token = AccessToken()
|
||||
token["sub"] = str(identity.sub)
|
||||
|
||||
# Only 1 query if the email has not changed
|
||||
token["email"] = identity.email
|
||||
with django_assert_num_queries(1):
|
||||
user = models.oidc_user_getter(token)
|
||||
|
||||
# Additional update query if the email has changed
|
||||
new_email = "people@example.com"
|
||||
token["email"] = new_email
|
||||
with django_assert_num_queries(2):
|
||||
user = models.oidc_user_getter(token)
|
||||
|
||||
identity.refresh_from_db()
|
||||
assert identity.email == new_email
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
assert user == identity.user
|
||||
assert user.email == user_email
|
||||
|
||||
|
||||
def test_models_oidc_user_getter_new_user_no_email():
|
||||
"""
|
||||
When a valid token is passed, a user should be created if the sub
|
||||
does not match any existing user.
|
||||
"""
|
||||
token = AccessToken()
|
||||
token["sub"] = "123"
|
||||
|
||||
user = models.oidc_user_getter(token)
|
||||
|
||||
identity = user.identities.get()
|
||||
assert identity.sub == "123"
|
||||
assert identity.email is None
|
||||
|
||||
assert user.email is None
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_models_oidc_user_getter_new_user_with_email():
|
||||
"""
|
||||
When the valid token passed contains an email and a new user is created,
|
||||
the email should be set on the user and on the identity.
|
||||
"""
|
||||
email = "people@example.com"
|
||||
token = AccessToken()
|
||||
token["sub"] = "123"
|
||||
token["email"] = email
|
||||
|
||||
user = models.oidc_user_getter(token)
|
||||
|
||||
identity = user.identities.get()
|
||||
assert identity.sub == "123"
|
||||
assert identity.email == email
|
||||
|
||||
assert user.email == email
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_models_oidc_user_getter_invalid_token(django_assert_num_queries):
|
||||
"""The token passed in argument should contain the configured user id claim."""
|
||||
token = AccessToken()
|
||||
|
||||
with django_assert_num_queries(0), pytest.raises(
|
||||
InvalidToken, match="Token contained no recognizable user identification"
|
||||
):
|
||||
models.oidc_user_getter(token)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
@@ -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:
|
||||
|
||||
@@ -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,25 +0,0 @@
|
||||
"""Utils for tests in the People core application"""
|
||||
from rest_framework_simplejwt.tokens import AccessToken
|
||||
|
||||
|
||||
class OIDCToken(AccessToken):
|
||||
"""Set payload on token from user/contact/email"""
|
||||
|
||||
@classmethod
|
||||
def for_user(cls, user):
|
||||
"""Returns an authorization token for the given user for testing."""
|
||||
identity = user.identities.filter(is_main=True).first()
|
||||
|
||||
token = cls()
|
||||
token["first_name"] = (
|
||||
user.profile_contact.short_name if user.profile_contact else "David"
|
||||
)
|
||||
token["last_name"] = (
|
||||
" ".join(user.profile_contact.full_name.split()[1:])
|
||||
if user.profile_contact
|
||||
else "Bowman"
|
||||
)
|
||||
token["sub"] = str(identity.sub)
|
||||
token["email"] = user.email
|
||||
|
||||
return token
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Tokens for Magnify's core app."""
|
||||
from rest_framework_simplejwt.settings import api_settings
|
||||
from rest_framework_simplejwt.tokens import Token
|
||||
|
||||
|
||||
class BearerToken(Token):
|
||||
"""Bearer token as emitted by Keycloak OIDC for example."""
|
||||
|
||||
token_type = "Bearer" # noqa: S105
|
||||
lifetime = api_settings.ACCESS_TOKEN_LIFETIME
|
||||
@@ -11,10 +11,14 @@ from django import db
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from faker import Faker
|
||||
|
||||
from core import models
|
||||
|
||||
from demo import defaults
|
||||
|
||||
fake = Faker()
|
||||
|
||||
logger = logging.getLogger("people.commands.demo.create_demo")
|
||||
|
||||
|
||||
@@ -134,6 +138,7 @@ def create_demo(stdout):
|
||||
sub=uuid4(),
|
||||
email=f"identity{i:d}{user_email:s}",
|
||||
is_main=(i == 0),
|
||||
name=fake.name(),
|
||||
)
|
||||
)
|
||||
queue.flush()
|
||||
|
||||
@@ -2,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),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Django settings for People project.
|
||||
Django's settings for People project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 3.1.5.
|
||||
|
||||
@@ -175,13 +175,15 @@ class Base(Configuration):
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"dockerflow.django.middleware.DockerflowMiddleware",
|
||||
]
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
"core.authentication.OIDCAuthenticationBackend",
|
||||
]
|
||||
|
||||
# Django applications from the highest priority to the lowest
|
||||
# Django's applications from the highest priority to the lowest
|
||||
INSTALLED_APPS = [
|
||||
# People
|
||||
"core",
|
||||
@@ -202,6 +204,8 @@ class Base(Configuration):
|
||||
"django.contrib.sites",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# OIDC third party
|
||||
"mozilla_django_oidc",
|
||||
]
|
||||
|
||||
# Cache
|
||||
@@ -211,7 +215,8 @@ class Base(Configuration):
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"core.authentication.DelegatedJWTAuthentication",
|
||||
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
"rest_framework.parsers.JSONParser",
|
||||
@@ -223,7 +228,18 @@ class Base(Configuration):
|
||||
"PAGE_SIZE": 20,
|
||||
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"DEFAULT_THROTTLE_RATES": {"sustained": "150/hour", "burst": "20/minute"},
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"sustained": values.Value(
|
||||
default="150/hour",
|
||||
environ_name="SUSTAINED_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"burst": values.Value(
|
||||
default="20/minute",
|
||||
environ_name="BURST_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
@@ -242,34 +258,6 @@ class Base(Configuration):
|
||||
"REDOC_DIST": "SIDECAR",
|
||||
}
|
||||
|
||||
# Simple JWT
|
||||
SIMPLE_JWT = {
|
||||
"ALGORITHM": values.Value(
|
||||
"RS256", environ_name="SIMPLE_JWT_ALGORITHM", environ_prefix=None
|
||||
),
|
||||
"JWK_URL": values.Value(
|
||||
None, environ_name="SIMPLE_JWT_JWK_URL", environ_prefix=None
|
||||
),
|
||||
"SIGNING_KEY": values.Value(
|
||||
None, environ_name="SIMPLE_JWT_SIGNING_KEY", environ_prefix=None
|
||||
),
|
||||
"VERIFYING_KEY": values.Value(
|
||||
None, environ_name="SIMPLE_JWT_VERIFYING_KEY", environ_prefix=None
|
||||
),
|
||||
"AUTH_HEADER_TYPES": ("Bearer",),
|
||||
"AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
|
||||
"TOKEN_TYPE_CLAIM": "typ",
|
||||
"USER_ID_FIELD": "sub",
|
||||
"USER_ID_CLAIM": "sub",
|
||||
"AUTH_TOKEN_CLASSES": ("core.tokens.BearerToken",),
|
||||
}
|
||||
|
||||
JWT_USER_GETTER = values.Value(
|
||||
"core.models.oidc_user_getter",
|
||||
environ_name="PEOPLE_JWT_USER_GETTER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Mail
|
||||
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
|
||||
EMAIL_HOST = values.Value(None)
|
||||
@@ -280,6 +268,7 @@ class Base(Configuration):
|
||||
EMAIL_FROM = values.Value("from@example.com")
|
||||
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
@@ -299,6 +288,68 @@ class Base(Configuration):
|
||||
CELERY_BROKER_URL = values.Value("redis://redis:6379/0")
|
||||
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({})
|
||||
|
||||
# Session
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
SESSION_COOKIE_AGE = 60 * 60 * 12 # 12 hours to match Agent Connect
|
||||
|
||||
# OIDC - Authorization Code Flow
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=True,
|
||||
environ_name="OIDC_CREATE_USER",
|
||||
)
|
||||
OIDC_RP_SIGN_ALGO = values.Value(
|
||||
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
|
||||
)
|
||||
OIDC_RP_CLIENT_ID = values.Value(
|
||||
"people", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None
|
||||
)
|
||||
OIDC_RP_CLIENT_SECRET = values.Value(
|
||||
environ_name="OIDC_RP_CLIENT_SECRET",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_OP_JWKS_ENDPOINT = values.Value(
|
||||
environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT = values.Value(
|
||||
environ_name="OIDC_OP_AUTHORIZATION_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_TOKEN_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_TOKEN_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_USER_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue(
|
||||
{}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None
|
||||
)
|
||||
OIDC_RP_SCOPES = values.Value(
|
||||
"openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None
|
||||
)
|
||||
LOGIN_REDIRECT_URL = values.Value(
|
||||
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
|
||||
)
|
||||
LOGIN_REDIRECT_URL_FAILURE = values.Value(
|
||||
None, environ_name="LOGIN_REDIRECT_URL_FAILURE", environ_prefix=None
|
||||
)
|
||||
LOGOUT_REDIRECT_URL = values.Value(
|
||||
None, environ_name="LOGOUT_REDIRECT_URL", environ_prefix=None
|
||||
)
|
||||
OIDC_USE_NONCE = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_USE_NONCE", environ_prefix=None
|
||||
)
|
||||
OIDC_REDIRECT_REQUIRE_HTTPS = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_REDIRECT_REQUIRE_HTTPS", environ_prefix=None
|
||||
)
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS = values.ListValue(
|
||||
default=[], environ_name="OIDC_REDIRECT_ALLOWED_HOSTS", environ_prefix=None
|
||||
)
|
||||
|
||||
USER_OIDC_FIELDS_TO_NAME = values.ListValue(
|
||||
default=["first_name", "last_name"],
|
||||
environ_name="USER_OIDC_FIELDS_TO_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -379,7 +430,7 @@ class Development(Base):
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072"]
|
||||
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072", "http://localhost:3000"]
|
||||
DEBUG = True
|
||||
|
||||
SESSION_COOKIE_NAME = "people_sessionid"
|
||||
@@ -394,10 +445,6 @@ class Development(Base):
|
||||
class Test(Base):
|
||||
"""Test environment settings"""
|
||||
|
||||
SIMPLE_JWT = {
|
||||
"USER_ID_FIELD": "sub",
|
||||
"USER_ID_CLAIM": "sub",
|
||||
}
|
||||
LOGGING = values.DictValue(
|
||||
{
|
||||
"version": 1,
|
||||
@@ -521,3 +568,22 @@ class PreProduction(Production):
|
||||
|
||||
nota bene: it should inherit from the Production environment.
|
||||
"""
|
||||
|
||||
|
||||
class Demo(Production):
|
||||
"""
|
||||
Demonstration environment settings
|
||||
|
||||
nota bene: it should inherit from the Production environment.
|
||||
"""
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = ["http://localhost:8082"]
|
||||
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.34.39",
|
||||
"boto3==1.34.51",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.3.6",
|
||||
"django-configurations==2.5",
|
||||
@@ -35,10 +35,9 @@ dependencies = [
|
||||
"django-storages==1.14.2",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.0.2",
|
||||
"djangorestframework-simplejwt[crypto]==5.3.1",
|
||||
"djangorestframework==3.14.0",
|
||||
"drf_spectacular==0.27.1",
|
||||
"dockerflow==2024.1.0",
|
||||
"dockerflow==2024.2.0",
|
||||
"easy_thumbnails==2.8.5",
|
||||
"factory_boy==3.3.0",
|
||||
"gunicorn==21.2.0",
|
||||
@@ -47,9 +46,10 @@ dependencies = [
|
||||
"psycopg[binary]==3.1.18",
|
||||
"PyJWT==2.8.0",
|
||||
"requests==2.31.0",
|
||||
"sentry-sdk==1.40.3",
|
||||
"sentry-sdk==1.40.6",
|
||||
"url-normalize==1.4.3",
|
||||
"whitenoise==6.6.0",
|
||||
"mozilla-django-oidc==4.0.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -63,18 +63,18 @@ dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.2.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.21.0",
|
||||
"ipython==8.22.1",
|
||||
"pyfakefs==5.3.5",
|
||||
"pylint-django==2.5.5",
|
||||
"pylint==3.0.3",
|
||||
"pylint==3.1.0",
|
||||
"pytest-cov==4.1.0",
|
||||
"pytest-django==4.8.0",
|
||||
"pytest==8.0.0",
|
||||
"pytest==8.0.2",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.5.0",
|
||||
"responses==0.24.1",
|
||||
"ruff==0.2.1",
|
||||
"types-requests==2.31.0.20240125",
|
||||
"responses==0.25.0",
|
||||
"ruff==0.2.2",
|
||||
"types-requests==2.31.0.20240218",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,8 @@ const config = {
|
||||
default: {
|
||||
theme: {
|
||||
colors: {
|
||||
'card-border': '#DDDDDD',
|
||||
'primary-bg': '#FAFAFA',
|
||||
'primary-100': '#EDF5FA',
|
||||
'primary-150': '#E5EEFA',
|
||||
'info-150': '#E5EEFA',
|
||||
@@ -76,6 +78,7 @@ const config = {
|
||||
'forms-labelledbox': {
|
||||
'label-color': {
|
||||
small: 'var(--c--theme--colors--primary-500)',
|
||||
'small-disabled': 'var(--c--theme--colors--greyscale-400)',
|
||||
big: {
|
||||
disabled: 'var(--c--theme--colors--greyscale-400)',
|
||||
},
|
||||
@@ -83,6 +86,8 @@ const config = {
|
||||
},
|
||||
'forms-select': {
|
||||
'border-color': 'var(--c--theme--colors--primary-500)',
|
||||
'border-color-disabled-hover':
|
||||
'var(--c--theme--colors--greyscale-200)',
|
||||
'border-radius': {
|
||||
hover: 'var(--c--components--forms-select--border-radius)',
|
||||
focus: 'var(--c--components--forms-select--border-radius)',
|
||||
@@ -175,6 +180,7 @@ const config = {
|
||||
dsfr: {
|
||||
theme: {
|
||||
colors: {
|
||||
'card-border': '#DDDDDD',
|
||||
'primary-text': '#000091',
|
||||
'primary-100': '#f5f5fe',
|
||||
'primary-200': '#ececfe',
|
||||
@@ -195,10 +201,11 @@ const config = {
|
||||
'secondary-700': '#3b2424',
|
||||
'secondary-800': '#341f1f',
|
||||
'secondary-900': '#2b1919',
|
||||
'greyscale-000': '#cecece',
|
||||
'greyscale-100': '#f6f6f6',
|
||||
'greyscale-200': '#eeeeee',
|
||||
'greyscale-300': '#e5e5e5',
|
||||
'greyscale-text': '#303C4B',
|
||||
'greyscale-000': '#f6f6f6',
|
||||
'greyscale-100': '#eeeeee',
|
||||
'greyscale-200': '#e5e5e5',
|
||||
'greyscale-300': '#e1e1e1',
|
||||
'greyscale-400': '#dddddd',
|
||||
'greyscale-500': '#cecece',
|
||||
'greyscale-600': '#7b7b7b',
|
||||
@@ -258,28 +265,75 @@ const config = {
|
||||
'border-radius': '0',
|
||||
},
|
||||
button: {
|
||||
'border-radius': '2px',
|
||||
'border-radius': '4px',
|
||||
primary: {
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
'color-hover': 'var(--c--theme--colors--primary-700)',
|
||||
'color-active': 'var(--c--theme--colors--primary-900)',
|
||||
},
|
||||
color: '#ffffff',
|
||||
'color-hover': '#ffffff',
|
||||
'color-active': '#ffffff',
|
||||
},
|
||||
secondary: {
|
||||
background: {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
'color-active': 'var(--c--theme--colors--primary-200)',
|
||||
},
|
||||
border: {
|
||||
'color-hover': 'var(--c--theme--colors--primary-300)',
|
||||
},
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
},
|
||||
datagrid: {
|
||||
header: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
size: 'var(--c--theme--font--sizes--s)',
|
||||
},
|
||||
body: {
|
||||
'background-color': 'transparent',
|
||||
'background-color-hover': '#F4F4FD',
|
||||
},
|
||||
pagination: {
|
||||
'background-color': 'transparent',
|
||||
'background-color-active': 'var(--c--theme--colors--primary-300)',
|
||||
},
|
||||
},
|
||||
'forms-checkbox': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'forms-switch': {
|
||||
'handle-border-radius': '2px',
|
||||
'rail-border-radius': '4px',
|
||||
},
|
||||
'forms-input': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'forms-select': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'forms-datepicker': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'forms-textarea': {
|
||||
'forms-fileuploader': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'forms-fileuploader': {
|
||||
'forms-input': {
|
||||
'border-radius': '4px',
|
||||
'background-color': '#ffffff',
|
||||
'border-color': 'var(--c--theme--colors--primary-text)',
|
||||
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-labelledbox': {
|
||||
'label-color': {
|
||||
big: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
},
|
||||
'forms-select': {
|
||||
'border-radius': '4px',
|
||||
'border-radius-hover': '4px',
|
||||
'background-color': '#ffffff',
|
||||
'border-color': 'var(--c--theme--colors--primary-text)',
|
||||
'border-color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-switch': {
|
||||
'handle-border-radius': '2px',
|
||||
'rail-border-radius': '4px',
|
||||
},
|
||||
'forms-textarea': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
compiler: {
|
||||
// Enables the styled-components SWC transform
|
||||
styledComponents: true,
|
||||
},
|
||||
webpack(config) {
|
||||
// Grab the existing rule that handles SVG imports
|
||||
const fileLoaderRule = config.module.rules.find((rule) =>
|
||||
|
||||
@@ -16,30 +16,31 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@openfun/cunningham-react": "2.4.0",
|
||||
"@tanstack/react-query": "5.18.1",
|
||||
"i18next": "23.7.16",
|
||||
"next": "14.1.0",
|
||||
"@tanstack/react-query": "5.24.8",
|
||||
"i18next": "23.10.0",
|
||||
"luxon": "3.4.4",
|
||||
"next": "14.1.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-i18next": "14.0.0",
|
||||
"react-i18next": "14.0.5",
|
||||
"styled-components": "6.1.8",
|
||||
"zustand": "4.5.0"
|
||||
"zustand": "4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.18.1",
|
||||
"@testing-library/jest-dom": "6.4.1",
|
||||
"@tanstack/react-query-devtools": "5.24.8",
|
||||
"@testing-library/jest-dom": "6.4.2",
|
||||
"@testing-library/react": "14.2.1",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/luxon": "3.4.2",
|
||||
"@types/node": "*",
|
||||
"@types/react": "18.2.53",
|
||||
"@types/react": "18.2.61",
|
||||
"@types/react-dom": "18.2.18",
|
||||
"dotenv": "16.4.1",
|
||||
"dotenv": "16.4.5",
|
||||
"eslint-config-people": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"keycloak-js": "23.0.6",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.2.5",
|
||||
"stylelint": "16.2.1",
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -3,16 +3,16 @@ import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import Page from '../page';
|
||||
import Page from '../pages';
|
||||
|
||||
describe('Page', () => {
|
||||
it('checks Page rendering', () => {
|
||||
render(<Page />, { wrapper: AppWrapper });
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
|
||||
'Hello Desk !',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /Create a new team/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
interface IAPIError {
|
||||
status: number;
|
||||
cause?: string[];
|
||||
}
|
||||
|
||||
export class APIError extends Error implements IAPIError {
|
||||
public status: number;
|
||||
public cause?: string[];
|
||||
|
||||
constructor(message: string, { status, cause }: IAPIError) {
|
||||
super(message);
|
||||
|
||||
this.name = 'APIError';
|
||||
this.status = status;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { useAuthStore } from '@/features/';
|
||||
|
||||
import { fetchAPI } from '../fetchApi';
|
||||
import { fetchAPI } from '@/api';
|
||||
import { useAuthStore } from '@/features/auth';
|
||||
|
||||
describe('fetchAPI', () => {
|
||||
beforeEach(() => {
|
||||
@@ -20,29 +19,40 @@ describe('fetchAPI', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the BEARER automatically', () => {
|
||||
useAuthStore.setState({ token: 'my-token' });
|
||||
|
||||
it('adds the credentials automatically', () => {
|
||||
fetchMock.mock('http://some.api.url/api/v1.0/some/url', 200);
|
||||
|
||||
void fetchAPI('some/url', { body: 'some body' });
|
||||
|
||||
expect(fetchMock.lastOptions()).toEqual({
|
||||
body: 'some body',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Authorization: 'Bearer my-token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('logout if 401 response', async () => {
|
||||
useAuthStore.setState({ token: 'my-token' });
|
||||
const mockReplace = jest.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: {
|
||||
replace: mockReplace,
|
||||
},
|
||||
});
|
||||
|
||||
useAuthStore.setState({ userData: { email: 'test@test.com' } });
|
||||
|
||||
fetchMock.mock('http://some.api.url/api/v1.0/some/url', 401);
|
||||
|
||||
await fetchAPI('some/url');
|
||||
|
||||
expect(useAuthStore.getState().token).toBeNull();
|
||||
expect(useAuthStore.getState().userData).toBeUndefined();
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith(
|
||||
'http://some.api.url/api/v1.0/authenticate/',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
import { useAuthStore } from '@/features/';
|
||||
import { login, useAuthStore } from '@/features/auth';
|
||||
|
||||
/**
|
||||
* Retrieves the CSRF token from the document's cookies.
|
||||
*
|
||||
* @returns {string|null} The CSRF token if found in the cookies, or null if not present.
|
||||
*/
|
||||
function getCSRFToken() {
|
||||
return document.cookie
|
||||
.split(';')
|
||||
.filter((cookie) => cookie.trim().startsWith('csrftoken='))
|
||||
.map((cookie) => cookie.split('=')[1])
|
||||
.pop();
|
||||
}
|
||||
|
||||
export const fetchAPI = async (input: string, init?: RequestInit) => {
|
||||
const apiUrl = `${process.env.NEXT_PUBLIC_API_URL}${input}`;
|
||||
const { token, logout } = useAuthStore.getState();
|
||||
const { logout } = useAuthStore.getState();
|
||||
|
||||
const csrfToken = getCSRFToken();
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...init?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(csrfToken && { 'X-CSRFToken': csrfToken }),
|
||||
},
|
||||
});
|
||||
|
||||
response.status === 401 && logout();
|
||||
// todo - handle 401, redirect to login screen
|
||||
// todo - please have a look to this documentation page https://mozilla-django-oidc.readthedocs.io/en/stable/xhr.html
|
||||
if (response.status === 401) {
|
||||
logout();
|
||||
// Fix - force re-logging the user, will be refactored
|
||||
login();
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './APIError';
|
||||
export * from './fetchApi';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export const errorCauses = async (response: Response) => {
|
||||
const errorsBody = (await response.json()) as Record<
|
||||
string,
|
||||
string | string[]
|
||||
> | null;
|
||||
|
||||
const causes = errorsBody
|
||||
? Object.entries(errorsBody)
|
||||
.map(([, value]) => value)
|
||||
.flat()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
cause: causes,
|
||||
};
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Box } from '@/components';
|
||||
import { HEADER_HEIGHT, Header, Menu } from '@/features/';
|
||||
|
||||
export default function InnerLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box as="main" $direction="column" $height="100vh" $css="overflow:hidden;">
|
||||
<Header />
|
||||
<Box $css="flex: 1;">
|
||||
<Menu />
|
||||
<Box
|
||||
$direction="column"
|
||||
$height={`calc(100vh - ${HEADER_HEIGHT})`}
|
||||
$width="100%"
|
||||
$css="overflow: auto;"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
export default function Contacts() {
|
||||
return <Box>Contacts</Box>;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
export default function Favorite() {
|
||||
return <Box>Favorite</Box>;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
@import url('../cunningham/cunningham-style.css');
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
export default function Groups() {
|
||||
return <Box>Groups</Box>;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
export default function Help() {
|
||||
return <Box>Help</Box>;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { CunninghamProvider } from '@openfun/cunningham-react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Auth } from '@/features/auth/Auth';
|
||||
import '@/i18n/initI18n';
|
||||
|
||||
import InnerLayout from './InnerLayout';
|
||||
import './globals.css';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { theme } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body suppressHydrationWarning={process.env.NODE_ENV === 'development'}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryDevtools />
|
||||
<CunninghamProvider theme={theme}>
|
||||
<Auth>
|
||||
<InnerLayout>{children}</InnerLayout>
|
||||
</Auth>
|
||||
</CunninghamProvider>
|
||||
</QueryClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { Teams } from '@/features';
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box $direction="column" className="p-b">
|
||||
<h1>{t('Hello Desk !')}</h1>
|
||||
<Teams />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
export default function Recent() {
|
||||
return <Box>Recent</Box>;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1020 B After Width: | Height: | Size: 1020 B |
@@ -0,0 +1,29 @@
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_179_19260)">
|
||||
<path
|
||||
d="M12.6406 26.02C14.5606 26.06 16.3406 27.02 17.5406 28.7C19.0006 30.76 21.4206 32 24.0006 32C26.5806 32 29.0006 30.76 30.4606 28.68C31.6606 27 33.4406 26.04 35.3606 26C33.9206 23.56 28.1606 22 24.0006 22C19.8606 22 14.0806 23.56 12.6406 26.02Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M8 26C11.32 26 14 23.32 14 20C14 16.68 11.32 14 8 14C4.68 14 2 16.68 2 20C2 23.32 4.68 26 8 26Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M40 26C43.32 26 46 23.32 46 20C46 16.68 43.32 14 40 14C36.68 14 34 16.68 34 20C34 23.32 36.68 26 40 26Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M24 20C27.32 20 30 17.32 30 14C30 10.68 27.32 8 24 8C20.68 8 18 10.68 18 14C18 17.32 20.68 20 24 20Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M42 28H35.46C33.92 28 32.76 28.9 32.1 29.84C32.02 29.96 29.38 34 24 34C21.14 34 17.94 32.72 15.9 29.84C15.12 28.74 13.9 28 12.54 28H6C3.8 28 2 29.8 2 32V40H16V35.48C18.3 37.08 21.08 38 24 38C26.92 38 29.7 37.08 32 35.48V40H46V32C46 29.8 44.2 28 42 28Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_179_19260">
|
||||
<rect width="48" height="48" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M20 0C8.96 0 0 8.96 0 20C0 31.04 8.96 40 20 40C31.04 40 40 31.04 40 20C40 8.96 31.04 0 20 0ZM20 6C23.32 6 26 8.68 26 12C26 15.32 23.32 18 20 18C16.68 18 14 15.32 14 12C14 8.68 16.68 6 20 6ZM20 34.4C15 34.4 10.58 31.84 8 27.96C8.06 23.98 16 21.8 20 21.8C23.98 21.8 31.94 23.98 32 27.96C29.42 31.84 25 34.4 20 34.4Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
@@ -1,7 +1,9 @@
|
||||
import { ReactHTML } from 'react';
|
||||
import { ComponentPropsWithRef, ReactHTML } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { CSSProperties } from 'styled-components/dist/types';
|
||||
|
||||
import { hideEffect, showEffect } from './Effect';
|
||||
|
||||
export interface BoxProps {
|
||||
as?: keyof ReactHTML;
|
||||
$align?: CSSProperties['alignItems'];
|
||||
@@ -10,17 +12,24 @@ export interface BoxProps {
|
||||
$css?: string;
|
||||
$direction?: CSSProperties['flexDirection'];
|
||||
$display?: CSSProperties['display'];
|
||||
$effect?: 'show' | 'hide';
|
||||
$flex?: boolean;
|
||||
$gap?: CSSProperties['gap'];
|
||||
$height?: CSSProperties['height'];
|
||||
$justify?: CSSProperties['justifyContent'];
|
||||
$overflow?: CSSProperties['overflow'];
|
||||
$position?: CSSProperties['position'];
|
||||
$radius?: CSSProperties['borderRadius'];
|
||||
$width?: CSSProperties['width'];
|
||||
$maxWidth?: CSSProperties['maxWidth'];
|
||||
$minWidth?: CSSProperties['minWidth'];
|
||||
}
|
||||
|
||||
export type BoxType = ComponentPropsWithRef<typeof Box>;
|
||||
|
||||
export const Box = styled('div')<BoxProps>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
${({ $align }) => $align && `align-items: ${$align};`}
|
||||
${({ $background }) => $background && `background: ${$background};`}
|
||||
${({ $color }) => $color && `color: ${$color};`}
|
||||
@@ -30,8 +39,19 @@ export const Box = styled('div')<BoxProps>`
|
||||
${({ $gap }) => $gap && `gap: ${$gap};`}
|
||||
${({ $height }) => $height && `height: ${$height};`}
|
||||
${({ $justify }) => $justify && `justify-content: ${$justify};`}
|
||||
${({ $overflow }) => $overflow && `overflow: ${$overflow};`}
|
||||
${({ $position }) => $position && `position: ${$position};`}
|
||||
${({ $radius }) => $radius && `border-radius: ${$radius};`}
|
||||
${({ $width }) => $width && `width: ${$width};`}
|
||||
${({ $maxWidth }) => $maxWidth && `max-width: ${$maxWidth};`}
|
||||
${({ $minWidth }) => $minWidth && `min-width: ${$minWidth};`}
|
||||
${({ $css }) => $css && `${$css};`}
|
||||
${({ $effect }) => {
|
||||
switch ($effect) {
|
||||
case 'show':
|
||||
return showEffect;
|
||||
case 'hide':
|
||||
return hideEffect;
|
||||
}
|
||||
}}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { Box, BoxType } from '.';
|
||||
|
||||
export const Card = ({
|
||||
children,
|
||||
$css,
|
||||
...props
|
||||
}: PropsWithChildren<BoxType>) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Box
|
||||
$background="white"
|
||||
$radius="4px"
|
||||
$css={`
|
||||
box-shadow: 2px 2px 5px ${colorsTokens()['primary-300']}88;
|
||||
border: 1px solid ${colorsTokens()['card-border']};
|
||||
${$css}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { css, keyframes } from 'styled-components';
|
||||
|
||||
const show = keyframes`
|
||||
0% { transform: scaleY(0); opacity: 0; }
|
||||
100% { transform: scaleY(1); opacity: 1;}
|
||||
`;
|
||||
|
||||
const hide = keyframes`
|
||||
0% { transform: scaleY(1); opacity: 1;}
|
||||
100% { display:none; transform: scaleY(0); opacity: 0 }
|
||||
`;
|
||||
|
||||
export const showEffect = css`
|
||||
animation: ${show} 0.3s ease-in-out;
|
||||
`;
|
||||
|
||||
export const hideEffect = css`
|
||||
animation: ${hide} 0.3s ease-in-out;
|
||||
animation-fill-mode: forwards;
|
||||
`;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { PropsWithChildren, useEffect, useRef } from 'react';
|
||||
|
||||
import { Box, BoxType } from '@/components';
|
||||
|
||||
interface InfiniteScrollProps extends BoxType {
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
next: () => void;
|
||||
scrollContainer: HTMLElement | null;
|
||||
}
|
||||
|
||||
export const InfiniteScroll = ({
|
||||
children,
|
||||
hasMore,
|
||||
isLoading,
|
||||
next,
|
||||
scrollContainer,
|
||||
...boxProps
|
||||
}: PropsWithChildren<InfiniteScrollProps>) => {
|
||||
const timeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHandle = () => {
|
||||
if (!hasMore || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// To not wait until the end of the scroll to load more data
|
||||
const heightFromBottom = 150;
|
||||
|
||||
const { scrollTop, clientHeight, scrollHeight } = scrollContainer;
|
||||
if (scrollTop + clientHeight >= scrollHeight - heightFromBottom) {
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
timeout.current = setTimeout(nextHandle, 50);
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [hasMore, isLoading, next, scrollContainer]);
|
||||
|
||||
return <Box {...boxProps}>{children}</Box>;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import Link from 'next/link';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const StyledLink = styled(Link)`
|
||||
text-decoration: none;
|
||||
color: #ffffff33;
|
||||
&[aria-current='page'] {
|
||||
color: #ffffff;
|
||||
}
|
||||
display: flex;
|
||||
`;
|
||||
@@ -14,6 +14,7 @@ export interface TextProps extends BoxProps {
|
||||
'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||
>;
|
||||
$weight?: CSSProperties['fontWeight'];
|
||||
$textAlign?: CSSProperties['textAlign'];
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
$size?: TextSizes | (string & {});
|
||||
$theme?:
|
||||
@@ -37,7 +38,10 @@ export interface TextProps extends BoxProps {
|
||||
| '900';
|
||||
}
|
||||
|
||||
export type TextType = ComponentPropsWithRef<typeof Text>;
|
||||
|
||||
export const TextStyled = styled(Box)<TextProps>`
|
||||
${({ $textAlign }) => $textAlign && `text-align: ${$textAlign};`}
|
||||
${({ $weight }) => $weight && `font-weight: ${$weight};`}
|
||||
${({ $size }) =>
|
||||
$size &&
|
||||
@@ -53,7 +57,7 @@ export const Text = ({
|
||||
return (
|
||||
<TextStyled
|
||||
as="span"
|
||||
$theme="primary"
|
||||
$theme="greyscale"
|
||||
$variation="text"
|
||||
{...props}
|
||||
></TextStyled>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text, TextType } from '@/components';
|
||||
|
||||
interface TextErrorsProps extends TextType {
|
||||
causes?: string[];
|
||||
defaultMessage?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a list of errors
|
||||
* @param causes - List of causes
|
||||
* @param defaultMessage - Default message if no causes
|
||||
* @param duration -- Duration in milliseconds of the error message
|
||||
* - Set to `0` or `undefined` for unlimited duration
|
||||
* @param textProps - Text props
|
||||
*/
|
||||
export const TextErrors = ({
|
||||
causes,
|
||||
defaultMessage,
|
||||
duration = 6000, // 6 seconds
|
||||
...textProps
|
||||
}: TextErrorsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
if (duration) {
|
||||
setTimeout(() => {
|
||||
setVisible(false);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box $effect={visible ? 'show' : 'hide'}>
|
||||
{causes &&
|
||||
causes.map((cause, i) => (
|
||||
<Text
|
||||
key={`causes-${i}`}
|
||||
className="mt-s"
|
||||
$theme="danger"
|
||||
$textAlign="center"
|
||||
{...textProps}
|
||||
>
|
||||
{cause}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{!causes && (
|
||||
<Text
|
||||
className="mt-s"
|
||||
$theme="danger"
|
||||
$textAlign="center"
|
||||
{...textProps}
|
||||
>
|
||||
{defaultMessage || t('Something bad happens, please retry.')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from './Box';
|
||||
export * from './Text';
|
||||
export * from './Link';
|
||||
export * from './TextErrors';
|
||||
export * from './Card';
|
||||
|
||||
@@ -25,7 +25,4 @@
|
||||
--c--components--forms-select--value-color--disabled: var(
|
||||
--c--theme--colors--greyscale-400
|
||||
);
|
||||
--c--components--forms-labelledbox--label-color--big: var(
|
||||
--c--theme--colors--primary-500
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
}
|
||||
|
||||
.labelled-box label {
|
||||
color: var(--c--theme--colors--primary-500);
|
||||
color: var(--c--theme--colors--primary-text);
|
||||
}
|
||||
|
||||
.labelled-box--disabled label {
|
||||
color: var(--c--components--forms-labelledbox--label-color--small--disabled);
|
||||
color: var(--c--components--forms-labelledbox--label-color--small-disabled);
|
||||
}
|
||||
|
||||
.c__field :not(.c__textarea__wrapper, div) .labelled-box label.placeholder {
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
.c__input__wrapper:hover,
|
||||
.c__textarea__wrapper:hover {
|
||||
box-shadow: var(--c--theme--colors--primary-500) 0 0 0 2px;
|
||||
box-shadow: var(--c--components--forms-input--box-shadow-color) 0 0 0 2px;
|
||||
}
|
||||
|
||||
.c__textarea__wrapper--disabled:hover,
|
||||
@@ -120,7 +120,16 @@ input:-webkit-autofill:focus {
|
||||
}
|
||||
|
||||
.c__select:not(.c__select--disabled) .c__select__wrapper:hover {
|
||||
box-shadow: var(--c--theme--colors--primary-500) 0 0 0 2px;
|
||||
box-shadow: var(--c--components--forms-input--box-shadow-color) 0 0 0 2px;
|
||||
}
|
||||
|
||||
.c__select__wrapper:hover {
|
||||
border-radius: var(--c--components--forms-select--border-radius-hover);
|
||||
border-color: var(--c--components--forms-select--border-color-hover);
|
||||
}
|
||||
|
||||
.c__select--disabled .c__select__wrapper:hover {
|
||||
border-color: var(--c--components--forms-select--border-color-disabled-hover);
|
||||
}
|
||||
|
||||
.c__select__menu__item {
|
||||
@@ -135,7 +144,7 @@ input:-webkit-autofill:focus {
|
||||
}
|
||||
|
||||
.c__select__wrapper:focus-within .labelled-box--disabled label {
|
||||
color: var(--c--components--forms-labelledbox--label-color--small--disabled);
|
||||
color: var(--c--components--forms-labelledbox--label-color--small-disabled);
|
||||
}
|
||||
|
||||
.c__select__wrapper .labelled-box {
|
||||
@@ -173,28 +182,60 @@ input:-webkit-autofill:focus {
|
||||
/**
|
||||
* DataGrid
|
||||
*/
|
||||
.c__datagrid > table td {
|
||||
max-width: 10rem;
|
||||
white-space: normal;
|
||||
color: var(--c--components--datagrid--cell--color);
|
||||
font-size: var(--c--components--datagrid--cell--size);
|
||||
.c__datagrid__table__container {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.c__datagrid > table th .c__datagrid__header {
|
||||
color: var(--c--theme--colors--primary-500);
|
||||
.c__datagrid__table__container > table th .c__datagrid__header {
|
||||
color: var(--c--components--datagrid--header--color);
|
||||
font-weight: var(--c--components--datagrid--header--weight);
|
||||
font-size: var(--c--components--datagrid--header--size);
|
||||
padding-block: 2rem;
|
||||
}
|
||||
|
||||
.c__datagrid > table tbody tr {
|
||||
border: 1px var(--c--theme--colors--primary-100) solid;
|
||||
.c__datagrid__table__container > table tbody tr {
|
||||
border: none;
|
||||
border-top: 1px var(--c--theme--colors--greyscale-300) solid;
|
||||
border-bottom: 1px var(--c--theme--colors--greyscale-300) solid;
|
||||
}
|
||||
|
||||
.c__datagrid__table__container > table tbody {
|
||||
background-color: var(--c--components--datagrid--body--background-color);
|
||||
}
|
||||
|
||||
.c__datagrid__table__container > table tbody tr:hover {
|
||||
background-color: var(
|
||||
--c--components--datagrid--body--background-color-hover
|
||||
);
|
||||
}
|
||||
|
||||
.c__datagrid__table__container > table th:first-child,
|
||||
.c__datagrid__table__container > table td:first-child {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.c__datagrid > .c__pagination {
|
||||
padding-top: 1rem;
|
||||
padding-right: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.c__pagination__list {
|
||||
gap: 3px;
|
||||
border-radius: 4px;
|
||||
background: var(--c--components--datagrid--pagination--background-color);
|
||||
}
|
||||
|
||||
.c__pagination__list .c__button--tertiary.c__button--active {
|
||||
background-color: var(
|
||||
--c--components--datagrid--pagination--background-color-active
|
||||
);
|
||||
color: var(--c--theme--colors--greyscale-800);
|
||||
}
|
||||
|
||||
.c__pagination__list .c__button--tertiary:disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (width <= 380px) {
|
||||
.c__datagrid > .c__pagination {
|
||||
flex-direction: column;
|
||||
@@ -272,6 +313,13 @@ input:-webkit-autofill:focus {
|
||||
color: var(--c--components--button--primary--color);
|
||||
}
|
||||
|
||||
.c__button--primary:hover {
|
||||
background-color: var(
|
||||
--c--components--button--primary--background--color-hover
|
||||
);
|
||||
color: var(--c--components--button--primary--color-hover);
|
||||
}
|
||||
|
||||
.c__button--primary:active,
|
||||
.c__button--primary.c__button--active {
|
||||
background-color: var(
|
||||
@@ -318,6 +366,7 @@ input:-webkit-autofill:focus {
|
||||
--c--components--button--secondary--background--color-hover
|
||||
);
|
||||
color: var(--c--components--button--secondary--color-hover);
|
||||
border: 1px solid var(--c--components--button--secondary--border--color-hover);
|
||||
}
|
||||
|
||||
.c__button--tertiary {
|
||||
|
||||
@@ -69,6 +69,8 @@
|
||||
--c--theme--colors--success-text: var(--c--theme--colors--greyscale-000);
|
||||
--c--theme--colors--warning-text: var(--c--theme--colors--greyscale-000);
|
||||
--c--theme--colors--danger-text: var(--c--theme--colors--greyscale-000);
|
||||
--c--theme--colors--card-border: #ddd;
|
||||
--c--theme--colors--primary-bg: #fafafa;
|
||||
--c--theme--colors--primary-150: #e5eefa;
|
||||
--c--theme--colors--info-150: #e5eefa;
|
||||
--c--theme--font--sizes--h1: 2.2rem;
|
||||
@@ -158,12 +160,18 @@
|
||||
--c--components--forms-labelledbox--label-color--small: var(
|
||||
--c--theme--colors--primary-500
|
||||
);
|
||||
--c--components--forms-labelledbox--label-color--small-disabled: var(
|
||||
--c--theme--colors--greyscale-400
|
||||
);
|
||||
--c--components--forms-labelledbox--label-color--big--disabled: var(
|
||||
--c--theme--colors--greyscale-400
|
||||
);
|
||||
--c--components--forms-select--border-color: var(
|
||||
--c--theme--colors--primary-500
|
||||
);
|
||||
--c--components--forms-select--border-color-disabled-hover: var(
|
||||
--c--theme--colors--greyscale-200
|
||||
);
|
||||
--c--components--forms-select--border-radius--hover: var(
|
||||
--c--components--forms-select--border-radius
|
||||
);
|
||||
@@ -313,6 +321,7 @@
|
||||
}
|
||||
|
||||
.cunningham-theme--dsfr {
|
||||
--c--theme--colors--card-border: #ddd;
|
||||
--c--theme--colors--primary-text: #000091;
|
||||
--c--theme--colors--primary-100: #f5f5fe;
|
||||
--c--theme--colors--primary-200: #ececfe;
|
||||
@@ -333,10 +342,11 @@
|
||||
--c--theme--colors--secondary-700: #3b2424;
|
||||
--c--theme--colors--secondary-800: #341f1f;
|
||||
--c--theme--colors--secondary-900: #2b1919;
|
||||
--c--theme--colors--greyscale-000: #cecece;
|
||||
--c--theme--colors--greyscale-100: #f6f6f6;
|
||||
--c--theme--colors--greyscale-200: #eee;
|
||||
--c--theme--colors--greyscale-300: #e5e5e5;
|
||||
--c--theme--colors--greyscale-text: #303c4b;
|
||||
--c--theme--colors--greyscale-000: #f6f6f6;
|
||||
--c--theme--colors--greyscale-100: #eee;
|
||||
--c--theme--colors--greyscale-200: #e5e5e5;
|
||||
--c--theme--colors--greyscale-300: #e1e1e1;
|
||||
--c--theme--colors--greyscale-400: #ddd;
|
||||
--c--theme--colors--greyscale-500: #cecece;
|
||||
--c--theme--colors--greyscale-600: #7b7b7b;
|
||||
@@ -386,15 +396,70 @@
|
||||
--c--theme--font--families--accent: marianne;
|
||||
--c--theme--font--families--base: marianne;
|
||||
--c--components--alert--border-radius: 0;
|
||||
--c--components--button--border-radius: 2px;
|
||||
--c--components--button--border-radius: 4px;
|
||||
--c--components--button--primary--background--color: var(
|
||||
--c--theme--colors--primary-text
|
||||
);
|
||||
--c--components--button--primary--background--color-hover: var(
|
||||
--c--theme--colors--primary-700
|
||||
);
|
||||
--c--components--button--primary--background--color-active: var(
|
||||
--c--theme--colors--primary-900
|
||||
);
|
||||
--c--components--button--primary--color: #fff;
|
||||
--c--components--button--primary--color-hover: #fff;
|
||||
--c--components--button--primary--color-active: #fff;
|
||||
--c--components--button--secondary--background--color-hover: var(
|
||||
--c--theme--colors--primary-100
|
||||
);
|
||||
--c--components--button--secondary--background--color-active: var(
|
||||
--c--theme--colors--primary-200
|
||||
);
|
||||
--c--components--button--secondary--border--color-hover: var(
|
||||
--c--theme--colors--primary-300
|
||||
);
|
||||
--c--components--button--secondary--color: var(
|
||||
--c--theme--colors--primary-text
|
||||
);
|
||||
--c--components--datagrid--header--color: var(
|
||||
--c--theme--colors--primary-text
|
||||
);
|
||||
--c--components--datagrid--header--size: var(--c--theme--font--sizes--s);
|
||||
--c--components--datagrid--body--background-color: transparent;
|
||||
--c--components--datagrid--body--background-color-hover: #f4f4fd;
|
||||
--c--components--datagrid--pagination--background-color: transparent;
|
||||
--c--components--datagrid--pagination--background-color-active: var(
|
||||
--c--theme--colors--primary-300
|
||||
);
|
||||
--c--components--forms-checkbox--border-radius: 0;
|
||||
--c--components--forms-datepicker--border-radius: 0;
|
||||
--c--components--forms-fileuploader--border-radius: 0;
|
||||
--c--components--forms-input--border-radius: 4px;
|
||||
--c--components--forms-input--background-color: #fff;
|
||||
--c--components--forms-input--border-color: var(
|
||||
--c--theme--colors--primary-text
|
||||
);
|
||||
--c--components--forms-input--box-shadow-color: var(
|
||||
--c--theme--colors--primary-text
|
||||
);
|
||||
--c--components--forms-labelledbox--label-color--big: var(
|
||||
--c--theme--colors--primary-text
|
||||
);
|
||||
--c--components--forms-select--border-radius: 4px;
|
||||
--c--components--forms-select--border-radius-hover: 4px;
|
||||
--c--components--forms-select--background-color: #fff;
|
||||
--c--components--forms-select--border-color: var(
|
||||
--c--theme--colors--primary-text
|
||||
);
|
||||
--c--components--forms-select--border-color-hover: var(
|
||||
--c--theme--colors--primary-text
|
||||
);
|
||||
--c--components--forms-select--box-shadow-color: var(
|
||||
--c--theme--colors--primary-text
|
||||
);
|
||||
--c--components--forms-switch--handle-border-radius: 2px;
|
||||
--c--components--forms-switch--rail-border-radius: 4px;
|
||||
--c--components--forms-input--border-radius: 0;
|
||||
--c--components--forms-select--border-radius: 0;
|
||||
--c--components--forms-datepicker--border-radius: 0;
|
||||
--c--components--forms-textarea--border-radius: 0;
|
||||
--c--components--forms-fileuploader--border-radius: 0;
|
||||
}
|
||||
|
||||
.clr-secondary-text {
|
||||
@@ -677,6 +742,14 @@
|
||||
color: var(--c--theme--colors--danger-text);
|
||||
}
|
||||
|
||||
.clr-card-border {
|
||||
color: var(--c--theme--colors--card-border);
|
||||
}
|
||||
|
||||
.clr-primary-bg {
|
||||
color: var(--c--theme--colors--primary-bg);
|
||||
}
|
||||
|
||||
.clr-primary-150 {
|
||||
color: var(--c--theme--colors--primary-150);
|
||||
}
|
||||
@@ -965,6 +1038,14 @@
|
||||
background-color: var(--c--theme--colors--danger-text);
|
||||
}
|
||||
|
||||
.bg-card-border {
|
||||
background-color: var(--c--theme--colors--card-border);
|
||||
}
|
||||
|
||||
.bg-primary-bg {
|
||||
background-color: var(--c--theme--colors--primary-bg);
|
||||
}
|
||||
|
||||
.bg-primary-150 {
|
||||
background-color: var(--c--theme--colors--primary-150);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ export const tokens = {
|
||||
'success-text': '#FFFFFF',
|
||||
'warning-text': '#FFFFFF',
|
||||
'danger-text': '#FFFFFF',
|
||||
'card-border': '#DDDDDD',
|
||||
'primary-bg': '#FAFAFA',
|
||||
'primary-150': '#E5EEFA',
|
||||
'info-150': '#E5EEFA',
|
||||
},
|
||||
@@ -181,11 +183,14 @@ export const tokens = {
|
||||
'forms-labelledbox': {
|
||||
'label-color': {
|
||||
small: 'var(--c--theme--colors--primary-500)',
|
||||
'small-disabled': 'var(--c--theme--colors--greyscale-400)',
|
||||
big: { disabled: 'var(--c--theme--colors--greyscale-400)' },
|
||||
},
|
||||
},
|
||||
'forms-select': {
|
||||
'border-color': 'var(--c--theme--colors--primary-500)',
|
||||
'border-color-disabled-hover':
|
||||
'var(--c--theme--colors--greyscale-200)',
|
||||
'border-radius': {
|
||||
hover: 'var(--c--components--forms-select--border-radius)',
|
||||
focus: 'var(--c--components--forms-select--border-radius)',
|
||||
@@ -321,6 +326,7 @@ export const tokens = {
|
||||
dsfr: {
|
||||
theme: {
|
||||
colors: {
|
||||
'card-border': '#DDDDDD',
|
||||
'primary-text': '#000091',
|
||||
'primary-100': '#f5f5fe',
|
||||
'primary-200': '#ececfe',
|
||||
@@ -341,10 +347,11 @@ export const tokens = {
|
||||
'secondary-700': '#3b2424',
|
||||
'secondary-800': '#341f1f',
|
||||
'secondary-900': '#2b1919',
|
||||
'greyscale-000': '#cecece',
|
||||
'greyscale-100': '#f6f6f6',
|
||||
'greyscale-200': '#eeeeee',
|
||||
'greyscale-300': '#e5e5e5',
|
||||
'greyscale-text': '#303C4B',
|
||||
'greyscale-000': '#f6f6f6',
|
||||
'greyscale-100': '#eeeeee',
|
||||
'greyscale-200': '#e5e5e5',
|
||||
'greyscale-300': '#e1e1e1',
|
||||
'greyscale-400': '#dddddd',
|
||||
'greyscale-500': '#cecece',
|
||||
'greyscale-600': '#7b7b7b',
|
||||
@@ -396,17 +403,66 @@ export const tokens = {
|
||||
},
|
||||
components: {
|
||||
alert: { 'border-radius': '0' },
|
||||
button: { 'border-radius': '2px' },
|
||||
button: {
|
||||
'border-radius': '4px',
|
||||
primary: {
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
'color-hover': 'var(--c--theme--colors--primary-700)',
|
||||
'color-active': 'var(--c--theme--colors--primary-900)',
|
||||
},
|
||||
color: '#ffffff',
|
||||
'color-hover': '#ffffff',
|
||||
'color-active': '#ffffff',
|
||||
},
|
||||
secondary: {
|
||||
background: {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
'color-active': 'var(--c--theme--colors--primary-200)',
|
||||
},
|
||||
border: { 'color-hover': 'var(--c--theme--colors--primary-300)' },
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
},
|
||||
datagrid: {
|
||||
header: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
size: 'var(--c--theme--font--sizes--s)',
|
||||
},
|
||||
body: {
|
||||
'background-color': 'transparent',
|
||||
'background-color-hover': '#F4F4FD',
|
||||
},
|
||||
pagination: {
|
||||
'background-color': 'transparent',
|
||||
'background-color-active': 'var(--c--theme--colors--primary-300)',
|
||||
},
|
||||
},
|
||||
'forms-checkbox': { 'border-radius': '0' },
|
||||
'forms-datepicker': { 'border-radius': '0' },
|
||||
'forms-fileuploader': { 'border-radius': '0' },
|
||||
'forms-input': {
|
||||
'border-radius': '4px',
|
||||
'background-color': '#ffffff',
|
||||
'border-color': 'var(--c--theme--colors--primary-text)',
|
||||
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-labelledbox': {
|
||||
'label-color': { big: 'var(--c--theme--colors--primary-text)' },
|
||||
},
|
||||
'forms-select': {
|
||||
'border-radius': '4px',
|
||||
'border-radius-hover': '4px',
|
||||
'background-color': '#ffffff',
|
||||
'border-color': 'var(--c--theme--colors--primary-text)',
|
||||
'border-color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-switch': {
|
||||
'handle-border-radius': '2px',
|
||||
'rail-border-radius': '4px',
|
||||
},
|
||||
'forms-input': { 'border-radius': '0' },
|
||||
'forms-select': { 'border-radius': '0' },
|
||||
'forms-datepicker': { 'border-radius': '0' },
|
||||
'forms-textarea': { 'border-radius': '0' },
|
||||
'forms-fileuploader': { 'border-radius': '0' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -20,9 +20,5 @@ declare module '*.svg?url' {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
NEXT_PUBLIC_API_URL?: string;
|
||||
NEXT_PUBLIC_KEYCLOAK_URL?: string;
|
||||
NEXT_PUBLIC_KEYCLOAK_REALM?: string;
|
||||
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID?: string;
|
||||
NEXT_PUBLIC_KEYCLOAK_LOGIN?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,11 @@ import { Box } from '@/components';
|
||||
import { useAuthStore } from './useAuthStore';
|
||||
|
||||
export const Auth = ({ children }: PropsWithChildren) => {
|
||||
const { initAuth, authenticated, initialized } = useAuthStore();
|
||||
const { authenticated, initAuth } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
initAuth();
|
||||
}, [initAuth, initialized]);
|
||||
}, [initAuth]);
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fetchAPI } from '@/api';
|
||||
|
||||
/**
|
||||
* Represents user data retrieved from the API.
|
||||
* This interface is incomplete, and will be
|
||||
* refactored in a near future.
|
||||
*
|
||||
* @interface UserData
|
||||
* @property {string} email - The email of the user.
|
||||
*/
|
||||
export interface UserData {
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves the current user's data from the API.
|
||||
* This function is called during frontend initialization to check
|
||||
* the user's authentication status through a session cookie.
|
||||
*
|
||||
* @async
|
||||
* @function getMe
|
||||
* @throws {Error} Throws an error if the API request fails.
|
||||
* @returns {Promise<UserData>} A promise that resolves to the user data.
|
||||
*/
|
||||
export const getMe = async (): Promise<UserData> => {
|
||||
const response = await fetchAPI(`users/me/`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Couldn't fetch user data: ${response.statusText}`);
|
||||
}
|
||||
return response.json() as Promise<UserData>;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './getMe';
|
||||
@@ -1,23 +0,0 @@
|
||||
import Keycloak, { KeycloakConfig } from 'keycloak-js';
|
||||
|
||||
const keycloakConfig: KeycloakConfig = {
|
||||
url: process.env.NEXT_PUBLIC_KEYCLOAK_URL,
|
||||
realm: process.env.NEXT_PUBLIC_KEYCLOAK_REALM || '',
|
||||
clientId: process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID || '',
|
||||
};
|
||||
|
||||
export const initKeycloak = (onAuthSuccess: (_token?: string) => void) => {
|
||||
const keycloak = new Keycloak(keycloakConfig);
|
||||
keycloak
|
||||
.init({
|
||||
onLoad: 'login-required',
|
||||
})
|
||||
.then((authenticated) => {
|
||||
if (authenticated) {
|
||||
onAuthSuccess(keycloak.token);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
throw new Error('Failed to initialize Keycloak.');
|
||||
});
|
||||
};
|
||||
@@ -1,40 +1,39 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { initKeycloak } from './keycloak';
|
||||
import { UserData, getMe } from '@/features/auth/api';
|
||||
|
||||
export const login = () => {
|
||||
window.location.replace(
|
||||
new URL('authenticate/', process.env.NEXT_PUBLIC_API_URL).href,
|
||||
);
|
||||
};
|
||||
|
||||
interface AuthStore {
|
||||
authenticated: boolean;
|
||||
initAuth: () => void;
|
||||
initialized: boolean;
|
||||
logout: () => void;
|
||||
token: string | null;
|
||||
userData?: UserData;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
authenticated: false,
|
||||
initialized: false,
|
||||
token: null,
|
||||
userData: undefined,
|
||||
};
|
||||
|
||||
export const useAuthStore = create<AuthStore>((set) => ({
|
||||
authenticated: initialState.authenticated,
|
||||
initialized: initialState.initialized,
|
||||
token: initialState.token,
|
||||
|
||||
initAuth: () =>
|
||||
set((state) => {
|
||||
if (process.env.NEXT_PUBLIC_KEYCLOAK_LOGIN && !state.initialized) {
|
||||
initKeycloak((token) => set({ authenticated: true, token }));
|
||||
return { initialized: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Implement OIDC production authentication
|
||||
*/
|
||||
|
||||
return {};
|
||||
}),
|
||||
userData: initialState.userData,
|
||||
|
||||
initAuth: () => {
|
||||
getMe()
|
||||
.then((data: UserData) => {
|
||||
set({ authenticated: true, userData: data });
|
||||
})
|
||||
.catch(() => {
|
||||
// todo - implement a proper login screen to prevent automatic navigation.
|
||||
login();
|
||||
});
|
||||
},
|
||||
logout: () => {
|
||||
set(initialState);
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ import { LanguagePicker } from '../language/';
|
||||
|
||||
import { default as IconCells } from './assets/icon-cells.svg?url';
|
||||
import { default as IconDesk } from './assets/icon-desk.svg?url';
|
||||
import { default as IconFAQ } from './assets/icon-faq.svg?url';
|
||||
import { default as IconGouv } from './assets/icon-gouv.svg?url';
|
||||
import { default as IconMarianne } from './assets/icon-marianne.svg?url';
|
||||
import IconMyAccount from './assets/icon-my-account.png';
|
||||
@@ -42,18 +41,23 @@ export const Header = () => {
|
||||
return (
|
||||
<StyledHeader>
|
||||
<RedStripe />
|
||||
<Box className="ml-bx mr-bx" $align="center" $justify="space-between">
|
||||
<Box $direction="column">
|
||||
<Box
|
||||
className="ml-bx mr-bx"
|
||||
$align="center"
|
||||
$justify="space-between"
|
||||
$direction="row"
|
||||
>
|
||||
<Box>
|
||||
<Image priority src={IconMarianne} alt={t('Marianne Logo')} />
|
||||
<Box $align="center" $gap="6rem">
|
||||
<Box $align="center" $gap="6rem" $direction="row">
|
||||
<Image
|
||||
priority
|
||||
src={IconGouv}
|
||||
alt={t('Freedom Equality Fraternity Logo')}
|
||||
/>
|
||||
<Box $align="center" $gap="1rem">
|
||||
<Box $align="center" $gap="1rem" $direction="row">
|
||||
<Image priority src={IconDesk} alt={t('Desk Logo')} />
|
||||
<Text className="m-0" as="h2">
|
||||
<Text className="m-0" as="h2" $theme="primary">
|
||||
{t('Desk')}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -68,27 +72,22 @@ export const Header = () => {
|
||||
`}
|
||||
$gap="5rem"
|
||||
$justify="flex-end"
|
||||
$direction="row"
|
||||
>
|
||||
<Box $align="center">
|
||||
<Button
|
||||
aria-label={t('Access to FAQ')}
|
||||
icon={<Image priority src={IconFAQ} alt={t('FAQ Icon')} />}
|
||||
className="m-s c__button-no-bg p-0"
|
||||
>
|
||||
{t('FAQ')}
|
||||
</Button>
|
||||
<Box $align="center" $direction="row">
|
||||
<LanguagePicker />
|
||||
</Box>
|
||||
<Box>
|
||||
<Box $direction="row">
|
||||
<Box $direction="row" $align="center" $gap="1rem">
|
||||
<Text $weight="bold">John Doe</Text>
|
||||
<Text $weight="bold" $theme="primary">
|
||||
John Doe
|
||||
</Text>
|
||||
<Image
|
||||
width={58}
|
||||
height={58}
|
||||
priority
|
||||
src={IconMyAccount}
|
||||
alt={t(`Profile picture`)}
|
||||
unoptimized
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M21 3C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.455L2 22.5V4C2 3.44772 2.44772 3 3 3H21ZM20 5H4V18.385L5.763 17H20V5ZM13 14V16H11V14H13ZM12.4592 6.02822C14.2875 6.27003 15.6148 7.88878 15.4936 9.72908C15.3724 11.5694 13.8443 13.0001 12 13H11V11H12C12.7902 11 13.4448 10.387 13.4967 9.59851C13.5486 8.81006 12.9799 8.11655 12.1966 8.01295C11.4133 7.90935 10.6839 8.43117 10.529 9.206L8.567 8.813C8.92838 7.00447 10.6308 5.78641 12.4592 6.02822Z"
|
||||
fill="#000091"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 663 B |
@@ -1,5 +0,0 @@
|
||||
export * from './auth/';
|
||||
export * from './header/';
|
||||
export * from './language/';
|
||||
export * from './menu/';
|
||||
export * from './teams/';
|
||||
@@ -49,7 +49,7 @@ export const LanguagePicker = () => {
|
||||
$align="center"
|
||||
>
|
||||
<Image priority src={IconLanguage} alt={t('Language Icon')} />
|
||||
<Text>{lang.toUpperCase()}</Text>
|
||||
<Text $theme="primary">{lang.toUpperCase()}</Text>
|
||||
</Box>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import IconGroup from '@/assets/icons/icon-group.svg';
|
||||
import { Box } from '@/components/';
|
||||
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
|
||||
|
||||
import MenuItem from './MenuItems';
|
||||
import IconRecent from './assets/icon-clock.svg';
|
||||
import IconContacts from './assets/icon-contacts.svg';
|
||||
import IconGroup from './assets/icon-group.svg';
|
||||
import IconSearch from './assets/icon-search.svg';
|
||||
import IconFavorite from './assets/icon-stars.svg';
|
||||
|
||||
@@ -21,9 +21,7 @@ export const Menu = () => {
|
||||
className="m-0 p-0"
|
||||
$background={colorsTokens()['primary-800']}
|
||||
$height="100%"
|
||||
$width="3.75rem"
|
||||
$justify="space-between"
|
||||
$direction="column"
|
||||
>
|
||||
<Box className="pt-b" $direction="column" $gap="0.8rem">
|
||||
<MenuItem Icon={IconSearch} label={t('Search')} href="/" />
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Box } from '@/components/';
|
||||
import { Box, StyledLink } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { SVGComponent } from '@/types/components';
|
||||
|
||||
import { Tooltip } from './Tooltip';
|
||||
|
||||
const StyledLink = styled(Link)`
|
||||
text-decoration: none;
|
||||
color: #ffffff33;
|
||||
&[aria-current='page'] {
|
||||
color: #ffffff;
|
||||
}
|
||||
`;
|
||||
|
||||
interface MenuItemProps {
|
||||
Icon: SVGComponent;
|
||||
label: string;
|
||||
@@ -29,7 +19,7 @@ const MenuItem = ({ Icon, label, href }: MenuItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const buttonRef = useRef(null);
|
||||
const parentRef = useRef(null);
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||
|
||||
const isActive = pathname === href;
|
||||
@@ -51,9 +41,10 @@ const MenuItem = ({ Icon, label, href }: MenuItemProps) => {
|
||||
<StyledLink
|
||||
href={href}
|
||||
aria-current={isActive && 'page'}
|
||||
ref={buttonRef}
|
||||
ref={parentRef}
|
||||
onMouseOver={() => setIsTooltipOpen(true)}
|
||||
onMouseOut={() => setIsTooltipOpen(false)}
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<Box
|
||||
className="m-st p-t"
|
||||
@@ -86,7 +77,7 @@ const MenuItem = ({ Icon, label, href }: MenuItemProps) => {
|
||||
</Box>
|
||||
{isTooltipOpen && (
|
||||
<Tooltip
|
||||
buttonRef={buttonRef}
|
||||
parentRef={parentRef}
|
||||
label={label}
|
||||
backgroundColor={backgroundTooltip}
|
||||
textColor={colorTooltip}
|
||||
|
||||
@@ -4,14 +4,14 @@ import React, { CSSProperties, useEffect, useState } from 'react';
|
||||
import { Box, Text } from '@/components/';
|
||||
|
||||
interface TooltipProps {
|
||||
buttonRef: React.MutableRefObject<null>;
|
||||
parentRef: React.MutableRefObject<null>;
|
||||
textColor: CSSProperties['color'];
|
||||
backgroundColor: CSSProperties['color'];
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const Tooltip = ({
|
||||
buttonRef,
|
||||
parentRef,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
label,
|
||||
@@ -23,7 +23,7 @@ export const Tooltip = ({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Popover parentRef={buttonRef} onClickOutside={() => ''} borderless>
|
||||
<Popover parentRef={parentRef} onClickOutside={() => ''} borderless>
|
||||
<Box
|
||||
aria-label="tooltip"
|
||||
className="ml-t p-t"
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { TeamList } from '../components/Panel/TeamList';
|
||||
|
||||
window.HTMLElement.prototype.scroll = function () {};
|
||||
|
||||
jest.mock('next/router', () => ({
|
||||
...jest.requireActual('next/router'),
|
||||
useRouter: () => ({
|
||||
query: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('PanelTeams', () => {
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('renders with no team to display', async () => {
|
||||
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
|
||||
count: 0,
|
||||
results: [],
|
||||
});
|
||||
|
||||
render(<TeamList />, { wrapper: AppWrapper });
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Create your first team by clicking on the "Create a new team" button.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an empty team', async () => {
|
||||
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Team 1',
|
||||
accesses: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<TeamList />, { wrapper: AppWrapper });
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
await screen.findByLabelText('Empty teams icon'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a team with only 1 member', async () => {
|
||||
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Team 1',
|
||||
accesses: [
|
||||
{
|
||||
id: '1',
|
||||
role: 'owner',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<TeamList />, { wrapper: AppWrapper });
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
await screen.findByLabelText('Empty teams icon'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a non-empty team', async () => {
|
||||
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Team 1',
|
||||
accesses: [
|
||||
{
|
||||
id: '1',
|
||||
role: 'admin',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
role: 'member',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<TeamList />, { wrapper: AppWrapper });
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByLabelText('Teams icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the error', async () => {
|
||||
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
|
||||
status: 500,
|
||||
});
|
||||
|
||||
render(<TeamList />, { wrapper: AppWrapper });
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Something bad happens, please refresh the page.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { Teams } from '..';
|
||||
|
||||
describe('Teams', () => {
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('checks Teams rendering', async () => {
|
||||
fetchMock.mock(`/api/teams/`, {
|
||||
results: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Team 1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Team 2',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<Teams />, { wrapper: AppWrapper });
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', {
|
||||
name: 'Create Team',
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/Team 1/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Team 2/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './types';
|
||||
export * from './useCreateTeam';
|
||||
export * from './useTeam';
|
||||
export * from './useTeams';
|
||||
export * from './useTeamsAccesses';
|
||||
@@ -0,0 +1,25 @@
|
||||
export enum Role {
|
||||
MEMBER = 'member',
|
||||
ADMIN = 'administrator',
|
||||
OWNER = 'owner',
|
||||
}
|
||||
|
||||
export interface Access {
|
||||
id: string;
|
||||
role: Role;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
accesses: Access[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { fetchAPI } from '@/api';
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { KEY_LIST_TEAM } from './useTeams';
|
||||
|
||||
@@ -8,11 +8,8 @@ type CreateTeamResponse = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
export interface CreateTeamResponseError {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export const createTeam = async (name: string) => {
|
||||
export const createTeam = async (name: string): Promise<CreateTeamResponse> => {
|
||||
const response = await fetchAPI(`teams/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@@ -21,21 +18,28 @@ export const createTeam = async (name: string) => {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Couldn't create team: ${response.statusText}`);
|
||||
throw new APIError(
|
||||
'Failed to create the team',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return response.json() as Promise<CreateTeamResponse>;
|
||||
};
|
||||
|
||||
export function useCreateTeam() {
|
||||
interface CreateTeamProps {
|
||||
onSuccess: (data: CreateTeamResponse) => void;
|
||||
}
|
||||
|
||||
export function useCreateTeam({ onSuccess }: CreateTeamProps) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<CreateTeamResponse, CreateTeamResponseError, string>({
|
||||
return useMutation<CreateTeamResponse, APIError, string>({
|
||||
mutationFn: createTeam,
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_TEAM],
|
||||
exact: true,
|
||||
});
|
||||
onSuccess(data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { Team } from './types';
|
||||
|
||||
export type TeamParams = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const getTeam = async ({ id }: TeamParams): Promise<Team> => {
|
||||
const response = await fetchAPI(`teams/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the team', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<Team>;
|
||||
};
|
||||
|
||||
export const KEY_TEAM = 'team';
|
||||
|
||||
export function useTeam(
|
||||
param: TeamParams,
|
||||
queryConfig?: UseQueryOptions<Team, APIError, Team>,
|
||||
) {
|
||||
return useQuery<Team, APIError, Team>({
|
||||
queryKey: [KEY_TEAM, param],
|
||||
queryFn: () => getTeam(param),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
@@ -1,38 +1,71 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
DefinedInitialDataInfiniteOptions,
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIList, fetchAPI } from '@/api';
|
||||
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
interface TeamResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
import { Team } from './types';
|
||||
|
||||
export enum TeamsOrdering {
|
||||
BY_CREATED_ON = 'created_at',
|
||||
BY_CREATED_ON_DESC = '-created_at',
|
||||
}
|
||||
|
||||
type TeamsResponse = APIList<TeamResponse>;
|
||||
export interface TeamsResponseError {
|
||||
detail: string;
|
||||
}
|
||||
export type TeamsParams = {
|
||||
ordering: TeamsOrdering;
|
||||
};
|
||||
type TeamsAPIParams = TeamsParams & {
|
||||
page: number;
|
||||
};
|
||||
|
||||
export const getTeams = async () => {
|
||||
const response = await fetchAPI(`teams/`);
|
||||
type TeamsResponse = APIList<Team>;
|
||||
|
||||
export const getTeams = async ({
|
||||
ordering,
|
||||
page,
|
||||
}: TeamsAPIParams): Promise<TeamsResponse> => {
|
||||
const orderingQuery = ordering ? `&ordering=${ordering}` : '';
|
||||
const response = await fetchAPI(`teams/?page=${page}${orderingQuery}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Couldn't fetch teams: ${response.statusText}`);
|
||||
throw new APIError('Failed to get the teams', await errorCauses(response));
|
||||
}
|
||||
return response.json();
|
||||
|
||||
return response.json() as Promise<TeamsResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_TEAM = 'teams';
|
||||
|
||||
export function useTeams(
|
||||
queryConfig?: UseQueryOptions<
|
||||
param: TeamsParams,
|
||||
queryConfig?: DefinedInitialDataInfiniteOptions<
|
||||
TeamsResponse,
|
||||
TeamsResponseError,
|
||||
TeamsResponse
|
||||
APIError,
|
||||
InfiniteData<TeamsResponse>,
|
||||
QueryKey,
|
||||
number
|
||||
>,
|
||||
) {
|
||||
return useQuery<TeamsResponse, TeamsResponseError, TeamsResponse>({
|
||||
queryKey: [KEY_LIST_TEAM],
|
||||
queryFn: getTeams,
|
||||
return useInfiniteQuery<
|
||||
TeamsResponse,
|
||||
APIError,
|
||||
InfiniteData<TeamsResponse>,
|
||||
QueryKey,
|
||||
number
|
||||
>({
|
||||
initialPageParam: 1,
|
||||
queryKey: [KEY_LIST_TEAM, param],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getTeams({
|
||||
...param,
|
||||
page: pageParam,
|
||||
}),
|
||||
getNextPageParam(lastPage, allPages) {
|
||||
return lastPage.next ? allPages.length + 1 : undefined;
|
||||
},
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { Access } from './types';
|
||||
|
||||
export type TeamAccessesAPIParams = {
|
||||
page: number;
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
type AccessesResponse = APIList<Access>;
|
||||
|
||||
export const getTeamAccesses = async ({
|
||||
page,
|
||||
teamId,
|
||||
}: TeamAccessesAPIParams): Promise<AccessesResponse> => {
|
||||
const response = await fetchAPI(`teams/${teamId}/accesses/?page=${page}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the team accesses',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<AccessesResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_TEAM_ACCESSES = 'teams-accesses';
|
||||
|
||||
export function useTeamAccesses(
|
||||
params: TeamAccessesAPIParams,
|
||||
queryConfig?: UseQueryOptions<AccessesResponse, APIError, AccessesResponse>,
|
||||
) {
|
||||
return useQuery<AccessesResponse, APIError, AccessesResponse>({
|
||||
queryKey: [KEY_LIST_TEAM_ACCESSES, params],
|
||||
queryFn: () => getTeamAccesses(params),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_178_17838)">
|
||||
<path
|
||||
d="M16.25 8.75H13.75V13.75H8.75V16.25H13.75V21.25H16.25V16.25H21.25V13.75H16.25V8.75ZM15 2.5C8.1 2.5 2.5 8.1 2.5 15C2.5 21.9 8.1 27.5 15 27.5C21.9 27.5 27.5 21.9 27.5 15C27.5 8.1 21.9 2.5 15 2.5ZM15 25C9.4875 25 5 20.5125 5 15C5 9.4875 9.4875 5 15 5C20.5125 5 25 9.4875 25 15C25 20.5125 20.5125 25 15 25Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_178_17838">
|
||||
<rect width="30" height="30" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 604 B |
@@ -0,0 +1,13 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_508_5524)">
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM4 12C4 7.58 7.58 4 12 4C13.85 4 15.55 4.63 16.9 5.69L5.69 16.9C4.63 15.55 4 13.85 4 12ZM12 20C10.15 20 8.45 19.37 7.1 18.31L18.31 7.1C19.37 8.45 20 10.15 20 12C20 16.42 16.42 20 12 20Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_508_5524">
|
||||
<rect width="24" height="24" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 578 B |