Compare commits

..

3 Commits

Author SHA1 Message Date
Lebaud Antoine 9eb4a1e9e3 🔧(backend) udpate email invitation sender
Replace the placeholder setting by a randomly picked 'do not reply'
email adress. Might be improved later on.
2024-04-02 23:26:38 +02:00
Lebaud Antoine d8b6bcc9eb 📱(frontend) handle extremely long name
Currently, with a very long group name (~100 char), text was overflowing
the flex elements.

I reworked the current layout to prevent this issue in Team info and
Team pannel.

I needed to debug some buggy flex layout issues. I decided to refactor
some styling, and ended up doing a big refactoring.

Working with Box and Text components cause high friction when
manipulating them. They use mix styling approaches ... which is quite
misleading for any developper joining the project.

It's mainly due to the fact that creating such generic wrappers,
relying on (heavy) interpolation functions might be against
styled component spirit.

I love manipulating inline styling, but styled component might not
be the best tool to take this approach.

I tried to align main screen with Johann's design to be pixel-perfect.
However, the actual design is not appropriate for responsive screen.
2024-04-02 23:21:44 +02:00
Lebaud Antoine 418bed945a 🩹(frontend) display email or name in delete modal
User's name could be null, leading to an empty text box when opening the
delete modal.

Prevent it by displaying user's name or email when deleting an user.
2024-04-02 22:53:43 +02:00
399 changed files with 8183 additions and 22893 deletions
-52
View File
@@ -1,52 +0,0 @@
name: Deploy
on:
push:
tags:
- 'preprod'
- 'production'
jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL
start-test-on-preprod:
needs:
- notify-argocd
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/preprod')
steps:
-
name: Debug
run: |
echo "Start test when preprod is ready"
+9 -43
View File
@@ -19,19 +19,8 @@ jobs:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
name: Checkout
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -42,7 +31,7 @@ jobs:
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
secret-file: .github/workflows/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Login to DockerHub
@@ -63,19 +52,8 @@ jobs:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
name: Checkout
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -86,7 +64,7 @@ jobs:
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
secret-file: .github/workflows/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Login to DockerHub
@@ -108,27 +86,15 @@ jobs:
- build-and-push-frontend
- build-and-push-backend
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
name: Checkout
uses: actions/checkout@v4
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
secret-file: .github/workflows/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
-22
View File
@@ -1,22 +0,0 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "people,secrets"
+79 -27
View File
@@ -4,6 +4,8 @@ on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- '*'
@@ -14,7 +16,7 @@ jobs:
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: show
@@ -37,7 +39,7 @@ jobs:
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Check that the CHANGELOG has been modified in the current branch
@@ -47,7 +49,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -150,7 +152,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set services env variables
run: |
make create-env-files
@@ -175,29 +177,15 @@ jobs:
with:
path: src/frontend/apps/desk/out/
key: build-front-${{ github.run_id }}
# Pull the latest image to build, and avoid caching pull-only images.
# (docker pull is faster than caching in most cases.)
- run: docker compose pull
# In this step, this action saves a list of existing images,
# the cache is created without them in the post run.
# It also restores the cache if it exists.
- uses: satackey/action-docker-layer-caching@v0.0.11
# Ignore the failure of a step and avoid terminating the job.
continue-on-error: true
- name: Build and Start Docker Servers
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
run: |
docker compose build --build-arg BUILDKIT_INLINE_CACHE=1
docker-compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
make run
# Finally, "Post Run satackey/action-docker-layer-caching@v0.0.11",
# which is the process of saving the cache, will be executed.
- name: Apply DRF migrations
run: |
make migrate
@@ -212,7 +200,7 @@ jobs:
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
@@ -226,7 +214,7 @@ jobs:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Install Node.js
uses: actions/setup-node@v4
with:
@@ -250,9 +238,9 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v3
with:
python-version: '3.10'
- name: Install development dependencies
@@ -296,7 +284,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Create writable /data
run: |
sudo mkdir -p /data/media && \
@@ -307,7 +295,7 @@ jobs:
name: mails-templates
path: src/backend/core/templates/mail
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v3
with:
python-version: '3.10'
- name: Install development dependencies
@@ -320,3 +308,67 @@ jobs:
run: python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
i18n-crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install gettext (required to make messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: '3.10'
- name: Install development dependencies
working-directory: src/backend
run: pip install --user .[dev]
- name: Generate the translation base file
run: ~/.local/bin/django-admin makemessages --keep-pot --all
- name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: .github/workflows/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18.x'
cache: 'yarn'
cache-dependency-path: src/frontend/yarn.lock
- name: Install dependencies
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Download sources from Crowdin to stay synchronized
run: |
docker run \
--rm \
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
-v "${{ github.workspace }}:/app" \
crowdin/cli:3.16.0 \
crowdin download sources -c /app/crowdin/config.yml
- name: Extract the frontend translation
run: make frontend-i18n-extract
- name: Upload files to Crowdin
run: |
docker run \
--rm \
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
-v "${{ github.workspace }}:/app" \
crowdin/cli:3.16.0 \
crowdin upload sources -c /app/crowdin/config.yml
+24
View File
@@ -0,0 +1,24 @@
SOPS_PRIVATE=ENC[AES256_GCM,data:FAgeMw3bbPVNZEuyuJ4rMeknOH4UZ1vC7k3S0qe6aaeGG3HHfEIpWHckCjHLDTz6hNDUxWSr/WPytntbGjqcvzKwYZBeehGx9hY=,iv:O64EPSvyYSxjHOIIrdjMQYIJR9HW2CxE+6Ay0e0dvoc=,tag:64G0A4kIJ8AzNx3+CluW5Q==,type:str]
CROWDIN_API_TOKEN=ENC[AES256_GCM,data:IlmqbPlM6s9paCAxn40v4LNEGbzYlm9SVplXfP8PI8Ei/K5oGtLPquoDokK33ySEfEh5b5xLAZjlmDyICdv89ETdd7RLB/63f2LduMn8n0A=,iv:gdcCrv58wkLC6wufFvHGrHB/A6aLXAApJ036BYjlN8g=,tag:wReGIj4izqzBHY7X0rqOAg==,type:str]
CROWDIN_BASE_PATH=ENC[AES256_GCM,data:bepqdi4eaMM=,iv:nF7FSUiuRu4kTYeEQB3cN/Fwrpt0fB0dC/dm4poec7w=,tag:kbxhwe1SQYkaddKyY1UQDg==,type:str]
CROWDIN_PROJECT_ID=ENC[AES256_GCM,data:z8ZkbbR9,iv:Uc2aGQ9dAkiUcTpBqcIqD2fXt4+/ObJJz/3D8BhN9/A=,tag:ZDqDpsD7IN7iyFvKmxoI2w==,type:str]
DOCKER_HUB_PASSWORD=ENC[AES256_GCM,data:3E/4G2zOBNZqkGc=,iv:zbQzrgLYgNp99zRUZ3ICG47OkLPV1HnhcNoIu3cSmpc=,tag:CEvKG2HVd2ijjGfzkM005Q==,type:str]
DOCKER_HUB_USER=ENC[AES256_GCM,data:Qc/HR/O8nw==,iv:nvXUmLHgNFQwm6aIpBcPvOdITOkw1YT7hIyk4ptuOOw=,tag:qoqSA9NlGCbor+7OyGArfw==,type:str]
ARGOCD_WEBHOOK_URL=ENC[AES256_GCM,data:WhW6TQQV/IQteW/jQukjlTaTLeNUzEzadp+HJEC7bqrWR88ZBUlk8sfF5KV7PAAAZtvMhe4mi/yl,iv:CInpRuJpKFpxozdzHQkfDxbhQYyy+VByzrDr2ZSmJ7E=,tag:RNmIoYtA5DSi1W7TDsJHxw==,type:str]
ARGOCD_WEBHOOK_SECRET=ENC[AES256_GCM,data:KTT5ubYdm5oVzXL0sM9HdNObVKhSD1spB3KYgM6CskEpQutb3R4Lu5mImft9C9tRKIkZetxV9YyLawCPaZm5P1U=,iv:Xp+xE43a6/GODhhujnSrTfcQJ2P9u4YrRakmxLwEtEc=,tag:jYAk+4X7x44edb0vcbnKWA==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHaHpGT0lLR21qRi8wZCt2\nc0t5NGlDR29lK2JscE4rc3VZOU5ic3FBdkhnCnJYdDdvMlBROWhPc2pjbDJTbmo0\ncENxV0hDYzZKSFh5RTNnUTRyaUdGLzgKLS0tIENIeDQyQ1NzMkF6cTZVRmU1VC9D\nNy9BTElMQlBEUWhCQWxpdVI1enJNblEKbGEkN7JPlj+OqBHYhZhZwfjpdxGlkjti\nBPsDHIklHYufk9+TjnxBYL1Oe6zLn4zlG0r/w8AHrTo6oke5dUjvKg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x
sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArOG1XL1NFc29xVWFFdW9Q\nM0FrY3BEazJRRXNHMjVpS2hCVVBNSllUU1dJCkRIT00xSVAzeEozZndwTzlmY1Bn\nc3VJYmQrVzRXZTF1TjR4QXQrYXpIaG8KLS0tIEtXNTkvb2tRcjR0YWdneTI2dlBu\nY2tkYVU4UGtZdEIxQWMxQ1h6aHQ1RjQKpPQRQZtMDMkmSUhm7PQXpBHYK45Fm7Gx\nNL4gpLq1bJ6tNlnvP2zj2OOJ7NsiSeH8Vey6NON6E5wDKckBkpZUdg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_1__map_recipient=age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUUEM5ZzNST2RHTzRKUUxH\nbGxsOVoxTkVQWnFBa3lweExjSG5tZW02ZTBVCmhmLzRkbEVUUFNOZXdZRG5QUUJL\nL0lvMDArRHBGbmsrR1Y0NE9DcmtwbWcKLS0tIDljdmdGaTVVdnBxdjlYM0IrWTZ1\nYU03M1dHZm5nMDY0ZE9TSzl2Mk9YcG8KZ6vkG1KUIpS3BnIWcluA0nc5YX5AB3RH\nPJqFTdNwUfeB8omOyifF4jxU3Jqm53d3kVmiSPXTlaOYwqrhbT1oAw==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_2__map_recipient=age1g5keveae6zhn059e7cxkjqdz4v3u47ypejv9ujld65nwh6d5pd9qfm0ecv
sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0c2x0YVB4cW1jVFRZVlBP\nNFJiSHIrWk9HWGgreVRSZWhkd3cveDBDZ1RZCkQzYnFxMEhUb2s2U3drZzQwTW5D\nbU9iL0RoN2Ztb0M2UEluOUQ5M3RtMEEKLS0tIEhJRnZGekxWR3dRejV1SkNDQkda\nQkY5MGZyZW1QNzdveXFTbGd0WjRZaTAKbv+2BPThEjNn7yjK4aX4C7kvnXQgf1Qb\n6J72BKD8AtHOJVdp/d1+I41ejwXdfBbSKN5O6rnPozH60GDWFlQRwA==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_3__map_recipient=age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4bzBsVUdlV1dzNVZoWGs2\nR3k0ZGh4cTlNTWdqNXlzc29KeTNTcmd6MHlZCi9wa29CNzcwWmtyT2U2bVArQXlU\nOGJXY3VaT2J5SEd1clY2WFVLa0s2a0EKLS0tIGlYZzRFT29ld2t6SU5tUUhIeUpG\nRnQwcTN0OFZJNGJicUNmeVE0eVcrU3MK96xiB4AkQSXLnP5XvVlQhvjQEQKhTZuO\nYbO1uP0EGSQ+f6uP3ENw4BFAgdB3y0YFcVm5VtTxN4Tpw3ZUVWtqew==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_4__map_recipient=age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBoWkEzQlRLNVd3U3hrU2dh\nY0l1OGVlYjJNZEIzK1R5NWFCK1lnK2pMVnlBCksrR1JKZTFmdzJCekZkQStvejBL\nd05wejByZjdnN2xweVhBemNiSDUwUEUKLS0tIEJIVGYzbWNBN2hrNEtoY2xMSk9v\naWw1THRBbmpDbGc2eE1WV0VSVUtoNm8KyCP6BtJRNUfvlAiuzQ1xhpjm6YFUA66G\n/qk9WLm07qHfFD5r2dGQBksN61Nk+akF0BGa56qnAEJp63lP8u1xbg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_5__map_recipient=age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
sops_lastmodified=2024-03-26T10:02:48Z
sops_mac=ENC[AES256_GCM,data:p7Br4MRgpr26vSS79QBwYRXQWdb20lbrSxOdcrj385EhDVaqZM9aaycf09FItuBrQDuNO3NVSoGnJvWhekwRXXJlxXDENmjP9YBnNt25WXCa2+PLQGzvGbZjo/KHky5zqwVV5XaJ++UuO7VJZgB6m5m7RPZEBCo4cwy7rvNeyEs=,iv:XaavC9VEDckO1QpsPjUwf5BpJzFcEdxycSZvuFX8D7A=,tag:S82ex6+X4MUkkR8m9UAgqA==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.8.1
-1
View File
@@ -66,7 +66,6 @@ src/frontend/tsclient
# Test & lint
.coverage
coverage.json
.pylint.d
.pytest_cache
db.sqlite3
-3
View File
@@ -1,3 +0,0 @@
[submodule "secrets"]
path = secrets
url = ../secrets
+14 -9
View File
@@ -1,10 +1,15 @@
creation_rules:
- path_regex: ./*
key_groups:
- age:
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa # Marie Pupo Jeammet
# Here we have
# - Jacques key-id: age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x
# - github-repo key-id: age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7
# - Anthony Le-Courric key-id: age1g5keveae6zhn059e7cxkjqdz4v3u47ypejv9ujld65nwh6d5pd9qfm0ecv
# - Antoine Lebaud key-id: age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
# - Marie Pupo Jeammet key-id: age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
# - argocd key-id: age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
- age:
age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x,
age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7,
age1g5keveae6zhn059e7cxkjqdz4v3u47ypejv9ujld65nwh6d5pd9qfm0ecv,
age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3,
age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa,
age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
-128
View File
@@ -7,131 +7,3 @@ and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.4.1] - 2024-10-23
### Fixed
- 🚑️(frontend) fix MailDomainsLayout
## [1.4.0] - 2024-10-23
### Added
- ✨(frontend) add tabs inside #466
### Fixed
- ✏️(mail) fix typo into mailbox creation email
- 🐛(sentry) fix duplicated sentry errors #479
- 🐛(script) improve and fix release script
## [1.3.1] - 2024-10-18
## [1.3.0] - 2024-10-18
### Added
- ✨(api) add RELEASE version on config endpoint #459
- ✨(backend) manage roles on domain admin view
- ✨(frontend) show version number in footer #369
### Changed
- 🛂(backend) match email if no existing user matches the sub
### Fixed
- 💄(mail) improve mailbox creation email #462
- 🐛(frontend) fix update accesses form #448
- 🛂(backend) do not duplicate user when disabled
## [1.2.1] - 2024-10-03
### Fixed
- 🔧(mail) use new scaleway email gateway #435
## [1.2.0] - 2024-09-30
### Added
- ✨(ci) add helmfile linter and fix argocd sync #424
- ✨(domains) add endpoint to list and retrieve domain accesses #404
- 🍱(dev) embark dimail-api as container #366
- ✨(dimail) allow la regie to request a token for another user #416
- ✨(frontend) show username on AccountDropDown #412
- 🥅(frontend) improve add & update group forms error handling #387
- ✨(frontend) allow group members filtering #363
- ✨(mailbox) send new mailbox confirmation email #397
- ✨(domains) domain accesses update API #423
- ✨(backend) domain accesses create API #428
- 🥅(frontend) catch new errors on mailbox creation #392
- ✨(api) domain accesses delete API #433
- ✨(frontend) add mail domain access management #413
### Fixed
- ♿️(frontend) fix left nav panel #396
- 🔧(backend) fix configuration to avoid different ssl warning #432
### Changed
- ♻️(serializers) move business logic to serializers #414
## [1.1.0] - 2024-09-10
### Added
- 📈(monitoring) configure sentry monitoring #378
- 🥅(frontend) improve api error handling #355
### Changed
- 🗃️(models) move dimail 'secret' to settings #372
### Fixed
- 🐛(dimail) improve handling of dimail errors on failed mailbox creation #377
- 💬(frontend) fix group member removal text #382
- 💬(frontend) fix add mail domain text #382
- 🐛(frontend) fix keyboard navigation #379
- 🐛(frontend) fix add mail domain form submission #355
## [1.0.2] - 2024-08-30
### Added
- 🔧Runtime config for the frontend (#345)
- 🔧(helm) configure resource server in staging
### Fixed
- 👽️(mailboxes) fix mailbox creation after dimail api improvement (#360)
## [1.0.1] - 2024-08-19
### Fixed
- ✨(frontend) user can add mail domains
## [1.0.0] - 2024-08-09
### Added
- ✨(domains) create and manage domains on admin + API
- ✨(domains) mailbox creation + link to email provisioning API
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.4.1...main
[1.4.1]: https://github.com/numerique-gouv/people/releases/v1.4.1
[1.4.0]: https://github.com/numerique-gouv/people/releases/v1.4.0
[1.3.1]: https://github.com/numerique-gouv/people/releases/v1.3.1
[1.3.0]: https://github.com/numerique-gouv/people/releases/v1.3.0
[1.2.1]: https://github.com/numerique-gouv/people/releases/v1.2.1
[1.2.0]: https://github.com/numerique-gouv/people/releases/v1.2.0
[1.1.0]: https://github.com/numerique-gouv/people/releases/v1.1.0
[1.0.2]: https://github.com/numerique-gouv/people/releases/v1.0.2
[1.0.1]: https://github.com/numerique-gouv/people/releases/v1.0.1
[1.0.0]: https://github.com/numerique-gouv/people/releases/v1.0.0
+3 -5
View File
@@ -24,8 +24,8 @@ COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint
RUN yarn --frozen-lockfile
### ---- Front-end builder dev image ----
FROM node:20 as frontend-builder-dev
### ---- Front-end builder image ----
FROM node:20 as frontend-builder
WORKDIR /builder
@@ -34,11 +34,9 @@ COPY ./src/frontend .
WORKDIR ./apps/desk
### ---- Front-end builder image ----
FROM frontend-builder-dev as frontend-builder
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
+3 -42
View File
@@ -88,7 +88,6 @@ bootstrap: \
back-i18n-compile \
mails-install \
mails-build \
dimail-setup-db \
install-front-desk
.PHONY: bootstrap
@@ -110,7 +109,6 @@ run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d app-dev
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d keycloak
@$(COMPOSE) up -d dimail
@echo "Wait for postgresql to be up..."
@$(WAIT_KC_DB)
@$(WAIT_DB)
@@ -131,12 +129,6 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
reset-dimail-container:
@$(COMPOSE) up --force-recreate -d dimail
@$(MAKE) setup-dimail-db
.PHONY: reset-dimail-container
# Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources
lint: \
@@ -174,35 +166,24 @@ test-back-parallel: ## run all back-end tests in parallel
bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel
test-coverage: ## compute, display and save test coverage
bin/pytest --cov=. --cov-report json .
.PHONY: test-coverage
makemigrations: ## run django makemigrations for the people project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) makemigrations $(ARGS)
@$(MANAGE) makemigrations
.PHONY: makemigrations
migrate: ## run django migrations for the people project.
@echo "$(BOLD)Running migrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) migrate $(ARGS)
@$(MANAGE) migrate
.PHONY: migrate
showmigrations: ## run django showmigrations for the people project.
@echo "$(BOLD)Running showmigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) showmigrations $(ARGS)
.PHONY: showmigrations
superuser: ## Create an admin superuser with password "admin"
@echo "$(BOLD)Creating a Django superuser$(RESET)"
$(MANAGE) createsuperuser --username admin --password admin
@$(MANAGE) createsuperuser --email admin@example.com --password admin
.PHONY: superuser
back-i18n-compile: ## compile the gettext files
@@ -280,13 +261,6 @@ i18n-generate-and-upload: \
crowdin-upload
.PHONY: i18n-generate-and-upload
# -- INTEROPERABILTY
# -- Dimail configuration
dimail-setup-db:
@echo "$(BOLD)Populating database of local dimail API container$(RESET)"
@$(MANAGE) setup_dimail_db
.PHONY: dimail-setup-db
# -- Mail generator
@@ -349,16 +323,3 @@ frontend-i18n-generate: \
frontend-i18n-compile: ## Format the crowin json files used deploy to the apps
cd $(PATH_FRONT) && yarn i18n:deploy
.PHONY: frontend-i18n-compile
# -- K8S
start-kind: ## Create the kubernetes cluster
./bin/start-kind.sh
.PHONY: start-kind
tilt-up: ## start tilt - k8s local development
tilt up -f ./bin/Tiltfile
.PHONY: tilt-up
release: ## helper for release and deployment
python scripts/release.py
.PHONY: release
+18 -27
View File
@@ -1,12 +1,12 @@
# People
People is an application to handle users and teams, and distribute permissions accross [La Suite](https://lasuite.numerique.gouv.fr/).
People is an application to handle users and teams.
It is built on top of [Django Rest
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/).
All interoperabilities will be described in `docs/interoperability`.
## Getting started
### Prerequisite
@@ -25,7 +25,7 @@ $ docker compose -v
> ⚠️ You may need to run the following commands with `sudo` but this can be
> avoided by assigning your user to the `docker` group.
### Bootstrap project
### Project bootstrap
The easiest way to start working on the project is to use GNU Make:
@@ -38,7 +38,7 @@ database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
dependency-related or migration-related issues.
Your Docker services should now be up and running! 🎉
Your Docker services should now be up and running 🎉
Note that if you need to run them afterward, you can use the eponym Make rule:
@@ -46,7 +46,13 @@ Note that if you need to run them afterward, you can use the eponym Make rule:
$ make run
```
You can check all available Make rules using:
### Adding content
You can create a basic demo site by running:
$ make demo
Finally, you can check all available Make rules using:
```bash
$ make help
@@ -63,25 +69,6 @@ You first need to create a superuser account:
$ make superuser
```
You can then login with sub `admin` and password `admin`.
### Adding demo content
You can create a basic demo site by running:
```bash
$ make demo
```
### Setting dimail database
To ease local development when working on interoperability between people and dimail, we embark dimail-api in a container running in "fake" mode.
To populate dimail local database with users/domains/permissions needed for basic development:
- log in with "people" user
- run `make dimail-setup-db`
### Run frontend
Run the front with:
@@ -90,10 +77,14 @@ Run the front with:
$ make run-front-desk
```
Then access [http://localhost:3000](http://localhost:3000) with :
Then access at
[http://localhost:3000](http://localhost:3000)
user: people
password: people
## Contributing
This project is intended to be community-driven, so please, do not hesitate to
-58
View File
@@ -1,58 +0,0 @@
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject')
namespace_create('desk')
docker_build(
'localhost:5001/people-backend:latest',
context='..',
dockerfile='../Dockerfile',
only=['./src/backend', './src/mail', './docker'],
target = 'backend-production',
live_update=[
sync('../src/backend', '/app'),
run(
'pip install -r /app/requirements.txt',
trigger=['./api/requirements.txt']
)
]
)
docker_build(
'localhost:5001/people-frontend:latest',
context='..',
dockerfile='../Dockerfile',
build_args={'ENV': 'dev'},
only=['./src/frontend', './src/mail', './docker'],
target = 'frontend-builder-dev',
live_update=[
sync('../src/frontend', '/builder'),
]
)
k8s_yaml(local('cd ../src/helm && helmfile -n desk -e dev template .'))
migration = '''
set -eu
# get k8s pod name from tilt resource name
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
kubectl -n desk exec "$POD_NAME" -- python manage.py makemigrations
'''
cmd_button('Make migration',
argv=['sh', '-c', migration],
resource='desk-backend',
icon_name='developer_board',
text='Run makemigration',
)
pod_migrate = '''
set -eu
# get k8s pod name from tilt resource name
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
kubectl -n desk exec "$POD_NAME" -- python manage.py migrate --no-input
'''
cmd_button('Migrate db',
argv=['sh', '-c', pod_migrate],
resource='desk-backend',
icon_name='developer_board',
text='Run database migration',
)
-102
View File
@@ -1,102 +0,0 @@
#!/bin/sh
set -o errexit
CURRENT_DIR=$(pwd)
# 0. Create ca
echo "0. Create ca"
mkcert -install
cd /tmp
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
cd $CURRENT_DIR
# 1. Create registry container unless it already exists
echo "1. Create registry container unless it already exists"
reg_name='kind-registry'
reg_port='5001'
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
docker run \
-d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
registry:2
fi
# 2. Create kind cluster with containerd registry config dir enabled
echo "2. Create kind cluster with containerd registry config dir enabled"
# TODO: kind will eventually enable this by default and this patch will
# be unnecessary.
#
# See:
# https://github.com/kubernetes-sigs/kind/issues/2875
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
cat <<EOF | kind create cluster --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
nodes:
- role: control-plane
image: kindest/node:v1.27.3
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
- role: worker
image: kindest/node:v1.27.3
- role: worker
image: kindest/node:v1.27.3
EOF
# 3. Add the registry config to the nodes
echo "3. Add the registry config to the nodes"
#
# This is necessary because localhost resolves to loopback addresses that are
# network-namespace local.
# In other words: localhost in the container is not localhost on the host.
#
# We want a consistent name that works from both ends, so we tell containerd to
# alias localhost:${reg_port} to the registry container when pulling images
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
for node in $(kind get nodes); do
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
[host."http://${reg_name}:5000"]
EOF
done
# 4. Connect the registry to the cluster network if not already connected
echo "4. Connect the registry to the cluster network if not already connected"
# This allows kind to bootstrap the network but ensures they're on the same network
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
docker network connect "kind" "${reg_name}"
fi
# 5. Document the local registry
echo "5. Document the local registry"
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: local-registry-hosting
namespace: kube-public
data:
localRegistryHosting.v1: |
host: "localhost:${reg_port}"
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
EOF
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
+3 -8
View File
@@ -1,3 +1,5 @@
version: '3.8'
services:
postgresql:
image: postgres:16
@@ -134,6 +136,7 @@ services:
kc_postgresql:
image: postgres:14.3
platform: linux/amd64
ports:
- "5433:5432"
env_file:
@@ -166,11 +169,3 @@ services:
- "8080:8080"
depends_on:
- kc_postgresql
dimail:
image: registry.mim-libre.fr/dimail/dimail-api:latest
environment:
DIMAIL_MODE: FAKE
DIMAIL_JWT_SECRET: fake_jwt_secret
ports:
- "8001:8000"
-35
View File
@@ -1,35 +0,0 @@
# dimail
## What is dimail ?
The mailing solution provided in La Suite is [Open-XChange](https://www.open-xchange.com/) (OX).
OX not having a provisioning API, 'dimail-api' or 'dimail' was created to allow mail-provisioning through People.
API and its documentation can be found [here](https://api.dev.ox.numerique.gouv.fr/docs#/).
## Architectural links of dimail
As dimail's primary goal is to act as an interface between People and OX, its architecture is similar to that of People. A series of requests are sent from People to dimail upon creating domains, users and accesses.
### Domains
Upon creating a domain on People, the same domain is created on dimail and will undergo a series of checks. When all checks have passed, the domain is considered valid and mailboxes can be created on it.
### Users
The ones issuing requests. Dimail users will reflect domains owners and administrators on People, for logging purposes and to allow direct use of dimail, if desired. User reconciliation is made on user uuid provided by ProConnect.
### Accesses
As for People, an access - a permissions (or "allows" in dimail) - grants an user permission to create objects on a domain.
Permissions requests are sent automatically upon :
- dimail database initialisation:
+ permission for dimail user People to create users and domains
- domain creation :
+ permission for dimail user People to manage domain
+ permission for new owner on new domain
- user creation:
+ permission for People to manage user
- access creation, if owner or admin:
+ permission for this user to manage domain
-164
View File
@@ -1,164 +0,0 @@
## Resource Server
For detailed information, please refer to the [OAuth 2.0 Resource Server documentation](https://www.oauth.com/oauth2-servers/the-resource-server/) and review the relevant commits that implement the resource server backend.
---
#### Overview
A resource server is a crucial component in an OAuth 2.0 ecosystem. It is responsible for accepting access tokens issued by the authorization server (in this case, Agent Connect), introspecting and validating those tokens, and then returning the requested protected resources to the client.
By implementing a resource server, we can securely share data between different services within La Suite. This ensures that only clients with the appropriate scopes and permissions can access specific resources, thereby enhancing security and maintaining proper access control across services.
---
#### Disclaimer
- Currently compatible only with Agent Connect.
- The development setup requires simplification, with dependencies on Agent Connect ideally mocked.
- Terminology aligns with the specification: what is referred to as a "resource server" is known as a "data provider" in Agent Connect.
- This documentation is WIP.
---
## Running Locally
#### Prerequisites
- **Agent Connect Stack**: Ensure the local Agent Connect stack is running. A solid understanding of its configuration and operation is recommended (advanced level).
- **People Stack**: Make sure the People stack is up and running.
- **Ngrok**: Install and set up Ngrok for secure tunneling.
---
### Update People's configurations
#### Environment variables
Agent Connect includes two pre-configured mocked data providers in its default stack (`bdd-fca-low`).
Use the client ID and client secret from one of these data providers. **Note:** Make sure to retrieve the decrypted secret, as it is stored encrypted in the database. You can find these values in the `dp.js` fixture file, where they are exposed in a comment.
Configure your environment with the following values from Agent Connect:
```
OIDC_RS_CLIENT_ID=<your-client-id-from-ac>
OIDC_RS_CLIENT_SECRET=<your-decrypted-client-secret-from-ac>
# In development, the resource server use insecure settings
OIDC_VERIFY_SSL=False
# Update the endpoints as follows
OIDC_OP_JWKS_ENDPOINT=https://core-fca-low.docker.dev-franceconnect.fr/api/v2/jwks
OIDC_OP_INTROSPECTION_ENDPOINT=https://core-fca-low.docker.dev-franceconnect.fr/api/v2/checktoken
OIDC_OP_URL=https://core-fca-low.docker.dev-franceconnect.fr/api/v2
```
#### Docker Network Configuration
To enable communication between the Docker networks for People and Agent Connect, update your docker-compose configuration. This setup is required because the Authorization Server and Resource Server will exchange requests over a back-channel, necessitating their accessibility to each other.
1. **Create a Network**: Define a new network to bridge the two Docker networks.
```yaml
networks:
authorization_server:
external: true
driver: bridge
name: "${DESK_NETWORK:-fc_public}"
```
2. **Update Network for `app-dev`**: Ensure your `app-dev` service is connected to both the default network and the new `authorization_server` network.
```yaml
app-dev:
...
networks:
- default
- authorization_server
```
#### Ngrok
To expose your local resource server through an HTTP tunnel, use Ngrok. This is necessary because, in the Agent Connect development stack, the resource server needs to be accessible to a user agent via a publicly accessible URL.
```
$ ngrok http 8071
```
---
### Update AgentConnect's configurations
Modify the AgentConnect configuration to include the local resource server settings.
**Update `fsa1-low` Environment File**:
Add your Ngrok URL and configure the `DATA_APIS` list with the appropriate values.
```env
App_DATA_APIS=[{"name":"Data Provider 1","url":"https://your-ngrok-url/api/v1.0/any-path","secret":"***"}, ...]
```
**Update Fixture in `dp.js`**:
Adjust the configuration for the data provider to match your local setup. Ensure that the `client_id`, `client_secret`, and other parameters are correctly set and aligned with your environment. This can be configured through environment variables.
```javascript
const dps = [
// Data Provider Configuration
{
uid: "6f21b751-ed06-48b6-a59c-36e1300a368a",
title: "Mock Data Provider - 1",
active: true,
slug: "DESK",
client_id: "***",
client_secret: "***",
// client_secret decrypted : ****
jwks_uri: "https://your-ngrok-url/api/v1.0/jwks", // Update this line
checktoken_signed_response_alg: "ES256",
checktoken_encrypted_response_alg: "RSA-OAEP",
checktoken_encrypted_response_enc: "A256GCM",
},
];
```
**Note**: Ensure that the `jwks_uri` and other cryptographic parameters (e.g., `checktoken_signed_response_alg`, `checktoken_encrypted_response_alg`, and `checktoken_encrypted_response_enc`) match your actual setup and are configured via environment variables where necessary.
---
### Usage
This section is a work in progress. Please note the following important points:
#### User `sub` Matching
Ensure that the `sub` (subject) field for users in AgentConnect matches the corresponding value in the People database. To synchronize this, you can run `make demo`, then edit the user's `sub` field to match the value returned by AgentConnect. For this, you'll need to update the editable field in Django Admin, specifically in `admin.py`. Adjust the `get_readonly_fields` method as follows:
```python
def get_readonly_fields(self, request, obj=None):
"""The 'sub' field should only be editable during creation, not for updates."""
if obj:
return self.readonly_fields
return self.readonly_fields + ["sub"] # update this line adding 'sub'
```
#### Scope for `groups`
Ensure that the `groups` scope is requested from the service provider during authentication with AgentConnect.
#### Resource Server Requests
By default, the `fsa1-low` environment calls the resource server using a POST request.
#### Testing
Most of the testing has been done using the `/users/me` endpoint. Update the `api/viewset.py` configuration to allow both GET and POST methods for this endpoint:
```python
@decorators.action(
detail=False,
methods=["get", "post"], # update this line adding 'post'
url_name="me",
url_path="me",
)
```
-32
View File
@@ -33,35 +33,3 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
OIDC_RS_CLIENT_ID=people
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RS_PRIVATE_KEY_STR="-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3boG1kwEGUYL+
U58RPrVToIsF9jHB64S6WJIIInPmAclBciXFb6BWG11mbRIgo8ha3WVnC/tGHbXb
ndiKdrH2vKHOsDhV9AmgHgNgWaUK9L0uuKEb/xMLePYWsYlgzcQJx8RZY7RQyWqE
20WfzFxeuCE7QMb6VXSOgwQMnJsKocguIh3VCI9RIBq3B1kdgW35AD63YKOygmGx
qjcWwbjhKLvkF7LpBdlyAEzOKqg4T5uCcHMfksMW2+foTJx70RrZM/KHU+Zysuw7
uhhVsgPBG+CsqBSjHQhs7jzymqxtQAfe1FkrCRxOq5Pv2Efr7kgtVSkJJiX3KutM
vnWuEypxAgMBAAECggEAGqKS9pbrN+vnmb7yMsqYgVVnQn0aggZNHlLkl4ZLLnuV
aemlhur7zO0JzajqUC+AFQOfaQxiFu8S/FoJ+qccFdATrcPEVmTKbgPVqSyzLKlX
fByGll5eOVT95NMwN8yBGgt2HSW/ZditXS/KxxahVgamGqjAC9MTSutGz/8Ae1U+
DNDBJCc6RAqu3T02tV9A2pSpVC1rSktDMpLUTscnsfxpaEQATd9DJUcHEvIwoX8q
GJpycPEhNhdPXqpln5SoMHcf/zS5ssF/Mce0lJJXYyE0LnEk9X12jMWyBqmLqXUY
cKLyynaFbis0DpQppwKx2y8GpL76k+Ci4dOHIvFknQKBgQDj/2WRMcWOvfBrggzj
FHpcme2gSo5A5c0CVyI+Xkf1Zab6UR6T7GiImEoj9tq0+o2WEix9rwoypgMBq8rz
/rrJAPSZjgv6z71k4EnO2FIB5R03vQmoBRCN8VlgvLM0xv52zyjV4Wx66Q4MDjyH
EgkpHyB0FzRZh0UzhnE/pYSetQKBgQDN9eLB1nA4CBSr1vMGNfQyfBQl3vpO9EP4
VSS3KnUqCIjJeLu682Ylu7SFxcJAfzUpy5S43hEvcuJsagsVKfmCAGcYZs9/xq3I
vzYyhaEOS5ezNxLSh4+yCNBPlmrmDyoazag0t8H8YQFBN6BVcxbATHqdWGUhIhYN
eEpEMOh2TQKBgGBr7kRNTENlyHtu8IxIaMcowfn8DdUcWmsW9oBx1vTNHKTYEZp1
bG/4F8LF7xCCtcY1wWMV17Y7xyG5yYcOv2eqY8dc72wO1wYGZLB5g5URlB2ycJcC
LVIaM7ZZl2BGl+8fBSIOx5XjYfFvQ+HLmtwtMchm19jVAEseHF7SXRfRAoGAK15j
aT2mU6Yf9C9G7T/fM+I8u9zACHAW/+ut14PxN/CkHQh3P16RW9CyqpiB1uLyZuKf
Zm4cYElotDuAKey0xVMgYlsDxnwni+X3m5vX1hLE1s/5/qrc7zg75QZfbCI1U3+K
s88d4e7rPLhh4pxhZgy0pP1ADkIHMr7ppIJH8OECgYEApNfbgsJVPAMzucUhJoJZ
OmZHbyCtJvs4b+zxnmhmSbopifNCgS4zjXH9qC7tsUph1WE6L2KXvtApHGD5H4GQ
IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
MbyqKyC6DAzv4jEEhHaN7oY=
-----END PRIVATE KEY-----
"
+1 -1
View File
@@ -13,7 +13,7 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
"matchPackageNames": ["node", "node-fetch", "i18next-parser"]
}
]
}
-115
View File
@@ -1,115 +0,0 @@
import datetime
import os
import re
import sys
from utils import run_command
RELEASE_KINDS = {'p': 'patch', 'm': 'minor', 'mj': 'major'}
def update_files(version):
"""Update all files needed with new release version"""
# pyproject.toml
sys.stdout.write("Update pyproject.toml...\n")
path = "src/backend/pyproject.toml"
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if line.startswith("version = "):
lines[index] = re.sub(r'\"(.*?)\"', f'"{version}"', line)
with open(path, 'w+') as file:
file.writelines(lines)
# helm files
sys.stdout.write("Update helm files...\n")
for env in ["preprod", "production"]:
path = f"src/helm/env.d/{env}/values.desk.yaml.gotmpl"
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if "tag:" in line:
lines[index] = re.sub(r'\"(.*?)\"', f'"v{version}"', line)
with open(path, 'w+') as file:
file.writelines(lines)
# frontend package.json
sys.stdout.write("Update package.json...\n")
files_to_modify = []
filename = "package.json"
for root, _dir, files in os.walk("src/frontend"):
if filename in files and "node_modules" not in root and ".next" not in root:
files_to_modify.append(os.path.join(root, filename))
for path in files_to_modify:
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if "version" in line:
lines[index] = re.sub(r'"version": \"(.*?)\"', f'"version": "{version}"', line)
with open(path, 'w+') as file:
file.writelines(lines)
return
def update_changelog(path, version):
"""Update changelog file with release info
"""
sys.stdout.write("Update CHANGELOG...\n")
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if "## [Unreleased]" in line:
today = datetime.date.today()
lines.insert(index + 1, f"\n## [{version}] - {today}\n")
if line.startswith("[unreleased]"):
last_version = lines[index + 1].split("]")[0][1:]
new_unreleased_line = line.replace(last_version, version)
new_release_line = lines[index + 1].replace(last_version, version)
lines[index] = new_unreleased_line
lines.insert(index + 1, new_release_line)
break
with open(path, 'w+') as file:
file.writelines(lines)
def prepare_release(version, kind):
sys.stdout.write('Let\'s go to create branch to release\n')
branch_to_release = f"release/{version}"
run_command(f"git checkout -b {branch_to_release}", shell=True)
run_command("git pull --rebase origin main", shell=True)
update_changelog("CHANGELOG.md", version)
update_files(version)
run_command("git add CHANGELOG.md", shell=True)
run_command("git add src/", shell=True)
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
run_command(f"git commit -m '{message}'", shell=True)
confirm = input(f"\nNEXT COMMAND: \n>> git push origin {branch_to_release}\nContinue ? (y,n) ")
if confirm == 'y':
run_command(f"git push origin {branch_to_release}", shell=True)
sys.stdout.write(f"""
PLEASE DO THE FOLLOWING INSTRUCTIONS:
--> Please submit PR and merge code to main than tag version
>> git tag -a v{version}
>> git push origin v{version}
--> Please check docker image: https://hub.docker.com/r/lasuite/people-backend/tags
>> git tag -d preprod
>> git tag preprod
>> git push -f origin preprod
--> Now please generate release on github interface for the current tag v{version}
""")
if __name__ == "__main__":
version, kind = None, None
while not version:
version = input("Enter your release version:")
while kind not in RELEASE_KINDS:
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
prepare_release(version, kind)
-4
View File
@@ -1,4 +0,0 @@
#!/bin/bash
git submodule update --init --recursive
git submodule foreach 'git fetch origin; git checkout $(git rev-parse --abbrev-ref HEAD); git reset --hard origin/$(git rev-parse --abbrev-ref HEAD); git submodule update --recursive; git clean -dfx'
-14
View File
@@ -1,14 +0,0 @@
import subprocess
import sys
def run_command(cmd, msg=None, shell=False, cwd='.', stdout=None):
if msg is None:
msg = f"# Running: {cmd}"
if stdout is not None:
stdout.write(msg + '\n')
if stdout != sys.stdout:
sys.stdout(msg)
subprocess.check_call(cmd, shell=shell, cwd=cwd, stdout=stdout, stderr=stdout)
if stdout is not None:
stdout.flush()
Submodule secrets deleted from b7ab5f1411
+44 -46
View File
@@ -1,14 +1,49 @@
"""Admin classes and registrations for People's core app."""
from django import forms
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from mailbox_manager.admin import MailDomainAccessInline
from . import models
class IdentityFormSet(forms.BaseInlineFormSet):
"""
Make the "is_main" field readonly when it is True so that declaring another identity
works in the admin.
"""
def add_fields(self, form, index):
"""Disable the "is_main" field when it is set to True"""
super().add_fields(form, index)
is_main_value = form.instance.is_main if form.instance else False
form.fields["is_main"].disabled = is_main_value
class IdentityInline(admin.TabularInline):
"""Inline admin class for user identities."""
fields = (
"sub",
"email",
"is_main",
"created_at",
"updated_at",
)
formset = IdentityFormSet
model = models.Identity
extra = 0
readonly_fields = ("email", "created_at", "sub", "updated_at")
def has_add_permission(self, request, obj):
"""
Identities are automatically created on successful OIDC logins.
Disable creating identities via the admin.
"""
return False
class TeamAccessInline(admin.TabularInline):
"""Inline admin class for team accesses."""
@@ -18,15 +53,6 @@ class TeamAccessInline(admin.TabularInline):
readonly_fields = ("created_at", "updated_at")
class TeamWebhookInline(admin.TabularInline):
"""Inline admin class for team webhooks."""
extra = 0
autocomplete_fields = ["team"]
model = models.TeamWebhook
readonly_fields = ("created_at", "updated_at")
@admin.register(models.User)
class UserAdmin(auth_admin.UserAdmin):
"""Admin class for the User model"""
@@ -37,12 +63,11 @@ class UserAdmin(auth_admin.UserAdmin):
{
"fields": (
"id",
"sub",
"password",
)
},
),
(_("Personal info"), {"fields": ("name", "email", "language", "timezone")}),
(_("Personal info"), {"fields": ("email", "language", "timezone")}),
(
_("Permissions"),
{
@@ -63,13 +88,13 @@ class UserAdmin(auth_admin.UserAdmin):
None,
{
"classes": ("wide",),
"fields": ("sub", "email", "password1", "password2"),
"fields": ("email", "password1", "password2"),
},
),
)
inlines = (TeamAccessInline, MailDomainAccessInline)
inlines = (IdentityInline, TeamAccessInline)
list_display = (
"get_user",
"email",
"created_at",
"updated_at",
"is_active",
@@ -79,29 +104,15 @@ class UserAdmin(auth_admin.UserAdmin):
)
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
readonly_fields = ["id", "created_at", "updated_at"]
search_fields = ("id", "email", "sub", "name")
def get_readonly_fields(self, request, obj=None):
"""The sub should only be editable for a create, not for updates."""
if obj:
return self.readonly_fields + ["sub"]
return self.readonly_fields
def get_user(self, obj):
"""Provide a nice display for user"""
return (
obj.name if obj.name else (obj.email if obj.email else f"[sub] {obj.sub}")
)
get_user.short_description = _("User")
readonly_fields = ("id", "created_at", "updated_at")
search_fields = ("id", "email", "identities__sub", "identities__email")
@admin.register(models.Team)
class TeamAdmin(admin.ModelAdmin):
"""Team admin interface declaration."""
inlines = (TeamAccessInline, TeamWebhookInline)
inlines = (TeamAccessInline,)
list_display = (
"name",
"slug",
@@ -111,19 +122,6 @@ class TeamAdmin(admin.ModelAdmin):
search_fields = ("name",)
@admin.register(models.TeamAccess)
class TeamAccessAdmin(admin.ModelAdmin):
"""Team access admin interface declaration."""
list_display = (
"user",
"team",
"role",
"created_at",
"updated_at",
)
@admin.register(models.Invitation)
class InvitationAdmin(admin.ModelAdmin):
"""Admin interface to handle invitations."""
-2
View File
@@ -23,8 +23,6 @@ def exception_handler(exc, context):
detail = exc.message
elif hasattr(exc, "messages"):
detail = exc.messages
else:
detail = ""
exc = drf_exceptions.ValidationError(detail=detail)
+23 -3
View File
@@ -52,22 +52,42 @@ class UserSerializer(DynamicFieldsModelSerializer):
"""Serialize users."""
timezone = TimeZoneSerializerField(use_pytz=False, required=True)
email = serializers.ReadOnlyField()
name = serializers.ReadOnlyField()
name = serializers.SerializerMethodField(read_only=True)
email = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.User
fields = [
"id",
"name",
"email",
"language",
"name",
"timezone",
"is_device",
"is_staff",
]
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
def _get_main_identity_attr(self, obj, attribute_name):
"""Return the specified attribute of the main identity."""
try:
return getattr(obj.main_identity[0], attribute_name)
except TypeError:
return getattr(obj.main_identity, attribute_name)
except IndexError:
main_identity = obj.identities.filter(is_main=True).first()
return getattr(obj.main_identity, attribute_name) if main_identity else None
except AttributeError:
return None
def get_name(self, obj):
"""Return main identity's name."""
return self._get_main_identity_attr(obj, "name")
def get_email(self, obj):
"""Return main identity's email."""
return self._get_main_identity_attr(obj, "email")
class TeamAccessSerializer(serializers.ModelSerializer):
"""Serialize team accesses."""
+30 -43
View File
@@ -1,8 +1,7 @@
"""API endpoints"""
from django.conf import settings
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 django.db.models.functions import Coalesce
from rest_framework import (
@@ -13,10 +12,8 @@ from rest_framework import (
pagination,
response,
throttling,
views,
viewsets,
)
from rest_framework.permissions import AllowAny
from core import models
@@ -201,6 +198,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",
)
)
# Exclude all users already in the given team
@@ -211,11 +214,15 @@ class UserViewSet(
if query := self.request.GET.get("q", ""):
similarity = Max(
TrigramSimilarity(
Coalesce(Func("email", function="unaccent"), Value("")),
Coalesce(
Func("identities__email", function="unaccent"), Value("")
),
Func(Value(query), function="unaccent"),
)
+ TrigramSimilarity(
Coalesce(Func("name", function="unaccent"), Value("")),
Coalesce(
Func("identities__name", function="unaccent"), Value("")
),
Func(Value(query), function="unaccent"),
)
)
@@ -238,6 +245,9 @@ class UserViewSet(
Return information on currently logged user
"""
user = request.user
user.main_identity = models.Identity.objects.filter(
user=user, is_main=True
).first()
return response.Response(
self.serializer_class(user, context={"request": request}).data
)
@@ -322,7 +332,7 @@ class TeamAccessViewSet(
filter_backends = [filters.OrderingFilter]
ordering = ["role"]
ordering_fields = ["role", "user__email", "user__name"]
ordering_fields = ["role", "email", "name"]
def get_permissions(self):
"""User only needs to be authenticated to list team accesses"""
@@ -350,39 +360,34 @@ class TeamAccessViewSet(
queryset = queryset.filter(team=self.kwargs["team_id"])
if self.action in {"list", "retrieve"}:
if query := self.request.GET.get("q", ""):
similarity = Max(
TrigramSimilarity(
Coalesce(Func("user__email", function="unaccent"), Value("")),
Func(Value(query), function="unaccent"),
)
+ TrigramSimilarity(
Coalesce(Func("user__name", function="unaccent"), Value("")),
Func(Value(query), function="unaccent"),
)
)
queryset = (
queryset.annotate(similarity=similarity)
.filter(similarity__gte=SIMILARITY_THRESHOLD)
.order_by("-similarity")
)
# Determine which role the logged-in user has in the team
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=self.kwargs["team_id"]
).values("role")[:1]
user_main_identity_query = models.Identity.objects.filter(
user=OuterRef("user_id"), is_main=True
)
queryset = (
# The logged-in user should be part of a team to see its accesses
queryset.filter(
team__accesses__user=self.request.user,
)
.prefetch_related(
Prefetch(
"user__identities",
queryset=models.Identity.objects.filter(is_main=True),
to_attr="main_identity",
)
)
# Abilities are computed based on logged-in user's role and
# the user role on each team access
.annotate(
user_role=Subquery(user_role_query),
email=Subquery(user_main_identity_query.values("email")[:1]),
name=Subquery(user_main_identity_query.values("name")[:1]),
)
.select_related("user")
.distinct()
)
return queryset
@@ -494,21 +499,3 @@ class InvitationViewset(
.distinct()
)
return queryset
class ConfigView(views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
def get(self, request):
"""
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = ["LANGUAGES", "FEATURES", "RELEASE"]
dict_settings = {}
for setting in array_settings:
dict_settings[setting] = getattr(settings, setting)
return response.Response(dict_settings)
@@ -1,8 +1,8 @@
"""Authentication Backends for the People core app."""
"""Authentication for the People core app."""
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
from django.db import models
from django.utils.translation import gettext_lazy as _
import requests
@@ -10,14 +10,14 @@ from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
User = get_user_model()
from .models import Identity
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
This class overrides the default OIDC Authentication Backend to accommodate differences
in the User model, and handles signed and/or encrypted UserInfo response.
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
"""
def get_userinfo(self, access_token, id_token, payload):
@@ -51,7 +51,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
return userinfo
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Create a new user if no match is found.
"""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.
@@ -67,30 +67,42 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
user_info = self.get_userinfo(access_token, id_token, payload)
# Get user's full name from OIDC fields defined in settings
full_name = self.compute_full_name(user_info)
email = user_info.get("email")
claims = {
"email": email,
"name": full_name,
}
# 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
sub = user_info.get("sub")
if not sub:
if sub is None:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
# if sub is absent, try matching on email
user = self.get_existing_user(sub, email)
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:
if not user.is_active:
raise SuspiciousOperation(_("User account is disabled"))
self.update_user_if_needed(user, claims)
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 = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
user = self.create_user(user_info)
return user
@@ -102,38 +114,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
_("Claims contained no recognizable user identification")
)
return self.UserModel.objects.create(
password="!", # noqa: S106
sub=sub,
email=claims.get("email"),
name=claims.get("name"),
user = self.UserModel.objects.create(password="!") # noqa: S106
Identity.objects.create(
user=user, sub=sub, email=claims.get("email"), name=claims.get("name")
)
def compute_full_name(self, user_info):
"""Compute user's full name based on OIDC fields in settings."""
name_fields = settings.USER_OIDC_FIELDS_TO_NAME
full_name = " ".join(
user_info[field] for field in name_fields if user_info.get(field)
)
return full_name or None
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return User.objects.get(email=email)
except User.DoesNotExist:
pass
return None
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
has_changed = any(
value and value != getattr(user, key) for key, value in claims.items()
)
if has_changed:
updated_claims = {key: value for key, value in claims.items() if value}
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
return user
-18
View File
@@ -1,18 +0,0 @@
"""Authentication URLs for the People core app."""
from django.urls import path
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
from .views import OIDCLogoutCallbackView, OIDCLogoutView
urlpatterns = [
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
path(
"logout-callback/",
OIDCLogoutCallbackView.as_view(),
name="oidc_logout_callback",
),
*mozzila_oidc_urls,
]
-137
View File
@@ -1,137 +0,0 @@
"""Authentication Views for the People core app."""
from urllib.parse import urlencode
from django.contrib import auth
from django.core.exceptions import SuspiciousOperation
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils import crypto
from mozilla_django_oidc.utils import (
absolutify,
)
from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
Adds support for handling logout callbacks from the identity provider (OP)
by initiating the logout flow if the user has an active session.
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
This parameter is crucial for maintaining the integrity of the logout flow between this call
and the subsequent callback.
"""
@staticmethod
def persist_state(request, state):
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
This method is used to store the OIDC state parameter in the session, according to the
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
utility function.
"""
if "oidc_states" not in request.session or not isinstance(
request.session["oidc_states"], dict
):
request.session["oidc_states"] = {}
request.session["oidc_states"][state] = {}
request.session.save()
def construct_oidc_logout_url(self, request):
"""Create the redirect URL for interfacing with the OIDC provider.
Retrieves the necessary parameters from the session and constructs the URL
required to initiate logout with the OpenID Connect provider.
If no ID token is found in the session, the logout flow will not be initiated,
and the method will return the default redirect URL.
The 'state' parameter is generated randomly and persisted in the session to ensure
its integrity during the subsequent callback.
"""
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
if not oidc_logout_endpoint:
return self.redirect_url
reverse_url = reverse("oidc_logout_callback")
id_token = request.session.get("oidc_id_token", None)
if not id_token:
return self.redirect_url
query = {
"id_token_hint": id_token,
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
"post_logout_redirect_uri": absolutify(request, reverse_url),
}
self.persist_state(request, query["state"])
return f"{oidc_logout_endpoint}?{urlencode(query)}"
def post(self, request):
"""Handle user logout.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, constructs the OIDC logout URL and redirects the user to start
the logout process.
If the user is redirected to the default logout URL, ensure her Django session
is terminated.
"""
logout_url = self.redirect_url
if request.user.is_authenticated:
logout_url = self.construct_oidc_logout_url(request)
# If the user is not redirected to the OIDC provider, ensure logout
if logout_url == self.redirect_url:
auth.logout(request)
return HttpResponseRedirect(logout_url)
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
Handles the callback after logout from the identity provider (OP).
Verifies the state parameter and performs necessary logout actions.
The Django session is maintained during the logout process to ensure the integrity
of the logout flow initiated in the previous step.
"""
http_method_names = ["get"]
def get(self, request):
"""Handle the logout callback.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, verifies the state parameter and performs necessary logout actions.
"""
if not request.user.is_authenticated:
return HttpResponseRedirect(self.redirect_url)
state = request.GET.get("state")
if state not in request.session.get("oidc_states", {}):
msg = "OIDC callback state not found in session `oidc_states`!"
raise SuspiciousOperation(msg)
del request.session["oidc_states"][state]
request.session.save()
auth.logout(request)
return HttpResponseRedirect(self.redirect_url)
-9
View File
@@ -3,7 +3,6 @@ Core application enums declaration
"""
from django.conf import global_settings, settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
@@ -15,11 +14,3 @@ ALL_LANGUAGES = getattr(
"ALL_LANGUAGES",
[(language, _(name)) for language, name in global_settings.LANGUAGES],
)
class WebhookStatusChoices(models.TextChoices):
"""Defines the possible statuses in which a webhook can be."""
FAILURE = "failure", _("Failure")
PENDING = "pending", _("Pending")
SUCCESS = "success", _("Success")
+14 -13
View File
@@ -120,17 +120,28 @@ class ContactFactory(BaseContactFactory):
class UserFactory(factory.django.DjangoModelFactory):
"""A factory to create random users for testing purposes."""
"""A factory to random users for testing purposes."""
class Meta:
model = models.User
django_get_or_create = ("email",)
email = factory.Faker("email")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
class IdentityFactory(factory.django.DjangoModelFactory):
"""A factory to create identities for a user"""
class Meta:
model = models.Identity
django_get_or_create = ("sub",)
user = factory.SubFactory(UserFactory)
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
name = factory.Faker("name")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
class TeamFactory(factory.django.DjangoModelFactory):
@@ -166,16 +177,6 @@ class TeamAccessFactory(factory.django.DjangoModelFactory):
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class TeamWebhookFactory(factory.django.DjangoModelFactory):
"""Create fake team webhooks for testing."""
class Meta:
model = models.TeamWebhook
team = factory.SubFactory(TeamFactory)
url = factory.Sequence(lambda n: f"https://example.com/Groups/{n!s}")
class InvitationFactory(factory.django.DjangoModelFactory):
"""A factory to create invitations for a user"""
+29 -25
View File
@@ -1,4 +1,4 @@
# Generated by Django 5.0.3 on 2024-06-08 09:25
# Generated by Django 5.0.2 on 2024-03-05 17:09
import django.contrib.auth.models
import django.core.validators
@@ -45,9 +45,7 @@ class Migration(migrations.Migration):
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('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')),
('email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='email address')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
@@ -89,6 +87,25 @@ class Migration(migrations.Migration):
name='profile_contact',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user', to='core.contact'),
),
migrations.CreateModel(
name='Identity',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('sub', models.CharField(help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='email address')),
('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='name')),
('is_main', models.BooleanField(default=False, help_text='Designates whether the email is the main one.', verbose_name='main')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identities', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'identity',
'verbose_name_plural': 'identities',
'db_table': 'people_identity',
'ordering': ('-is_main', 'email'),
},
),
migrations.CreateModel(
name='Invitation',
fields=[
@@ -127,35 +144,22 @@ class Migration(migrations.Migration):
name='users',
field=models.ManyToManyField(related_name='teams', through='core.TeamAccess', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='TeamWebhook',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('url', models.URLField(verbose_name='url')),
('secret', models.CharField(blank=True, max_length=255, null=True, verbose_name='secret')),
('status', models.CharField(choices=[('failure', 'Failure'), ('pending', 'Pending'), ('success', 'Success')], default='pending', max_length=10)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhooks', to='core.team')),
],
options={
'verbose_name': 'Team webhook',
'verbose_name_plural': 'Team webhooks',
'db_table': 'people_team_webhook',
},
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(check=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(condition=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(condition=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
constraint=models.CheckConstraint(check=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
),
migrations.AlterUniqueTogether(
name='contact',
unique_together={('owner', 'base')},
),
migrations.AddConstraint(
model_name='identity',
constraint=models.UniqueConstraint(fields=('user', 'email'), name='unique_user_email', violation_error_message='This email address is already declared for this user.'),
),
migrations.AddConstraint(
model_name='invitation',
constraint=models.UniqueConstraint(fields=('email', 'team'), name='email_and_team_unique_together'),
+107 -106
View File
@@ -12,9 +12,8 @@ from logging import getLogger
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.db import models, transaction
from django.db import models
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.functional import lazy
@@ -25,9 +24,6 @@ from django.utils.translation import override
import jsonschema
from timezone_field import TimeZoneField
from core.enums import WebhookStatusChoices
from core.utils.webhooks import scim_synchronizer
logger = getLogger(__name__)
current_dir = os.path.dirname(os.path.abspath(__file__))
@@ -78,7 +74,7 @@ class BaseModel(models.Model):
def save(self, *args, **kwargs):
"""Call `full_clean` before saving."""
self.full_clean()
return super().save(*args, **kwargs)
super().save(*args, **kwargs)
class Contact(BaseModel):
@@ -124,12 +120,12 @@ class Contact(BaseModel):
unique_together = ("owner", "base")
constraints = [
models.CheckConstraint(
condition=~models.Q(base__isnull=False, owner__isnull=True),
check=~models.Q(base__isnull=False, owner__isnull=True),
name="base_owner_constraint",
violation_error_message="A contact overriding a base contact must be owned.",
),
models.CheckConstraint(
condition=~models.Q(base=models.F("id")),
check=~models.Q(base=models.F("id")),
name="base_not_self",
violation_error_message="A contact cannot be based on itself.",
),
@@ -161,25 +157,7 @@ class Contact(BaseModel):
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
),
max_length=255,
unique=True,
validators=[sub_validator],
)
email = models.EmailField(_("email address"), null=True, blank=True)
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
email = models.EmailField(_("email address"), unique=True, null=True, blank=True)
profile_contact = models.OneToOneField(
Contact,
on_delete=models.SET_NULL,
@@ -221,7 +199,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
objects = auth_models.UserManager()
USERNAME_FIELD = "sub"
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
class Meta:
@@ -230,11 +208,91 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
verbose_name_plural = _("users")
def __str__(self):
return self.name if self.name else self.email or f"User {self.sub}"
return (
str(self.profile_contact)
if self.profile_contact
else self.email or str(self.id)
)
def clean(self):
"""Validate fields."""
super().clean()
if self.profile_contact_id and not self.profile_contact.owner == self:
raise exceptions.ValidationError(
"Users can only declare as profile a contact they own."
)
def email_user(self, subject, message, from_email=None, **kwargs):
"""Email this user."""
main_identity = self.identities.get(is_main=True)
mail.send_mail(subject, message, from_email, [main_identity.email], **kwargs)
@classmethod
def get_email_field_name(cls):
"""
Raise error when trying to get email field name from the user as we are using
a separate Email model to allow several emails per user.
"""
raise NotImplementedError(
"This feature is deactivated to allow several emails per user."
)
class Identity(BaseModel):
"""User identity"""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
),
)
user = models.ForeignKey(User, related_name="identities", on_delete=models.CASCADE)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
),
max_length=255,
unique=True,
validators=[sub_validator],
)
email = models.EmailField(_("email address"), null=True, blank=True)
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
is_main = models.BooleanField(
_("main"),
default=False,
help_text=_("Designates whether the email is the main one."),
)
class Meta:
db_table = "people_identity"
ordering = ("-is_main", "email")
verbose_name = _("identity")
verbose_name_plural = _("identities")
constraints = [
# Uniqueness
models.UniqueConstraint(
fields=["user", "email"],
name="unique_user_email",
violation_error_message=_(
"This email address is already declared for this user."
),
),
]
def __str__(self):
main_str = "[main]" if self.is_main else ""
id_str = self.email or self.sub
return f"{id_str:s}{main_str:s}"
def save(self, *args, **kwargs):
"""
If it's a new user, give her access to the relevant teams.
Saves identity, ensuring users always have exactly one main identity.
If it's a new identity, give its user access to the relevant teams.
"""
if self._state.adding:
@@ -242,16 +300,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
super().save(*args, **kwargs)
def clean(self):
"""Validate fields."""
super().clean()
if self.email:
self.email = User.objects.normalize_email(self.email)
if self.profile_contact_id and not self.profile_contact.owner == self:
raise exceptions.ValidationError(
"Users can only declare as profile a contact they own."
)
# Ensure users always have one and only one main identity.
if self.is_main is True:
self.user.identities.exclude(id=self.id).update(is_main=False)
def _convert_valid_invitations(self):
"""
@@ -272,17 +323,24 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
TeamAccess.objects.bulk_create(
[
TeamAccess(user=self, team=invitation.team, role=invitation.role)
TeamAccess(user=self.user, team=invitation.team, role=invitation.role)
for invitation in valid_invitations
]
)
valid_invitations.delete()
def email_user(self, subject, message, from_email=None, **kwargs):
"""Email this user."""
if not self.email:
raise ValueError("You must first set an email for the user.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
def clean(self):
"""Normalize the email field and clean the 'is_main' field."""
if self.email:
self.email = User.objects.normalize_email(self.email)
if not self.user.identities.exclude(pk=self.pk).filter(is_main=True).exists():
if not self.created_at:
self.is_main = True
elif not self.is_main:
raise exceptions.ValidationError(
{"is_main": "A user should have one and only one main identity."}
)
super().clean()
class Team(BaseModel):
@@ -310,7 +368,7 @@ class Team(BaseModel):
return self.name
def save(self, *args, **kwargs):
"""Override save function to compute the slug."""
"""Overriding save function to compute the slug."""
self.slug = self.get_slug()
return super().save(*args, **kwargs)
@@ -377,34 +435,6 @@ class TeamAccess(BaseModel):
def __str__(self):
return f"{self.user!s} is {self.role:s} in team {self.team!s}"
def save(self, *args, **kwargs):
"""
Override save function to fire webhooks on any addition or update
to a team access.
"""
if self._state.adding:
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
with transaction.atomic():
instance = super().save(*args, **kwargs)
scim_synchronizer.add_user_to_group(self.team, self.user)
else:
instance = super().save(*args, **kwargs)
return instance
def delete(self, *args, **kwargs):
"""
Override delete method to fire webhooks on to team accesses.
Don't allow deleting a team access until it is successfully synchronized with all
its webhooks.
"""
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
with transaction.atomic():
arguments = self.team, self.user
super().delete(*args, **kwargs)
scim_synchronizer.remove_user_from_group(*arguments)
def get_abilities(self, user):
"""
Compute and return abilities for a given user taking into account
@@ -455,34 +485,6 @@ class TeamAccess(BaseModel):
}
class TeamWebhook(BaseModel):
"""Webhooks fired on changes in teams."""
team = models.ForeignKey(Team, related_name="webhooks", on_delete=models.CASCADE)
url = models.URLField(_("url"))
secret = models.CharField(_("secret"), max_length=255, null=True, blank=True)
status = models.CharField(
max_length=10,
default=WebhookStatusChoices.PENDING,
choices=WebhookStatusChoices.choices,
)
class Meta:
db_table = "people_team_webhook"
verbose_name = _("Team webhook")
verbose_name_plural = _("Team webhooks")
def __str__(self):
return f"Webhook to {self.url} for {self.team}"
def get_headers(self):
"""Build header dict from webhook object."""
headers = {"Content-Type": "application/json"}
if self.secret:
headers["Authorization"] = f"Bearer {self.secret:s}"
return headers
class Invitation(BaseModel):
"""User invitation to teams."""
@@ -526,8 +528,8 @@ class Invitation(BaseModel):
"""Validate fields."""
super().clean()
# Check if a user already exists for the provided email
if User.objects.filter(email=self.email).exists():
# Check if an identity already exists for the provided email
if Identity.objects.filter(email=self.email).exists():
raise exceptions.ValidationError(
{"email": _("This email is already associated to a registered user.")}
)
@@ -572,7 +574,6 @@ class Invitation(BaseModel):
with override(self.issuer.language):
template_vars = {
"title": _("Invitation to join Desk!"),
"site": Site.objects.get_current(),
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
@@ -1,54 +0,0 @@
"""Resource Server Authentication"""
import base64
import binascii
import logging
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from mozilla_django_oidc.contrib.drf import OIDCAuthentication
from .backend import ResourceServerBackend, ResourceServerImproperlyConfiguredBackend
from .clients import AuthorizationServerClient
logger = logging.getLogger(__name__)
class ResourceServerAuthentication(OIDCAuthentication):
"""Authenticate clients using the token received from the authorization server."""
def __init__(self):
super().__init__()
try:
authorization_server_client = AuthorizationServerClient(
url=settings.OIDC_OP_URL,
verify_ssl=settings.OIDC_VERIFY_SSL,
timeout=settings.OIDC_TIMEOUT,
proxy=settings.OIDC_PROXY,
url_jwks=settings.OIDC_OP_JWKS_ENDPOINT,
url_introspection=settings.OIDC_OP_INTROSPECTION_ENDPOINT,
)
self.backend = ResourceServerBackend(authorization_server_client)
except ImproperlyConfigured as err:
message = "Resource Server authentication is disabled"
logger.debug("%s. Exception: %s", message, err)
self.backend = ResourceServerImproperlyConfiguredBackend()
def get_access_token(self, request):
"""Retrieve and decode the access token from the request.
This method overcharges the 'get_access_token' method from the parent class,
to support service providers that would base64 encode the bearer token.
"""
access_token = super().get_access_token(request)
try:
access_token = base64.b64decode(access_token).decode("utf-8")
except (binascii.Error, TypeError):
pass
return access_token
-223
View File
@@ -1,223 +0,0 @@
"""Resource Server Backend"""
import logging
from django.conf import settings
from django.contrib import auth
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from joserfc import jwe as jose_jwe
from joserfc import jwt as jose_jwt
from joserfc.errors import InvalidClaimError, InvalidTokenError
from requests.exceptions import HTTPError
from rest_framework.exceptions import AuthenticationFailed
from . import utils
logger = logging.getLogger(__name__)
class ResourceServerBackend:
"""Backend of an OAuth 2.0 resource server.
This backend is designed to authenticate resource owners to our API using the access token
they received from the authorization server.
In the context of OAuth 2.0, a resource server is a server that hosts protected resources and
is capable of accepting and responding to protected resource requests using access tokens.
The resource server verifies the validity of the access tokens issued by the authorization
server to ensure secure access to the resources.
For more information, visit: https://www.oauth.com/oauth2-servers/the-resource-server/
"""
# pylint: disable=too-many-instance-attributes
def __init__(self, authorization_server_client):
# pylint: disable=invalid-name
self.UserModel = auth.get_user_model()
self._client_id = settings.OIDC_RS_CLIENT_ID
self._client_secret = settings.OIDC_RS_CLIENT_SECRET
self._encryption_encoding = settings.OIDC_RS_ENCRYPTION_ENCODING
self._encryption_algorithm = settings.OIDC_RS_ENCRYPTION_ALGO
self._signing_algorithm = settings.OIDC_RS_SIGNING_ALGO
self._scopes = settings.OIDC_RS_SCOPES
if (
not self._client_id
or not self._client_secret
or not authorization_server_client
):
raise ImproperlyConfigured(
"Could not instantiate ResourceServerBackend, some parameters are missing."
)
self._authorization_server_client = authorization_server_client
self._claims_registry = jose_jwt.JWTClaimsRegistry(
iss={"essential": True, "value": self._authorization_server_client.url},
aud={"essential": True, "value": self._client_id},
token_introspection={"essential": True},
)
# pylint: disable=unused-argument
def get_or_create_user(self, access_token, id_token, payload):
"""Maintain API compatibility with OIDCAuthentication class from mozilla-django-oidc
Params 'id_token', 'payload' won't be used, and our implementation will only
support 'get_user', not 'get_or_create_user'.
"""
return self.get_user(access_token)
def get_user(self, access_token):
"""Get user from an access token emitted by the authorization server.
This method will submit the access token to the authorization server for
introspection, to ensure its validity and obtain the associated metadata.
It follows the specifications outlined in RFC7662 https://www.rfc-editor.org/info/rfc7662,
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12.
In our eGovernment applications, the standard RFC 7662 doesn't provide sufficient security.
Its introspection response is a plain JSON object. Therefore, we use the draft RFC
that extends RFC 7662 by returning a signed and encrypted JWT for stronger assurance that
the authorization server issued the token introspection response.
"""
jwt = self._introspect(access_token)
claims = self._verify_claims(jwt)
user_info = self._verify_user_info(claims["token_introspection"])
sub = user_info.get("sub")
if sub is None:
message = "User info contained no recognizable user identification"
logger.debug(message)
raise SuspiciousOperation(message)
try:
user = self.UserModel.objects.get(sub=sub)
except self.UserModel.DoesNotExist:
logger.debug("Login failed: No user with %s found", sub)
return None
return user
def _verify_user_info(self, introspection_response):
"""Verify the 'introspection_response' to get valid and relevant user info.
The 'introspection_response' should be still active, and while authenticating
the resource owner should have requested relevant scope to access her data in
our resource server.
Scope should be configured to match between the AS and the RS. The AS will filter
all the scopes the resource owner requested to expose only the relevant ones to
our resource server.
"""
active = introspection_response.get("active", None)
if not active:
message = "Introspection response is not active."
logger.debug(message)
raise SuspiciousOperation(message)
requested_scopes = introspection_response.get("scope", None).split(" ")
if set(self._scopes).isdisjoint(set(requested_scopes)):
message = "Introspection response contains any required scopes."
logger.debug(message)
raise SuspiciousOperation(message)
return introspection_response
def _introspect(self, token):
"""Introspect an access token to the authorization server."""
try:
jwe = self._authorization_server_client.get_introspection(
self._client_id,
self._client_secret,
token,
)
except HTTPError as err:
message = "Could not fetch introspection"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
private_key = utils.import_private_key_from_settings()
jws = self._decrypt(jwe, private_key=private_key)
try:
public_key_set = self._authorization_server_client.import_public_keys()
except (TypeError, ValueError, AttributeError, HTTPError) as err:
message = "Could get authorization server JWKS"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
jwt = self._decode(jws, public_key_set)
return jwt
def _decrypt(self, encrypted_token, private_key):
"""Decrypt the token encrypted by the Authorization Server (AS).
Resource Server (RS)'s public key is used for encryption, and its private
key is used for decryption. The RS's public key is exposed to the AS via a JWKS endpoint.
Encryption Algorithm and Encoding should be configured to match between the AS
and the RS.
"""
try:
decrypted_token = jose_jwe.decrypt_compact(
encrypted_token,
private_key,
algorithms=[self._encryption_algorithm, self._encryption_encoding],
)
except Exception as err:
message = "Token decryption failed"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
return decrypted_token
def _decode(self, encoded_token, public_key_set):
"""Decode the token signed by the Authorization Server (AS).
AS's private key is used for signing, and its public key is used for decoding.
The AS public key is exposed via a JWK endpoint.
Signing Algorithm should be configured to match between the AS and the RS.
"""
try:
token = jose_jwt.decode(
encoded_token.plaintext,
public_key_set,
algorithms=[self._signing_algorithm],
)
except ValueError as err:
message = "Token decoding failed"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
return token
def _verify_claims(self, token):
"""Verify the claims of the token to ensure authentication security.
By verifying these claims, we ensure that the token was issued by a
trusted authorization server and is intended for this specific
resource server. This prevents various types of attacks, such as
token substitution or misuse of tokens issued for different clients.
"""
try:
self._claims_registry.validate(token.claims)
except (InvalidClaimError, InvalidTokenError) as err:
message = "Failed to validate token's claims"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
return token.claims
class ResourceServerImproperlyConfiguredBackend:
"""Fallback backend for improperly configured Resource Servers."""
def get_or_create_user(self, access_token, id_token, payload):
"""Indicate that the Resource Server is improperly configured."""
raise AuthenticationFailed("Resource Server is improperly configured")
@@ -1,95 +0,0 @@
"""Resource Server Clients classes"""
from django.core.exceptions import ImproperlyConfigured
import requests
from joserfc.jwk import KeySet
class AuthorizationServerClient:
"""Client for interacting with an OAuth 2.0 authorization server.
An authorization server issues access tokens to client applications after authenticating
and obtaining authorization from the resource owner. It also provides endpoints for token
introspection and JSON Web Key Sets (JWKS) to validate and decode tokens.
This client facilitates communication with the authorization server, including:
- Fetching token introspection responses.
- Fetching JSON Web Key Sets (JWKS) for token validation.
- Setting appropriate headers for secure communication as recommended by RFC drafts.
"""
# ruff: noqa: PLR0913 PLR017
# pylint: disable=too-many-positional-arguments
# pylint: disable=too-many-arguments
def __init__(
self,
url,
url_jwks,
url_introspection,
verify_ssl,
timeout,
proxy,
):
if not url or not url_jwks or not url_introspection:
raise ImproperlyConfigured(
"Could not instantiate AuthorizationServerClient, some parameters are missing."
)
self.url = url
self._url_introspection = url_introspection
self._url_jwks = url_jwks
self._verify_ssl = verify_ssl
self._timeout = timeout
self._proxy = proxy
@property
def _introspection_headers(self):
"""Get HTTP header for the introspection request.
Notify the authorization server that we expect a signed and encrypted response
by setting the appropriate 'Accept' header.
This follows the recommendation from the draft RFC:
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12.
"""
return {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/token-introspection+jwt",
}
def get_introspection(self, client_id, client_secret, token):
"""Retrieve introspection response about a token."""
response = requests.post(
self._url_introspection,
data={
"client_id": client_id,
"client_secret": client_secret,
"token": token,
},
headers=self._introspection_headers,
verify=self._verify_ssl,
timeout=self._timeout,
proxies=self._proxy,
)
response.raise_for_status()
return response.text
def get_jwks(self):
"""Retrieve Authorization Server JWKS."""
response = requests.get(
self._url_jwks,
verify=self._verify_ssl,
timeout=self._timeout,
proxies=self._proxy,
)
response.raise_for_status()
return response.json()
def import_public_keys(self):
"""Retrieve and import Authorization Server JWKS."""
jwks = self.get_jwks()
public_keys = KeySet.import_key_set(jwks)
return public_keys
-9
View File
@@ -1,9 +0,0 @@
"""Resource Server URL Configuration"""
from django.urls import path
from .views import JWKSView
urlpatterns = [
path("jwks", JWKSView.as_view(), name="resource_server_jwks"),
]
-48
View File
@@ -1,48 +0,0 @@
"""Resource Server utils functions"""
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from joserfc.jwk import JWKRegistry
def import_private_key_from_settings():
"""Import the private key used by the resource server when interacting with the OIDC provider.
This private key is crucial; its public components are exposed in the JWK endpoints,
while its private component is used for decrypting the introspection token retrieved
from the OIDC provider.
By default, we recommend using RSAKey for asymmetric encryption,
known for its strong security features.
Note:
- The function requires the 'OIDC_RS_PRIVATE_KEY_STR' setting to be configured.
- The 'OIDC_RS_ENCRYPTION_KEY_TYPE' and 'OIDC_RS_ENCRYPTION_ALGO' settings can be customized
based on the chosen key type.
Raises:
ImproperlyConfigured: If the private key setting is missing, empty, or incorrect.
Returns:
joserfc.jwk.JWK: The imported private key as a JWK object.
"""
private_key_str = getattr(settings, "OIDC_RS_PRIVATE_KEY_STR", None)
if not private_key_str:
raise ImproperlyConfigured(
"OIDC_RS_PRIVATE_KEY_STR setting is missing or empty."
)
private_key_pem = private_key_str.encode()
try:
private_key = JWKRegistry.import_key(
private_key_pem,
key_type=settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
parameters={"alg": settings.OIDC_RS_ENCRYPTION_ALGO, "use": "enc"},
)
except ValueError as err:
raise ImproperlyConfigured("OIDC_RS_PRIVATE_KEY_STR setting is wrong.") from err
return private_key
-40
View File
@@ -1,40 +0,0 @@
"""Resource Server views"""
from django.core.exceptions import ImproperlyConfigured
from joserfc.jwk import KeySet
from rest_framework.response import Response
from rest_framework.views import APIView
from . import utils
class JWKSView(APIView):
"""
API endpoint for retrieving a JSON Web Keys Set (JWKS).
Returns:
Response: JSON response containing the JWKS data.
"""
authentication_classes = [] # disable authentication
permission_classes = [] # disable permission
def get(self, request):
"""Handle GET requests to retrieve JSON Web Keys Set (JWKS).
Returns:
Response: JSON response containing the JWKS data.
"""
try:
private_key = utils.import_private_key_from_settings()
except (ImproperlyConfigured, ValueError) as err:
return Response({"error": str(err)}, status=500)
try:
jwk = KeySet([private_key]).as_dict(private=False)
except (TypeError, ValueError, AttributeError):
return Response({"error": "Could not load key"}, status=500)
return Response(jwk)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,286 +0,0 @@
"""Unit tests for the Authentication Backends."""
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
import pytest
from core import factories, models
from core.authentication.backends import OIDCAuthenticationBackend
pytestmark = pytest.mark.django_db
User = get_user_model()
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()
user = factories.UserFactory()
def get_userinfo_mocked(*args):
return {"sub": user.sub}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(1):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_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()
user = factories.UserFactory(name="John Doe")
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": user.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):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_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 user when they change.
"""
klass = OIDCAuthenticationBackend()
user = factories.UserFactory(name="John Doe", email="john.doe@example.com")
def get_userinfo_mocked(*args):
return {
"sub": user.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):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.name == f"{first_name:s} {last_name:s}"
def test_authentication_getter_existing_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user doesn't match the sub but matches the email,
the user should be returned.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory()
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(2):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_existing_user_no_fallback_to_email(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should not match users by email, even if the email matches.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory()
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub doesn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
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/name, created user's email/name 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
)
assert user.sub == "123"
assert user.email is None
assert user.name 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 user.
"""
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
)
assert user.sub == "123"
assert user.email == email
assert user.name == "John Doe"
assert user.password == "!"
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
def test_authentication_getter_existing_disabled_user_via_sub(
django_assert_num_queries, monkeypatch
):
"""
If an existing user matches the sub but is disabled,
an error should be raised and a user should not be created.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory(name="John Doe", is_active=False)
def get_userinfo_mocked(*args):
return {
"sub": db_user.sub,
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(1),
pytest.raises(SuspiciousOperation, match="User account is disabled"),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
def test_authentication_getter_existing_disabled_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user does not matches the sub but match the email and is disabled,
an error should be raised and a user should not be created.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory(name="John Doe", is_active=False)
def get_userinfo_mocked(*args):
return {
"sub": "random",
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(2),
pytest.raises(SuspiciousOperation, match="User account is disabled"),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
@@ -1,10 +0,0 @@
"""Unit tests for the Authentication URLs."""
from core.authentication.urls import urlpatterns
def test_urls_override_default_mozilla_django_oidc():
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
url_names = [u.name for u in urlpatterns]
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
@@ -1,231 +0,0 @@
"""Unit tests for the Authentication Views."""
from unittest import mock
from urllib.parse import parse_qs, urlparse
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.exceptions import SuspiciousOperation
from django.test import RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import crypto
import pytest
from rest_framework.test import APIClient
from core import factories
from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView
pytestmark = pytest.mark.django_db
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_anonymous():
"""Anonymous users calling the logout url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_custom")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
)
def test_view_logout(mocked_oidc_logout_url):
"""Authenticated users should be redirected to OIDC provider for logout."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
)
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
"""Authenticated users should be logged out when no OIDC provider is available."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/default-redirect-logout"
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback_anonymous():
"""Anonymous users calling the logout callback url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_callback")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@pytest.mark.parametrize(
"initial_oidc_states",
[{}, {"other_state": "foo"}],
)
def test_view_logout_persist_state(initial_oidc_states):
"""State value should be persisted in session's data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_oidc_states:
request.session["oidc_states"] = initial_oidc_states
request.session.save()
mocked_state = "mock_state"
OIDCLogoutView().persist_state(request, mocked_state)
assert "oidc_states" in request.session
assert request.session["oidc_states"] == {
"mock_state": {},
**initial_oidc_states,
}
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
@mock.patch.object(OIDCLogoutView, "persist_state")
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
def test_view_logout_construct_oidc_logout_url(
mocked_get_random_string, mocked_persist_state
):
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
request.session["oidc_id_token"] = "mocked_oidc_id_token"
request.session.save()
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
mocked_persist_state.assert_called_once()
mocked_get_random_string.assert_called_once()
params = parse_qs(urlparse(redirect_url).query)
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
assert params["state"][0] == "mocked_state"
url = reverse("oidc_logout_callback")
assert url in params["post_logout_redirect_uri"][0]
@override_settings(LOGOUT_REDIRECT_URL="/")
def test_view_logout_construct_oidc_logout_url_none_id_token():
"""If no ID token is available in the session,
the user should be redirected to the final URL."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
assert redirect_url == "/"
@pytest.mark.parametrize(
"initial_state",
[None, {"other_state": "foo"}],
)
def test_view_logout_callback_wrong_state(initial_state):
"""Should raise an error if OIDC state doesn't match session data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_state:
request.session["oidc_states"] = initial_state
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with pytest.raises(SuspiciousOperation) as excinfo:
callback_view(request)
assert (
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
)
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback():
"""If state matches, callback should clear OIDC state and redirects."""
user = factories.UserFactory()
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
mocked_state = "mocked_state"
request.session["oidc_states"] = {mocked_state: {}}
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
def clear_user(request):
# Assert state is cleared prior to logout
assert request.session["oidc_states"] == {}
request.user = AnonymousUser()
mock_logout.side_effect = clear_user
response = callback_view(request)
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"
@@ -1,447 +0,0 @@
"""
Test for the Resource Server (RS) Backend.
"""
# pylint: disable=W0212
from logging import Logger
from unittest.mock import Mock, patch
from django.contrib import auth
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
import pytest
from joserfc.errors import InvalidClaimError, InvalidTokenError
from joserfc.jwt import JWTClaimsRegistry
from requests.exceptions import HTTPError
from core.resource_server.backend import ResourceServerBackend
@pytest.fixture(name="mock_authorization_server")
def fixture_mock_authorization_server():
"""Mock an Authorization Server client."""
mock_server = Mock()
mock_server.url = "https://auth.server.com"
return mock_server
@pytest.fixture(name="mock_token")
def fixture_mock_token():
"""Mock a token"""
mock_token = Mock()
mock_token.claims = {"sub": "user123", "iss": "https://auth.server.com"}
return mock_token
@pytest.fixture(name="resource_server_backend")
def fixture_resource_server_backend(settings, mock_authorization_server):
"""Generate a Resource Server backend."""
settings.OIDC_RS_CLIENT_ID = "client_id"
settings.OIDC_RS_CLIENT_SECRET = "client_secret"
settings.OIDC_RS_ENCRYPTION_ENCODING = "A256GCM"
settings.OIDC_RS_ENCRYPTION_ALGO = "RSA-OAEP"
settings.OIDC_RS_SIGNING_ALGO = "ES256"
settings.OIDC_RS_SCOPES = ["groups"]
return ResourceServerBackend(mock_authorization_server)
@override_settings(OIDC_RS_CLIENT_ID="client_id")
@override_settings(OIDC_RS_CLIENT_SECRET="client_secret")
@override_settings(OIDC_RS_ENCRYPTION_ENCODING="A256GCM")
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RSA-OAEP")
@override_settings(OIDC_RS_SIGNING_ALGO="RS256")
@override_settings(OIDC_RS_SCOPES=["scopes"])
@patch.object(auth, "get_user_model", return_value="foo")
def test_backend_initialization(mock_get_user_model, mock_authorization_server):
"""Test the ResourceServerBackend initialization."""
backend = ResourceServerBackend(mock_authorization_server)
mock_get_user_model.assert_called_once()
assert backend.UserModel == "foo"
assert backend._client_id == "client_id"
assert backend._client_secret == "client_secret"
assert backend._encryption_encoding == "A256GCM"
assert backend._encryption_algorithm == "RSA-OAEP"
assert backend._signing_algorithm == "RS256"
assert backend._scopes == ["scopes"]
assert backend._authorization_server_client == mock_authorization_server
assert isinstance(backend._claims_registry, JWTClaimsRegistry)
assert backend._claims_registry.options == {
"iss": {"essential": True, "value": "https://auth.server.com"},
"aud": {"essential": True, "value": "client_id"},
"token_introspection": {"essential": True},
}
@patch.object(ResourceServerBackend, "get_user", return_value="user")
def test_get_or_create_user(mock_get_user, resource_server_backend):
"""Test 'get_or_create_user' method."""
access_token = "access_token"
res = resource_server_backend.get_or_create_user(access_token, None, None)
mock_get_user.assert_called_once_with(access_token)
assert res == "user"
def test_verify_claims_success(resource_server_backend, mock_token):
"""Test '_verify_claims' method with a successful response."""
with patch.object(
resource_server_backend._claims_registry, "validate"
) as mock_validate:
resource_server_backend._verify_claims(mock_token)
mock_validate.assert_called_once_with(mock_token.claims)
def test_verify_claims_invalid_claim_error(resource_server_backend, mock_token):
"""Test '_verify_claims' method with an invalid claim error."""
with patch.object(
resource_server_backend._claims_registry, "validate"
) as mock_validate:
mock_validate.side_effect = InvalidClaimError("claim_name")
expected_message = "Failed to validate token's claims"
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._verify_claims(mock_token)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
def test_verify_claims_invalid_token_error(resource_server_backend, mock_token):
"""Test '_verify_claims' method with an invalid token error."""
with patch.object(
resource_server_backend._claims_registry, "validate"
) as mock_validate:
mock_validate.side_effect = InvalidTokenError
expected_message = "Failed to validate token's claims"
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._verify_claims(mock_token)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
def test_decode_success(resource_server_backend):
"""Test '_decode' method with a successful response."""
encoded_token = Mock()
encoded_token.plaintext = "valid_encoded_token"
public_key_set = Mock()
expected_decoded_token = {"sub": "user123"}
with patch(
"joserfc.jwt.decode", return_value=expected_decoded_token
) as mock_decode:
decoded_token = resource_server_backend._decode(encoded_token, public_key_set)
mock_decode.assert_called_once_with(
"valid_encoded_token", public_key_set, algorithms=["ES256"]
)
assert decoded_token == expected_decoded_token
def test_decode_failure(resource_server_backend):
"""Test '_decode' method with a ValueError"""
encoded_token = Mock()
encoded_token.plaintext = "invalid_encoded_token"
public_key_set = Mock()
with patch("joserfc.jwt.decode", side_effect=ValueError):
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match="Token decoding failed"):
resource_server_backend._decode(encoded_token, public_key_set)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", "Token decoding failed", exc_info=True
)
def test_decrypt_success(resource_server_backend):
"""Test '_decrypt' method with a successful response."""
encrypted_token = "valid_encrypted_token"
private_key = "private_key"
expected_decrypted_token = {"sub": "user123"}
with patch(
"joserfc.jwe.decrypt_compact", return_value=expected_decrypted_token
) as mock_decrypt:
decrypted_token = resource_server_backend._decrypt(encrypted_token, private_key)
mock_decrypt.assert_called_once_with(
encrypted_token, private_key, algorithms=["RSA-OAEP", "A256GCM"]
)
assert decrypted_token == expected_decrypted_token
def test_decrypt_failure(resource_server_backend):
"""Test '_decrypt' method with an Exception."""
encrypted_token = "invalid_encrypted_token"
private_key = "private_key"
with patch(
"joserfc.jwe.decrypt_compact", side_effect=Exception("Decryption error")
):
expected_message = "Token decryption failed"
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._decrypt(encrypted_token, private_key)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
@patch(
"core.resource_server.utils.import_private_key_from_settings",
return_value="private_key",
)
# pylint: disable=unused-argument
def test_introspect_success(
mock_import_private_key_from_settings, resource_server_backend
):
"""Test '_introspect' method with a successful response."""
token = "valid_token"
jwe = "valid_jwe"
jws = "valid_jws"
jwt = {"sub": "user123"}
resource_server_backend._authorization_server_client.get_introspection = Mock(
return_value=jwe
)
resource_server_backend._decrypt = Mock(return_value=jws)
resource_server_backend._authorization_server_client.import_public_keys = Mock(
return_value="public_key_set"
)
resource_server_backend._decode = Mock(return_value=jwt)
result = resource_server_backend._introspect(token)
assert result == jwt
resource_server_backend._authorization_server_client.get_introspection.assert_called_once_with(
"client_id", "client_secret", token
)
resource_server_backend._decrypt.assert_called_once_with(
jwe, private_key="private_key"
)
resource_server_backend._authorization_server_client.import_public_keys.assert_called_once()
resource_server_backend._decode.assert_called_once_with(jws, "public_key_set")
def test_introspect_introspection_failure(resource_server_backend):
"""Test '_introspect' method when introspection to the AS fails."""
token = "invalid_token"
resource_server_backend._authorization_server_client.get_introspection.side_effect = HTTPError(
"Introspection error"
)
with patch.object(Logger, "debug") as mock_logger_debug:
expected_message = "Could not fetch introspection"
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._introspect(token)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
@patch(
"core.resource_server.utils.import_private_key_from_settings",
return_value="private_key",
)
# pylint: disable=unused-argument
def test_introspect_public_key_import_failure(
mock_import_private_key_from_settings, resource_server_backend
):
"""Test '_introspect' method when fetching AS's jwks fails."""
token = "valid_token"
jwe = "valid_jwe"
jws = "valid_jws"
resource_server_backend._authorization_server_client.get_introspection = Mock(
return_value=jwe
)
resource_server_backend._decrypt = Mock(return_value=jws)
resource_server_backend._authorization_server_client.import_public_keys.side_effect = HTTPError(
"Public key error"
)
with patch.object(Logger, "debug") as mock_logger_debug:
expected_message = "Could get authorization server JWKS"
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._introspect(token)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
def test_verify_user_info_success(resource_server_backend):
"""Test '_verify_user_info' with a successful response."""
introspection_response = {"active": True, "scope": "groups"}
result = resource_server_backend._verify_user_info(introspection_response)
assert result == introspection_response
def test_verify_user_info_inactive(resource_server_backend):
"""Test '_verify_user_info' with an inactive introspection response."""
introspection_response = {"active": False, "scope": "groups"}
expected_message = "Introspection response is not active."
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._verify_user_info(introspection_response)
mock_logger_debug.assert_called_once_with(expected_message)
def test_verify_user_info_wrong_scopes(resource_server_backend):
"""Test '_verify_user_info' with wrong requested scopes."""
introspection_response = {"active": True, "scope": "wrong-scopes"}
expected_message = "Introspection response contains any required scopes."
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._verify_user_info(introspection_response)
mock_logger_debug.assert_called_once_with(expected_message)
def test_get_user_success(resource_server_backend):
"""Test '_get_user' with a successful response."""
access_token = "valid_access_token"
mock_jwt = Mock()
mock_claims = {"token_introspection": {"sub": "user123"}}
mock_user = Mock()
resource_server_backend._introspect = Mock(return_value=mock_jwt)
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
resource_server_backend._verify_user_info = Mock(
return_value=mock_claims["token_introspection"]
)
resource_server_backend.UserModel.objects.get = Mock(return_value=mock_user)
user = resource_server_backend.get_user(access_token)
assert user == mock_user
resource_server_backend._introspect.assert_called_once_with(access_token)
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
resource_server_backend._verify_user_info.assert_called_once_with(
mock_claims["token_introspection"]
)
resource_server_backend.UserModel.objects.get.assert_called_once_with(sub="user123")
def test_get_user_could_not_introspect(resource_server_backend):
"""Test '_get_user' with introspection failing."""
access_token = "valid_access_token"
resource_server_backend._introspect = Mock(
side_effect=SuspiciousOperation("Invalid jwt")
)
resource_server_backend._verify_claims = Mock()
resource_server_backend._verify_user_info = Mock()
with pytest.raises(SuspiciousOperation, match="Invalid jwt"):
resource_server_backend.get_user(access_token)
resource_server_backend._introspect.assert_called_once_with(access_token)
resource_server_backend._verify_claims.assert_not_called()
resource_server_backend._verify_user_info.assert_not_called()
def test_get_user_invalid_introspection_response(resource_server_backend):
"""Test '_get_user' with an invalid introspection response."""
access_token = "valid_access_token"
mock_jwt = Mock()
resource_server_backend._introspect = Mock(return_value=mock_jwt)
resource_server_backend._verify_claims = Mock(
side_effect=SuspiciousOperation("Invalid claims")
)
resource_server_backend._verify_user_info = Mock()
with pytest.raises(SuspiciousOperation, match="Invalid claims"):
resource_server_backend.get_user(access_token)
resource_server_backend._introspect.assert_called_once_with(access_token)
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
resource_server_backend._verify_user_info.assert_not_called()
def test_get_user_user_not_found(resource_server_backend):
"""Test '_get_user' if the user is not found."""
access_token = "valid_access_token"
mock_jwt = Mock()
mock_claims = {"token_introspection": {"sub": "user123"}}
resource_server_backend._introspect = Mock(return_value=mock_jwt)
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
resource_server_backend._verify_user_info = Mock(
return_value=mock_claims["token_introspection"]
)
resource_server_backend.UserModel.objects.get = Mock(
side_effect=resource_server_backend.UserModel.DoesNotExist
)
with patch.object(Logger, "debug") as mock_logger_debug:
user = resource_server_backend.get_user(access_token)
assert user is None
resource_server_backend._introspect.assert_called_once_with(access_token)
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
resource_server_backend._verify_user_info.assert_called_once_with(
mock_claims["token_introspection"]
)
resource_server_backend.UserModel.objects.get.assert_called_once_with(
sub="user123"
)
mock_logger_debug.assert_called_once_with(
"Login failed: No user with %s found", "user123"
)
def test_get_user_no_user_identification(resource_server_backend):
"""Test '_get_user' if the response miss a user identification."""
access_token = "valid_access_token"
mock_jwt = Mock()
mock_claims = {"token_introspection": {}}
resource_server_backend._introspect = Mock(return_value=mock_jwt)
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
resource_server_backend._verify_user_info = Mock(
return_value=mock_claims["token_introspection"]
)
expected_message = "User info contained no recognizable user identification"
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend.get_user(access_token)
mock_logger_debug.assert_called_once_with(expected_message)
@@ -1,187 +0,0 @@
"""
Test for the Resource Server (RS) clients classes.
"""
# pylint: disable=W0212
from unittest.mock import MagicMock, patch
import pytest
from joserfc.jwk import KeySet, RSAKey
from requests.exceptions import HTTPError
from core.resource_server.clients import AuthorizationServerClient
@pytest.fixture(name="client")
def fixture_client():
"""Generate an Authorization Server client."""
return AuthorizationServerClient(
url="https://auth.example.com/api/v2",
url_jwks="https://auth.example.com/api/v2/jwks",
url_introspection="https://auth.example.com/api/v2/introspect",
verify_ssl=True,
timeout=5,
proxy=None,
)
def test_authorization_server_client_initialization():
"""Test the AuthorizationServerClient initialization."""
new_client = AuthorizationServerClient(
url="https://auth.example.com/api/v2",
url_jwks="https://auth.example.com/api/v2/jwks",
url_introspection="https://auth.example.com/api/v2/checktoken/foo",
verify_ssl=True,
timeout=5,
proxy=None,
)
assert new_client.url == "https://auth.example.com/api/v2"
assert (
new_client._url_introspection
== "https://auth.example.com/api/v2/checktoken/foo"
)
assert new_client._url_jwks == "https://auth.example.com/api/v2/jwks"
assert new_client._verify_ssl is True
assert new_client._timeout == 5
assert new_client._proxy is None
def test_introspection_headers(client):
"""Test the introspection headers to ensure they match the expected values."""
assert client._introspection_headers == {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/token-introspection+jwt",
}
@patch("requests.post")
def test_get_introspection_success(mock_post, client):
"""Test 'get_introspection' method with a successful response."""
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.text = "introspection response"
mock_post.return_value = mock_response
result = client.get_introspection("client_id", "client_secret", "token")
assert result == "introspection response"
mock_post.assert_called_once_with(
"https://auth.example.com/api/v2/introspect",
data={
"client_id": "client_id",
"client_secret": "client_secret",
"token": "token",
},
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/token-introspection+jwt",
},
verify=True,
timeout=5,
proxies=None,
)
@patch("requests.post", side_effect=HTTPError())
# pylint: disable=(unused-argument
def test_get_introspection_error(mock_post, client):
"""Test 'get_introspection' method with an HTTPError."""
with pytest.raises(HTTPError):
client.get_introspection("client_id", "client_secret", "token")
@patch("requests.get")
def test_get_jwks_success(mock_get, client):
"""Test 'get_jwks' method with a successful response."""
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"jwks": "foo"}
mock_get.return_value = mock_response
result = client.get_jwks()
assert result == {"jwks": "foo"}
mock_get.assert_called_once_with(
"https://auth.example.com/api/v2/jwks",
verify=client._verify_ssl,
timeout=client._timeout,
proxies=client._proxy,
)
@patch("requests.get")
def test_get_jwks_error(mock_get, client):
"""Test 'get_jwks' method with an HTTPError."""
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = HTTPError(
response=MagicMock(status=500)
)
mock_get.return_value = mock_response
with pytest.raises(HTTPError):
client.get_jwks()
@patch("requests.get")
def test_import_public_keys_valid(mock_get, client):
"""Test 'import_public_keys' method with a successful response."""
mocked_key = RSAKey.generate_key(2048)
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"keys": [mocked_key.as_dict()]}
mock_get.return_value = mock_response
response = client.import_public_keys()
assert isinstance(response, KeySet)
assert response.as_dict() == KeySet([mocked_key]).as_dict()
@patch("requests.get")
def test_import_public_keys_http_error(mock_get, client):
"""Test 'import_public_keys' method with an HTTPError."""
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = HTTPError(
response=MagicMock(status=500)
)
mock_get.return_value = mock_response
with pytest.raises(HTTPError):
client.import_public_keys()
@patch("requests.get")
def test_import_public_keys_empty_jwks(mock_get, client):
"""Test 'import_public_keys' method with empty keys response."""
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"keys": []}
mock_get.return_value = mock_response
response = client.import_public_keys()
assert isinstance(response, KeySet)
assert response.as_dict() == {"keys": []}
@patch("requests.get")
def test_import_public_keys_invalid_jwks(mock_get, client):
"""Test 'import_public_keys' method with invalid keys response."""
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"keys": [{"foo": "foo"}]}
mock_get.return_value = mock_response
with pytest.raises(ValueError):
client.import_public_keys()
@@ -1,88 +0,0 @@
"""
Test for the Resource Server (RS) utils functions.
"""
from django.core.exceptions import ImproperlyConfigured
from django.test.utils import override_settings
import pytest
from joserfc.jwk import ECKey, RSAKey
from core.resource_server.utils import import_private_key_from_settings
PRIVATE_KEY_STR_MOCKED = """-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3boG1kwEGUYL+
U58RPrVToIsF9jHB64S6WJIIInPmAclBciXFb6BWG11mbRIgo8ha3WVnC/tGHbXb
ndiKdrH2vKHOsDhV9AmgHgNgWaUK9L0uuKEb/xMLePYWsYlgzcQJx8RZY7RQyWqE
20WfzFxeuCE7QMb6VXSOgwQMnJsKocguIh3VCI9RIBq3B1kdgW35AD63YKOygmGx
qjcWwbjhKLvkF7LpBdlyAEzOKqg4T5uCcHMfksMW2+foTJx70RrZM/KHU+Zysuw7
uhhVsgPBG+CsqBSjHQhs7jzymqxtQAfe1FkrCRxOq5Pv2Efr7kgtVSkJJiX3KutM
vnWuEypxAgMBAAECggEAGqKS9pbrN+vnmb7yMsqYgVVnQn0aggZNHlLkl4ZLLnuV
aemlhur7zO0JzajqUC+AFQOfaQxiFu8S/FoJ+qccFdATrcPEVmTKbgPVqSyzLKlX
fByGll5eOVT95NMwN8yBGgt2HSW/ZditXS/KxxahVgamGqjAC9MTSutGz/8Ae1U+
DNDBJCc6RAqu3T02tV9A2pSpVC1rSktDMpLUTscnsfxpaEQATd9DJUcHEvIwoX8q
GJpycPEhNhdPXqpln5SoMHcf/zS5ssF/Mce0lJJXYyE0LnEk9X12jMWyBqmLqXUY
cKLyynaFbis0DpQppwKx2y8GpL76k+Ci4dOHIvFknQKBgQDj/2WRMcWOvfBrggzj
FHpcme2gSo5A5c0CVyI+Xkf1Zab6UR6T7GiImEoj9tq0+o2WEix9rwoypgMBq8rz
/rrJAPSZjgv6z71k4EnO2FIB5R03vQmoBRCN8VlgvLM0xv52zyjV4Wx66Q4MDjyH
EgkpHyB0FzRZh0UzhnE/pYSetQKBgQDN9eLB1nA4CBSr1vMGNfQyfBQl3vpO9EP4
VSS3KnUqCIjJeLu682Ylu7SFxcJAfzUpy5S43hEvcuJsagsVKfmCAGcYZs9/xq3I
vzYyhaEOS5ezNxLSh4+yCNBPlmrmDyoazag0t8H8YQFBN6BVcxbATHqdWGUhIhYN
eEpEMOh2TQKBgGBr7kRNTENlyHtu8IxIaMcowfn8DdUcWmsW9oBx1vTNHKTYEZp1
bG/4F8LF7xCCtcY1wWMV17Y7xyG5yYcOv2eqY8dc72wO1wYGZLB5g5URlB2ycJcC
LVIaM7ZZl2BGl+8fBSIOx5XjYfFvQ+HLmtwtMchm19jVAEseHF7SXRfRAoGAK15j
aT2mU6Yf9C9G7T/fM+I8u9zACHAW/+ut14PxN/CkHQh3P16RW9CyqpiB1uLyZuKf
Zm4cYElotDuAKey0xVMgYlsDxnwni+X3m5vX1hLE1s/5/qrc7zg75QZfbCI1U3+K
s88d4e7rPLhh4pxhZgy0pP1ADkIHMr7ppIJH8OECgYEApNfbgsJVPAMzucUhJoJZ
OmZHbyCtJvs4b+zxnmhmSbopifNCgS4zjXH9qC7tsUph1WE6L2KXvtApHGD5H4GQ
IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
MbyqKyC6DAzv4jEEhHaN7oY=
-----END PRIVATE KEY-----
"""
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
@pytest.mark.parametrize("mocked_private_key", [None, ""])
def test_import_private_key_from_settings_missing_or_empty_key(
settings, mocked_private_key
):
"""Should raise an exception if the settings 'OIDC_RS_PRIVATE_KEY_STR' is missing or empty."""
settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key
with pytest.raises(
ImproperlyConfigured,
match="OIDC_RS_PRIVATE_KEY_STR setting is missing or empty.",
):
import_private_key_from_settings()
@pytest.mark.parametrize("mocked_private_key", ["123", "foo", "invalid_key"])
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="RSA")
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RS256")
def test_import_private_key_from_settings_incorrect_key(settings, mocked_private_key):
"""Should raise an exception if the setting 'OIDC_RS_PRIVATE_KEY_STR' has an incorrect value."""
settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key
with pytest.raises(
ImproperlyConfigured, match="OIDC_RS_PRIVATE_KEY_STR setting is wrong."
):
import_private_key_from_settings()
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="RSA")
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RS256")
def test_import_private_key_from_settings_success_rsa_key():
"""Should import private key string as an RSA key."""
private_key = import_private_key_from_settings()
assert isinstance(private_key, RSAKey)
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="EC")
@override_settings(OIDC_RS_ENCRYPTION_ALGO="ES256")
def test_import_private_key_from_settings_success_ec_key():
"""Should import private key string as an EC key."""
private_key = import_private_key_from_settings()
assert isinstance(private_key, ECKey)
@@ -1,70 +0,0 @@
"""
Tests for the Resource Server (RS) Views.
"""
from unittest import mock
from django.core.exceptions import ImproperlyConfigured
from django.urls import reverse
import pytest
from joserfc.jwk import RSAKey
from rest_framework.test import APIClient
pytestmark = pytest.mark.django_db
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
def test_view_jwks_valid_public_key(mock_import_private_key_from_settings):
"""JWKs endpoint should return a set of valid Json Web Key"""
mocked_key = RSAKey.generate_key(2048)
mock_import_private_key_from_settings.return_value = mocked_key
url = reverse("resource_server_jwks")
response = APIClient().get(url)
mock_import_private_key_from_settings.assert_called_once()
assert response.status_code == 200
assert response["Content-Type"] == "application/json"
jwks = response.json()
assert jwks == {"keys": [mocked_key.as_dict(private=False)]}
# Security checks to make sure no details from the private key are exposed
private_details = ["d", "p", "q", "dp", "dq", "qi", "oth", "r", "t"]
assert all(
private_detail not in jwks["keys"][0].keys()
for private_detail in private_details
)
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
def test_view_jwks_invalid_private_key(mock_import_private_key_from_settings):
"""JWKS endpoint should return a proper exception when loading keys fails."""
mock_import_private_key_from_settings.return_value = "wrong_key"
url = reverse("resource_server_jwks")
response = APIClient().get(url)
mock_import_private_key_from_settings.assert_called_once()
assert response.status_code == 500
assert response.json() == {"error": "Could not load key"}
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
def test_view_jwks_missing_private_key(mock_import_private_key_from_settings):
"""JWKS endpoint should return a proper exception when private key is missing."""
mock_import_private_key_from_settings.side_effect = ImproperlyConfigured("foo.")
url = reverse("resource_server_jwks")
response = APIClient().get(url)
mock_import_private_key_from_settings.assert_called_once()
assert response.status_code == 500
assert response.json() == {"error": "foo."}
@@ -33,7 +33,7 @@ def test_openapi_client_schema():
)
assert output.getvalue() == ""
response = Client().get("/api/v1.0/swagger.json")
response = Client().get("/v1.0/swagger.json")
assert response.status_code == 200
with open(
@@ -2,12 +2,9 @@
Test for team accesses API endpoints in People's core app : create
"""
import json
import random
import re
import pytest
import responses
from rest_framework.test import APIClient
from core import factories, models
@@ -41,7 +38,9 @@ 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.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
other_user = factories.UserFactory()
team = factories.TeamFactory()
@@ -64,7 +63,9 @@ def test_api_team_accesses_create_authenticated_unrelated():
def test_api_team_accesses_create_authenticated_member():
"""Members of a team should not be allowed to create team accesses."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "member")])
other_user = factories.UserFactory()
@@ -92,7 +93,9 @@ def test_api_team_accesses_create_authenticated_administrator():
"""
Administrators of a team should be able to create team accesses except for the "owner" role.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
@@ -143,7 +146,9 @@ def test_api_team_accesses_create_authenticated_owner():
"""
Owners of a team should be able to create team accesses whatever the role.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
other_user = factories.UserFactory()
@@ -169,60 +174,3 @@ def test_api_team_accesses_create_authenticated_owner():
"role": role,
"user": str(other_user.id),
}
def test_api_team_accesses_create_webhook():
"""
When the team has a webhook, creating a team access should fire a call.
"""
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "owner")])
webhook = factories.TeamWebhookFactory(team=team)
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert rsp.call_count == 1
assert rsps.calls[0].request.url == webhook.url
# Payload sent to scim provider
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(other_user.id),
"email": other_user.email,
"type": "User",
}
],
}
],
}
@@ -2,12 +2,9 @@
Test for team accesses API endpoints in People's core app : delete
"""
import json
import random
import re
import pytest
import responses
from rest_framework.test import APIClient
from core import factories, models
@@ -32,7 +29,9 @@ 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.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
access = factories.TeamAccessFactory()
client = APIClient()
@@ -50,7 +49,9 @@ 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.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
@@ -72,7 +73,9 @@ 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.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
@@ -96,7 +99,9 @@ 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.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
@@ -120,7 +125,9 @@ 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.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
@@ -141,75 +148,18 @@ def test_api_team_accesses_delete_owners_last_owner():
"""
It should not be possible to delete the last owner access from a team
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
assert models.TeamAccess.objects.count() == 1
assert models.TeamAccess.objects.count() == 1
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_webhook():
"""
When the team has a webhook, deleting a team access should fire a call.
"""
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "administrator")])
webhook = factories.TeamWebhookFactory(team=team)
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(user)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert rsp.call_count == 1
assert rsps.calls[0].request.url == webhook.url
# Payload sent to scim provider
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "remove",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": access.user.email,
"type": "User",
}
],
}
],
}
assert models.TeamAccess.objects.count() == 1
assert models.TeamAccess.objects.filter(user=access.user).exists() is False
@@ -28,7 +28,9 @@ 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.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(3, team=team)
@@ -55,12 +57,19 @@ def test_api_team_accesses_list_authenticated_related():
Authenticated users should be able to list team accesses for a team
to which they are related, with a given role.
"""
user, administrator, owner = factories.UserFactory.create_batch(3)
identity = factories.IdentityFactory(is_main=True)
user = identity.user
team = factories.TeamFactory()
access1 = factories.TeamAccessFactory.create(team=team, user=owner, role="owner")
owner = factories.IdentityFactory(is_main=True)
access1 = factories.TeamAccessFactory.create(
team=team, user=owner.user, role="owner"
)
administrator = factories.IdentityFactory(is_main=True)
access2 = factories.TeamAccessFactory.create(
team=team, user=administrator, role="administrator"
team=team, user=administrator.user, role="administrator"
)
# Ensure this user's role is different from other team members to test abilities' computation
@@ -84,8 +93,8 @@ def test_api_team_accesses_list_authenticated_related():
"id": str(user_access.id),
"user": {
"id": str(user_access.user.id),
"email": str(user.email),
"name": str(user.name),
"email": str(identity.email),
"name": str(identity.name),
},
"role": str(user_access.role),
"abilities": user_access.get_abilities(user),
@@ -115,152 +124,46 @@ def test_api_team_accesses_list_authenticated_related():
)
def test_api_team_accesses_list_find_members(django_assert_num_queries):
def test_api_team_accesses_list_authenticated_main_identity():
"""
Authenticated users should be able to search users access with a case-insensitive and
partial query on the name.
Name and email should be returned from main identity only
"""
dave = factories.UserFactory(name="dave bowman", email=None)
frank = factories.UserFactory(name="frank poole", email=None)
mary = factories.UserFactory(name="mary poole", email=None)
nicole = factories.UserFactory(name="nicole foole", email=None)
factories.UserFactory(email="tester@ministry.fr", name="john doe")
factories.UserFactory(name="heywood floyd", email=None)
factories.UserFactory(name="Andrei Smyslov", email=None)
factories.TeamFactory.create_batch(10)
user = factories.UserFactory()
identity = factories.IdentityFactory(user=user, is_main=True)
factories.IdentityFactory(user=user) # additional non-main identity
# Add Mary, Nicole and Dave in the same team
team = factories.TeamFactory(
name="Odyssey",
users=[
(mary, models.RoleChoices.OWNER),
(nicole, models.RoleChoices.ADMIN),
(dave, models.RoleChoices.MEMBER),
],
)
factories.TeamFactory(users=[(frank, models.RoleChoices.MEMBER)])
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user) # random role
# Search users in the team Odyssey
client = APIClient()
client.force_login(mary)
# 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)
# 5 queries are needed here:
# - 1 query: select on user authenticated
# - 4 queries: get all users, owner included (2 more queries)
with django_assert_num_queries(5):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 3
# 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)
# We can find David Bowman
# 3 queries are needed here:
# - 1 query: user authenticated
# - 2 queries: search user query with match
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?q=bowman",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 1
dave_access = dave.accesses.get(team=team)
assert response.json()["results"][0]["id"] == str(dave_access.id)
# We can find Nicole
# 3 queries are needed here:
# - 1 query: user authenticated
# - 2 queries: search user query with match
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?q=nicol",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 1
nicole_access = nicole.accesses.get(team=team)
assert response.json()["results"][0]["id"] == str(nicole_access.id)
# We can find Nicole and Mary
# 5 queries are needed here:
# - 1 query: select on user authenticated
# - 4 queries: search user query with match and the owner found (2 more queries)
with django_assert_num_queries(5):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?q=ool",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 2
user_access_ids = sorted([user["id"] for user in response.json()["results"]])
mary_access = mary.accesses.get(team=team)
assert sorted([str(mary_access.id), str(nicole_access.id)]) == user_access_ids
# We cannot find Andrei, because he isn't a member of the current team
# 2 queries are needed here:
# - 1 query: select on user authenticated
# - 1 query: search user query with no match
with django_assert_num_queries(2):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?q=andrei",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 0
# We can find Mary
# 5 queries are needed here:
# - 1 query: select on user authenticated
# - 4 queries: search user query with match and an owner found (2 more queries)
with django_assert_num_queries(5):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?q=mary",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 1
assert response.json()["results"][0]["id"] == str(mary_access.id)
def test_api_team_accesses__list_find_members_by_email():
"""
Authenticated users should be able to search users access with a case-insensitive and
partial query on the email.
"""
user = factories.UserFactory(name=None, email="alicia@example.com")
# set all names to None to match only on emails
colleague1 = factories.UserFactory(name=None, email="prudence_crandall@edu.us")
colleague2 = factories.UserFactory(name=None, email="reinebrunehaut@gouv.fr")
colleague3 = factories.UserFactory(name=None, email="artemisia.gentileschi@arte.it")
# Add all colleague in the same team
team = factories.TeamFactory(
users=[
(user, models.RoleChoices.ADMIN),
(colleague1, models.RoleChoices.OWNER),
(colleague2, models.RoleChoices.ADMIN),
(colleague3, models.RoleChoices.MEMBER),
],
)
factories.TeamAccessFactory.create_batch(4)
# Create another team with similar email
factories.TeamFactory(
users=[
(
factories.UserFactory(name=None, email="bruneau@gouv.fr"),
models.RoleChoices.ADMIN,
),
],
)
# Search users in the team Odyssey
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?q=BRUNE",
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)),
]
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 1
assert response.json()["results"][0]["user"]["email"] == "reinebrunehaut@gouv.fr"
def test_api_team_accesses_list_authenticated_constant_numqueries(
@@ -270,28 +173,31 @@ def test_api_team_accesses_list_authenticated_constant_numqueries(
The number of queries should not depend on the amount of fetched accesses.
"""
user = factories.UserFactory()
factories.IdentityFactory(user=user, is_main=True)
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user) # random role
client = APIClient()
client.force_login(user)
# Only 3 queries are needed to efficiently fetch team accesses,
# 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(3):
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.UserFactory()
extra_user = factories.IdentityFactory(is_main=True).user
factories.TeamAccessFactory(team=team, user=extra_user)
# num queries should still be the same
with django_assert_num_queries(3):
# num queries should still be 4
with django_assert_num_queries(4):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
@@ -304,12 +210,14 @@ def test_api_team_accesses_list_authenticated_ordering():
"""Team accesses can be ordered by "role"."""
user = factories.UserFactory()
factories.IdentityFactory(user=user, is_main=True)
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user)
# create 20 new team members
for _ in range(20):
extra_user = factories.UserFactory()
extra_user = factories.IdentityFactory(is_main=True).user
factories.TeamAccessFactory(team=team, user=extra_user)
client = APIClient()
@@ -334,34 +242,44 @@ def test_api_team_accesses_list_authenticated_ordering():
assert sorted(results, reverse=True) == results
@pytest.mark.parametrize("ordering_field", ["email", "name"])
def test_api_team_accesses_list_authenticated_ordering_user(ordering_field):
"""Team accesses can be ordered by user's fields."""
@pytest.mark.parametrize("ordering_fields", ["name", "email"])
def test_api_team_accesses_list_authenticated_ordering_user(ordering_fields):
"""Team accesses can be ordered by user's fields "email" or "name"."""
user = factories.UserFactory()
factories.IdentityFactory(user=user, is_main=True)
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user)
# create 20 new team members
for _ in range(20):
extra_user = factories.UserFactory()
extra_user = factories.IdentityFactory(is_main=True).user
factories.TeamAccessFactory(team=team, user=extra_user)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=user__{ordering_field}",
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering={ordering_fields}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 21
def normalize(x):
"""Mimic Django order_by, which is case-insensitive and space-insensitive"""
return x.casefold().replace(" ", "")
results = [
team_access["user"][ordering_field]
team_access["user"][ordering_fields]
for team_access in response.json()["results"]
]
assert sorted(results, key=normalize) == results
assert sorted(results) == results
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=-{ordering_fields}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 21
results = [
team_access["user"][ordering_fields]
for team_access in response.json()["results"]
]
assert sorted(results, reverse=True) == results
@@ -31,7 +31,9 @@ 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.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
access = factories.TeamAccessFactory(team=factories.TeamFactory())
client = APIClient()
@@ -60,7 +62,9 @@ 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.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory(is_main=True)
user = identity.user
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user)
@@ -75,8 +79,8 @@ def test_api_team_accesses_retrieve_authenticated_related():
"id": str(access.id),
"user": {
"id": str(access.user.id),
"email": str(user.email),
"name": str(user.name),
"email": str(identity.email),
"name": str(identity.name),
},
"role": str(access.role),
"abilities": access.get_abilities(user),
@@ -43,7 +43,9 @@ 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.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
@@ -70,7 +72,9 @@ def test_api_team_accesses_update_authenticated_unrelated():
def test_api_team_accesses_update_authenticated_member():
"""Members of a team should not be allowed to update its accesses."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
old_values = serializers.TeamAccessSerializer(instance=access).data
@@ -101,7 +105,9 @@ 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.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team,
@@ -145,7 +151,9 @@ 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.
"""
user = factories.UserFactory()
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")
@@ -176,7 +184,9 @@ 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.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(
@@ -217,7 +227,9 @@ 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.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
factories.UserFactory()
access = factories.TeamAccessFactory(
@@ -263,7 +275,9 @@ 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.
"""
user = factories.UserFactory()
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
@@ -293,7 +307,9 @@ 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.
"""
user = factories.UserFactory()
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
@@ -10,7 +10,7 @@ from rest_framework.status import (
)
from rest_framework.test import APIClient
from core.factories import TeamFactory, UserFactory
from core.factories import IdentityFactory, TeamFactory
from core.models import Team
pytestmark = pytest.mark.django_db
@@ -34,10 +34,11 @@ def test_api_teams_create_authenticated():
Authenticated users should be able to create teams and should automatically be declared
as the owner of the newly created team.
"""
user = UserFactory()
identity = IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.post(
"/api/v1.0/teams/",
@@ -57,9 +58,9 @@ def test_api_teams_create_authenticated_slugify_name():
"""
Creating teams should automatically generate a slug.
"""
user = UserFactory()
identity = IdentityFactory()
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.post(
"/api/v1.0/teams/",
@@ -86,10 +87,10 @@ def test_api_teams_create_authenticated_expected_slug(param):
"""
Creating teams should automatically create unaccented, no unicode, lower-case slug.
"""
user = UserFactory()
identity = IdentityFactory()
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.post(
"/api/v1.0/teams/",
@@ -109,10 +110,10 @@ def test_api_teams_create_authenticated_unique_slugs():
Creating teams should raise an error if already existing slug.
"""
TeamFactory(name="existing team")
user = UserFactory()
identity = IdentityFactory()
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.post(
"/api/v1.0/teams/",
@@ -33,10 +33,10 @@ def test_api_teams_delete_authenticated_unrelated():
Authenticated users should not be allowed to delete a team to which they are not
related.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
team = factories.TeamFactory()
@@ -54,7 +54,8 @@ def test_api_teams_delete_authenticated_member():
Authenticated users should not be allowed to delete a team for which they are
only a member.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -77,7 +78,8 @@ def test_api_teams_delete_authenticated_administrator():
Authenticated users should not be allowed to delete a team for which they are
administrator.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -100,7 +102,8 @@ def test_api_teams_delete_authenticated_owner():
Authenticated users should be able to delete a team for which they are directly
owner.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -31,7 +31,8 @@ def test_api_teams_list_authenticated():
Authenticated users should be able to list teams
they are an owner/administrator/member of.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -58,7 +59,8 @@ def test_api_teams_list_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -103,7 +105,8 @@ def test_api_teams_list_pagination(
def test_api_teams_list_authenticated_distinct():
"""A team with several related users should only be listed once."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -126,7 +129,8 @@ def test_api_teams_order():
"""
Test that the endpoint GET teams is sorted in 'created_at' descending order by default.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -155,7 +159,8 @@ def test_api_teams_order_param():
Test that the 'created_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -3,7 +3,7 @@ Tests for Teams API endpoint in People's core app: retrieve
"""
import pytest
from rest_framework import status
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND
from rest_framework.test import APIClient
from core import factories
@@ -16,7 +16,7 @@ def test_api_teams_retrieve_anonymous():
team = factories.TeamFactory()
response = APIClient().get(f"/api/v1.0/teams/{team.id}/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@@ -27,17 +27,17 @@ def test_api_teams_retrieve_authenticated_unrelated():
Authenticated users should not be allowed to retrieve a team to which they are
not related.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
team = factories.TeamFactory()
response = client.get(
f"/api/v1.0/teams/{team.id!s}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
@@ -46,7 +46,8 @@ def test_api_teams_retrieve_authenticated_related():
Authenticated users should be allowed to retrieve a team to which they
are related whatever the role.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -59,7 +60,7 @@ def test_api_teams_retrieve_authenticated_related():
f"/api/v1.0/teams/{team.id!s}/",
)
assert response.status_code == status.HTTP_200_OK
assert response.status_code == HTTP_200_OK
assert sorted(response.json().pop("accesses")) == sorted(
[
str(access1.id),
@@ -45,10 +45,10 @@ def test_api_teams_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a team to which they are not related.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
team = factories.TeamFactory()
old_team_values = serializers.TeamSerializer(instance=team).data
@@ -73,7 +73,8 @@ def test_api_teams_update_authenticated_members():
Users who are members of a team but not administrators should
not be allowed to update it.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -100,7 +101,8 @@ def test_api_teams_update_authenticated_members():
def test_api_teams_update_authenticated_administrators():
"""Administrators of a team should be allowed to update it."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -132,7 +134,8 @@ def test_api_teams_update_authenticated_administrators():
def test_api_teams_update_authenticated_owners():
"""Administrators of a team should be allowed to update it,
apart from read-only fields."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -167,7 +170,8 @@ def test_api_teams_update_administrator_or_owner_of_another():
Being administrator or owner of a team should not grant authorization to update
another team.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -196,7 +200,8 @@ def test_api_teams_update_existing_slug_should_return_error():
Updating a team's name to an existing slug should return a bad request,
instead of creating a duplicate.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
-41
View File
@@ -1,41 +0,0 @@
"""
Test users API endpoints in the People core app.
"""
import pytest
from rest_framework.status import (
HTTP_200_OK,
)
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_config_anonymous():
"""Anonymous users should be allowed to get the configuration."""
client = APIClient()
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
"FEATURES": {"TEAMS": True},
"RELEASE": "NA",
}
def test_api_config_authenticated():
"""Authenticated users should be allowed to get the configuration."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
"FEATURES": {"TEAMS": True},
"RELEASE": "NA",
}
+44 -25
View File
@@ -69,14 +69,15 @@ def test_api_contacts_list_authenticated_no_query():
Authenticated users should be able to list contacts without applying a query.
Profile and base contacts should be excluded.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
contact = factories.ContactFactory(owner=user)
user.profile_contact = contact
user.save()
# Let's have 5 contacts in database:
assert user.profile_contact is not None # Excluded because profile contact
base_contact = factories.BaseContactFactory() # Excluded because overridden
base_contact = factories.BaseContactFactory() # Excluded because overriden
factories.ContactFactory(
base=base_contact
) # Excluded because belongs to other user
@@ -107,7 +108,8 @@ def test_api_contacts_list_authenticated_by_full_name():
Authenticated users should be able to search users with a case insensitive and
partial query on the full name.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
dave = factories.BaseContactFactory(full_name="David Bowman")
nicole = factories.BaseContactFactory(full_name="Nicole Foole")
@@ -148,7 +150,8 @@ def test_api_contacts_list_authenticated_by_full_name():
def test_api_contacts_list_authenticated_uppercase_content():
"""Upper case content should be found by lower case query."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
dave = factories.BaseContactFactory(full_name="EEE", short_name="AAA")
@@ -172,7 +175,8 @@ def test_api_contacts_list_authenticated_uppercase_content():
def test_api_contacts_list_authenticated_capital_query():
"""Upper case query should find lower case content."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
@@ -196,7 +200,8 @@ def test_api_contacts_list_authenticated_capital_query():
def test_api_contacts_list_authenticated_accented_content():
"""Accented content should be found by unaccented query."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
dave = factories.BaseContactFactory(full_name="ééé", short_name="ààà")
@@ -220,7 +225,8 @@ def test_api_contacts_list_authenticated_accented_content():
def test_api_contacts_list_authenticated_accented_query():
"""Accented query should find unaccented content."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
@@ -258,7 +264,9 @@ def test_api_contacts_retrieve_authenticated_owned():
"""
Authenticated users should be allowed to retrieve a contact they own.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
contact = factories.ContactFactory(owner=user)
client = APIClient()
@@ -281,11 +289,11 @@ def test_api_contacts_retrieve_authenticated_public():
"""
Authenticated users should be able to retrieve public contacts.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
contact = factories.BaseContactFactory()
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 200
@@ -303,11 +311,11 @@ def test_api_contacts_retrieve_authenticated_other():
"""
Authenticated users should not be allowed to retrieve another user's contacts.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
contact = factories.ContactFactory()
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 403
@@ -331,10 +339,10 @@ def test_api_contacts_create_anonymous_forbidden():
def test_api_contacts_create_authenticated_missing_base():
"""Anonymous users should be able to create users."""
user = factories.UserFactory(profile_contact=None)
identity = factories.IdentityFactory(user__profile_contact=None)
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.post(
"/api/v1.0/contacts/",
@@ -352,7 +360,8 @@ def test_api_contacts_create_authenticated_missing_base():
def test_api_contacts_create_authenticated_successful():
"""Authenticated users should be able to create contacts."""
user = factories.UserFactory(profile_contact=None)
identity = factories.IdentityFactory(user__profile_contact=None)
user = identity.user
base_contact = factories.BaseContactFactory()
client = APIClient()
@@ -395,10 +404,12 @@ def test_api_contacts_create_authenticated_successful():
@override_settings(ALLOW_API_USER_CREATE=True)
def test_api_contacts_create_authenticated_existing_override():
"""
Trying to create a contact for base contact that is already overridden by the user
Trying to create a contact for base contact that is already overriden by the user
should receive a 400 error.
"""
user = factories.UserFactory(profile_contact=None)
identity = factories.IdentityFactory(user__profile_contact=None)
user = identity.user
base_contact = factories.BaseContactFactory()
factories.ContactFactory(base=base_contact, owner=user)
@@ -452,7 +463,8 @@ def test_api_contacts_update_authenticated_owned():
"""
Authenticated users should be allowed to update their own contacts.
"""
user = factories.UserFactory(profile_contact=None)
identity = factories.IdentityFactory(user__profile_contact=None)
user = identity.user
client = APIClient()
client.force_login(user)
@@ -486,7 +498,8 @@ def test_api_contacts_update_authenticated_profile():
"""
Authenticated users should be allowed to update their profile contact.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -521,7 +534,8 @@ def test_api_contacts_update_authenticated_other():
"""
Authenticated users should not be allowed to update contacts owned by other users.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -559,7 +573,8 @@ def test_api_contacts_delete_list_anonymous():
def test_api_contacts_delete_list_authenticated():
"""Authenticated users should not be allowed to delete a list of contacts."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -587,7 +602,8 @@ def test_api_contacts_delete_authenticated_public():
"""
Authenticated users should not be allowed to delete a public contact.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -606,7 +622,8 @@ def test_api_contacts_delete_authenticated_owner():
"""
Authenticated users should be allowed to delete a contact they own.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
contact = factories.ContactFactory(owner=user)
client = APIClient()
@@ -625,7 +642,8 @@ def test_api_contacts_delete_authenticated_profile():
"""
Authenticated users should be allowed to delete their profile contact.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
contact = factories.ContactFactory(owner=user, base=None)
user.profile_contact = contact
user.save()
@@ -645,7 +663,8 @@ def test_api_contacts_delete_authenticated_other():
"""
Authenticated users should not be allowed to delete a contact they don't own.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
contact = factories.ContactFactory()
client = APIClient()
@@ -34,15 +34,15 @@ def test_api_team_invitations__create__anonymous():
def test_api_team_invitations__create__authenticated_outsider():
"""Users outside of team should not be permitted to invite to team."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
team = factories.TeamFactory()
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build()
).data
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
@@ -57,16 +57,16 @@ def test_api_team_invitations__create__authenticated_outsider():
)
def test_api_team_invitations__create__privileged_members(role):
"""Owners and administrators should be able to invite new members."""
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, role)])
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, role)])
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build()
).data
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
@@ -79,16 +79,16 @@ def test_api_team_invitations__create__members():
"""
Members should not be able to invite new members.
"""
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "member")])
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, "member")])
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build()
).data
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
@@ -102,16 +102,16 @@ def test_api_team_invitations__create__members():
def test_api_team_invitations__create__issuer_and_team_automatically_added():
"""Team and issuer fields should auto-complete."""
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "owner")])
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, "owner")])
# Generate a random invitation
invitation = factories.InvitationFactory.build()
invitation_data = {"email": invitation.email, "role": invitation.role}
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_data,
@@ -120,7 +120,7 @@ def test_api_team_invitations__create__issuer_and_team_automatically_added():
assert response.status_code == status.HTTP_201_CREATED
# team and issuer automatically set
assert response.json()["team"] == str(team.id)
assert response.json()["issuer"] == str(user.id)
assert response.json()["issuer"] == str(identity.user.id)
def test_api_team_invitations__create__cannot_duplicate_invitation():
@@ -129,8 +129,8 @@ def test_api_team_invitations__create__cannot_duplicate_invitation():
team = existing_invitation.team
# Grant privileged role on the Team to the user
user = factories.UserFactory()
factories.TeamAccessFactory(team=team, user=user, role="administrator")
identity = factories.IdentityFactory()
factories.TeamAccessFactory(team=team, user=identity.user, role="administrator")
# Create a new invitation to the same team with the exact same email address
duplicated_invitation = serializers.InvitationSerializer(
@@ -138,7 +138,7 @@ def test_api_team_invitations__create__cannot_duplicate_invitation():
).data
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
duplicated_invitation,
@@ -154,9 +154,11 @@ def test_api_team_invitations__create__cannot_invite_existing_users():
"""
Should not be able to invite already existing users.
"""
user, existing_user = factories.UserFactory.create_batch(2)
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "administrator")])
existing_user = factories.IdentityFactory(is_main=True)
# Build an invitation to the email of an exising identity in the db
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build(email=existing_user.email, team=team)
@@ -164,7 +166,6 @@ def test_api_team_invitations__create__cannot_invite_existing_users():
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
@@ -189,10 +190,14 @@ def test_api_team_invitations__list__authenticated():
Authenticated user should be able to list invitations
in teams they belong to, including from other issuers.
"""
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "administrator"), (other_user, "owner")])
identity = factories.IdentityFactory()
other_user = factories.UserFactory()
team = factories.TeamFactory(
users=[(identity.user, "administrator"), (other_user, "owner")]
)
invitation = factories.InvitationFactory(
team=team, role="administrator", issuer=user
team=team, role="administrator", issuer=identity.user
)
other_invitations = factories.InvitationFactory.create_batch(
2, team=team, role="member", issuer=other_user
@@ -203,7 +208,7 @@ def test_api_team_invitations__list__authenticated():
factories.InvitationFactory.create_batch(2, team=other_team, role="member")
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.get(
f"/api/v1.0/teams/{team.id}/invitations/",
)
@@ -230,22 +235,24 @@ def test_api_team_invitations__list__expired_invitations_still_listed(settings):
"""
Expired invitations are still listed.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
other_user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "administrator"), (other_user, "owner")])
team = factories.TeamFactory(
users=[(identity.user, "administrator"), (other_user, "owner")]
)
# override settings to accelerate validation expiration
settings.INVITATION_VALIDITY_DURATION = 1 # second
expired_invitation = factories.InvitationFactory(
team=team,
role="member",
issuer=user,
issuer=identity.user,
)
time.sleep(1)
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.get(
f"/api/v1.0/teams/{team.id}/invitations/",
)
@@ -286,12 +293,12 @@ def test_api_team_invitations__retrieve__unrelated_user():
"""
Authenticated unrelated users should not be able to retrieve invitations.
"""
user = factories.UserFactory()
user = factories.IdentityFactory(user=factories.UserFactory()).user
invitation = factories.InvitationFactory()
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
)
@@ -304,7 +311,8 @@ def test_api_team_invitations__retrieve__team_member():
Authenticated team members should be able to retrieve invitations
whatever their role in the team.
"""
user = factories.UserFactory()
user = factories.IdentityFactory(user=factories.UserFactory()).user
invitation = factories.InvitationFactory()
factories.TeamAccessFactory(team=invitation.team, user=user, role="member")
@@ -334,14 +342,13 @@ def test_api_team_invitations__update__forbidden(method):
"""
Update of invitations is currently forbidden.
"""
user = factories.UserFactory()
user = factories.IdentityFactory(user=factories.UserFactory()).user
invitation = factories.InvitationFactory()
factories.TeamAccessFactory(team=invitation.team, user=user, role="owner")
client = APIClient()
client.force_login(user)
response = None
if method == "put":
response = client.put(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
@@ -350,7 +357,6 @@ def test_api_team_invitations__update__forbidden(method):
response = client.patch(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
)
assert response is not None
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
assert response.json()["detail"] == f'Method "{method.upper()}" not allowed.'
@@ -367,12 +373,13 @@ def test_api_team_invitations__delete__anonymous():
def test_api_team_invitations__delete__authenticated_outsider():
"""Members outside of team should not cancel invitations."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
team = factories.TeamFactory()
invitation = factories.InvitationFactory(team=team)
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.delete(
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
@@ -382,12 +389,13 @@ def test_api_team_invitations__delete__authenticated_outsider():
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_team_invitations__delete__privileged_members(role):
"""Privileged member should be able to cancel invitation."""
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, role)])
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, role)])
invitation = factories.InvitationFactory(team=team)
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.delete(
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
@@ -396,12 +404,13 @@ def test_api_team_invitations__delete__privileged_members(role):
def test_api_team_invitations__delete__members():
"""Member should not be able to cancel invitation."""
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "member")])
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, "member")])
invitation = factories.InvitationFactory(team=team)
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.delete(
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
+226 -99
View File
@@ -35,10 +35,10 @@ def test_api_users_list_authenticated():
"""
Authenticated users should be able to list all users.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
factories.UserFactory.create_batch(2)
response = client.get(
@@ -53,15 +53,23 @@ def test_api_users_authenticated_list_by_email():
Authenticated users should be able to search users with a case-insensitive and
partial query on the email.
"""
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
dave = factories.UserFactory(email="david.bowman@work.com", name=None)
nicole = factories.UserFactory(email="nicole_foole@work.com", name=None)
frank = factories.UserFactory(email="frank_poole@work.com", name=None)
factories.UserFactory(email="heywood_floyd@work.com", name=None)
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="john doe")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(
email="david.bowman@work.com", name=None, is_main=True
)
nicole = factories.IdentityFactory(
email="nicole_foole@work.com", name=None, is_main=True
)
frank = factories.IdentityFactory(
email="frank_poole@work.com", name=None, is_main=True
)
factories.IdentityFactory(email="heywood_floyd@work.com", name=None, is_main=True)
# Full query should work
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
@@ -69,14 +77,14 @@ def test_api_users_authenticated_list_by_email():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids[0] == str(dave.id)
assert user_ids[0] == str(dave.user.id)
# Partial query should work
response = client.get("/api/v1.0/users/?q=fran")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids[0] == str(frank.id)
assert user_ids[0] == str(frank.user.id)
# Result that matches a trigram twice ranks better than result that matches once
response = client.get("/api/v1.0/users/?q=ole")
@@ -84,7 +92,7 @@ def test_api_users_authenticated_list_by_email():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
# "Nicole Foole" matches twice on "ole"
assert user_ids == [str(nicole.id), str(frank.id)]
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
# Even with a low similarity threshold, query should match expected emails
response = client.get("/api/v1.0/users/?q=ool")
@@ -92,22 +100,22 @@ def test_api_users_authenticated_list_by_email():
assert response.status_code == HTTP_200_OK
assert response.json()["results"] == [
{
"id": str(nicole.id),
"id": str(nicole.user.id),
"email": nicole.email,
"name": nicole.name,
"is_device": nicole.is_device,
"is_staff": nicole.is_staff,
"language": nicole.language,
"timezone": str(nicole.timezone),
"is_device": nicole.user.is_device,
"is_staff": nicole.user.is_staff,
"language": nicole.user.language,
"timezone": str(nicole.user.timezone),
},
{
"id": str(frank.id),
"id": str(frank.user.id),
"email": frank.email,
"name": frank.name,
"is_device": frank.is_device,
"is_staff": frank.is_staff,
"language": frank.language,
"timezone": str(frank.timezone),
"is_device": frank.user.is_device,
"is_staff": frank.user.is_staff,
"language": frank.user.language,
"timezone": str(frank.user.timezone),
},
]
@@ -117,15 +125,17 @@ def test_api_users_authenticated_list_by_name():
Authenticated users should be able to search users with a case-insensitive and
partial query on the name.
"""
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
dave = factories.UserFactory(name="dave bowman", email=None)
nicole = factories.UserFactory(name="nicole foole", email=None)
frank = factories.UserFactory(name="frank poole", email=None)
factories.UserFactory(name="heywood floyd", email=None)
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="john doe")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(name="dave bowman", email=None, is_main=True)
nicole = factories.IdentityFactory(name="nicole foole", email=None, is_main=True)
frank = factories.IdentityFactory(name="frank poole", email=None, is_main=True)
factories.IdentityFactory(name="heywood floyd", email=None, is_main=True)
# Full query should work
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
@@ -133,14 +143,14 @@ def test_api_users_authenticated_list_by_name():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids[0] == str(dave.id)
assert user_ids[0] == str(dave.user.id)
# Partial query should work
response = client.get("/api/v1.0/users/?q=fran")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids[0] == str(frank.id)
assert user_ids[0] == str(frank.user.id)
# Result that matches a trigram twice ranks better than result that matches once
response = client.get("/api/v1.0/users/?q=ole")
@@ -148,7 +158,7 @@ def test_api_users_authenticated_list_by_name():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
# "Nicole Foole" matches twice on "ole"
assert user_ids == [str(nicole.id), str(frank.id)]
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
# Even with a low similarity threshold, query should match expected user
response = client.get("/api/v1.0/users/?q=ool")
@@ -156,22 +166,22 @@ def test_api_users_authenticated_list_by_name():
assert response.status_code == HTTP_200_OK
assert response.json()["results"] == [
{
"id": str(nicole.id),
"id": str(nicole.user.id),
"email": nicole.email,
"name": nicole.name,
"is_device": nicole.is_device,
"is_staff": nicole.is_staff,
"language": nicole.language,
"timezone": str(nicole.timezone),
"is_device": nicole.user.is_device,
"is_staff": nicole.user.is_staff,
"language": nicole.user.language,
"timezone": str(nicole.user.timezone),
},
{
"id": str(frank.id),
"id": str(frank.user.id),
"email": frank.email,
"name": frank.name,
"is_device": frank.is_device,
"is_staff": frank.is_staff,
"language": frank.language,
"timezone": str(frank.timezone),
"is_device": frank.user.is_device,
"is_staff": frank.user.is_staff,
"language": frank.user.language,
"timezone": str(frank.user.timezone),
},
]
@@ -182,14 +192,20 @@ def test_api_users_authenticated_list_by_name_and_email():
partial query on the name and email.
"""
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
nicole = factories.UserFactory(email="nicole_foole@work.com", name="nicole foole")
frank = factories.UserFactory(email="oleg_poole@work.com", name=None)
david = factories.UserFactory(email=None, name="david role")
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="john doe")
client = APIClient()
client.force_login(user)
nicole = factories.IdentityFactory(
email="nicole_foole@work.com", name="nicole foole", is_main=True
)
frank = factories.IdentityFactory(
email="oleg_poole@work.com", name=None, is_main=True
)
david = factories.IdentityFactory(email=None, name="david role", is_main=True)
# Result that matches a trigram in name and email ranks better than result that matches once
response = client.get("/api/v1.0/users/?q=ole")
@@ -199,7 +215,7 @@ def test_api_users_authenticated_list_by_name_and_email():
# "Nicole Foole" matches twice on "ole" in her name and twice on "ole" in her email
# "Oleg poole" matches twice on "ole" in her email
# "David role" matches once on "ole" in his name
assert user_ids == [str(nicole.id), str(frank.id), str(david.id)]
assert user_ids == [str(nicole.user.id), str(frank.user.id), str(david.user.id)]
def test_api_users_authenticated_list_exclude_users_already_in_team(
@@ -209,26 +225,27 @@ def test_api_users_authenticated_list_exclude_users_already_in_team(
Authenticated users should be able to search users
but the result should exclude all users already in the given team.
"""
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
dave = factories.UserFactory(name="dave bowman", email=None)
nicole = factories.UserFactory(name="nicole foole", email=None)
frank = factories.UserFactory(name="frank poole", email=None)
mary = factories.UserFactory(name="mary poole", email=None)
factories.UserFactory(name="heywood floyd", email=None)
factories.UserFactory(name="Andrei Smyslov", email=None)
factories.TeamFactory.create_batch(10)
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="john doe")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(name="dave bowman", email=None, is_main=True)
nicole = factories.IdentityFactory(name="nicole foole", email=None, is_main=True)
frank = factories.IdentityFactory(name="frank poole", email=None, is_main=True)
mary = factories.IdentityFactory(name="mary poole", email=None, is_main=True)
factories.IdentityFactory(name="heywood floyd", email=None, is_main=True)
factories.IdentityFactory(name="Andrei Smyslov", email=None, is_main=True)
factories.TeamFactory.create_batch(10)
# Add Dave and Frank in the same team
team = factories.TeamFactory(
users=[
(dave, models.RoleChoices.MEMBER),
(frank, models.RoleChoices.MEMBER),
(dave.user, models.RoleChoices.MEMBER),
(frank.user, models.RoleChoices.MEMBER),
]
)
factories.TeamFactory(users=[(nicole, models.RoleChoices.MEMBER)])
factories.TeamFactory(users=[(nicole.user, models.RoleChoices.MEMBER)])
# Search user to add him/her to a team, we give a team id to the request
# to exclude all users already in the team
@@ -249,25 +266,118 @@ def test_api_users_authenticated_list_exclude_users_already_in_team(
# - user authenticated
# - search user query
# - User
with django_assert_num_queries(3):
# - Identity
with django_assert_num_queries(4):
response = client.get(
f"/api/v1.0/users/?q=ool&team_id={team.id}",
)
assert response.status_code == HTTP_200_OK
user_ids = sorted([user["id"] for user in response.json()["results"]])
assert user_ids == sorted([str(mary.id), str(nicole.id)])
assert user_ids == sorted([str(mary.user.id), str(nicole.user.id)])
def test_api_users_authenticated_list_multiple_identities_single_user():
"""
User with multiple identities should appear only once in results.
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="eva karl")
client = APIClient()
client.force_login(user)
dave = factories.UserFactory()
factories.IdentityFactory(
user=dave, email="dave.bowman@work.com", name="dave bowman"
)
factories.IdentityFactory(user=dave, email="dave.bowman@fun.fr", name="dave bowman")
# Full query should work
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == HTTP_200_OK
# A single user is returned, despite similarity matching both emails
assert response.json()["count"] == 1
assert response.json()["results"][0]["id"] == str(dave.id)
def test_api_users_authenticated_list_multiple_identities_multiple_users():
"""
User with multiple identities should be ranked
on their best matching identity.
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="john doe")
client = APIClient()
client.force_login(user)
dave = factories.UserFactory()
dave_identity = factories.IdentityFactory(
user=dave, email="dave.bowman@work.com", is_main=True, name="dave bowman"
)
factories.IdentityFactory(user=dave, email="babibou@ehehe.com", name="babihou")
davina_identity = factories.IdentityFactory(
user=factories.UserFactory(), email="davina.bowan@work.com", name="davina"
)
prue_identity = factories.IdentityFactory(
user=factories.UserFactory(),
email="prudence.crandall@work.com",
name="prudence",
)
# Full query should work
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 3
assert response.json()["results"] == [
{
"id": str(dave.id),
"email": dave_identity.email,
"name": dave_identity.name,
"is_device": dave_identity.user.is_device,
"is_staff": dave_identity.user.is_staff,
"language": dave_identity.user.language,
"timezone": str(dave_identity.user.timezone),
},
{
"id": str(davina_identity.user.id),
"email": davina_identity.email,
"name": davina_identity.name,
"is_device": davina_identity.user.is_device,
"is_staff": davina_identity.user.is_staff,
"language": davina_identity.user.language,
"timezone": str(davina_identity.user.timezone),
},
{
"id": str(prue_identity.user.id),
"email": prue_identity.email,
"name": prue_identity.name,
"is_device": prue_identity.user.is_device,
"is_staff": prue_identity.user.is_staff,
"language": prue_identity.user.language,
"timezone": str(prue_identity.user.timezone),
},
]
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", name="eva karl")
dave = factories.UserFactory(
email="DAVID.BOWMAN@INTENSEWORK.COM", name="DAVID BOWMAN"
)
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="eva karl")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(
email="DAVID.BOWMAN@INTENSEWORK.COM", name="DAVID BOWMAN"
)
# Unaccented full address
response = client.get(
"/api/v1.0/users/?q=david.bowman@intensework.com",
@@ -275,7 +385,7 @@ def test_api_users_authenticated_list_uppercase_content():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id)]
assert user_ids == [str(dave.user.id)]
# Partial query
response = client.get(
@@ -284,17 +394,19 @@ def test_api_users_authenticated_list_uppercase_content():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id)]
assert user_ids == [str(dave.user.id)]
def test_api_users_list_authenticated_capital_query():
"""Upper case query should find lower case content."""
user = factories.UserFactory(email="tester@ministry.fr", name="eva karl")
dave = factories.UserFactory(email="david.bowman@work.com", name="david bowman")
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="eva karl")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(email="david.bowman@work.com", name="david bowman")
# Full uppercase query
response = client.get(
"/api/v1.0/users/?q=DAVID.BOWMAN@WORK.COM",
@@ -302,7 +414,7 @@ def test_api_users_list_authenticated_capital_query():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id)]
assert user_ids == [str(dave.user.id)]
# Partial uppercase email
response = client.get(
@@ -311,17 +423,21 @@ def test_api_users_list_authenticated_capital_query():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id)]
assert user_ids == [str(dave.user.id)]
def test_api_contacts_list_authenticated_accented_query():
"""Accented content should be found by unaccented query."""
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
helene = factories.UserFactory(email="helene.bowman@work.com", name="helene bowman")
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="john doe")
client = APIClient()
client.force_login(user)
helene = factories.IdentityFactory(
email="helene.bowman@work.com", name="helene bowman"
)
# Accented full query
response = client.get(
"/api/v1.0/users/?q=hélène.bowman@work.com",
@@ -329,7 +445,7 @@ def test_api_contacts_list_authenticated_accented_query():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(helene.id)]
assert user_ids == [str(helene.user.id)]
# Unaccented partial email
response = client.get(
@@ -338,7 +454,7 @@ def test_api_contacts_list_authenticated_accented_query():
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(helene.id)]
assert user_ids == [str(helene.user.id)]
@mock.patch.object(Pagination, "get_page_size", return_value=3)
@@ -346,7 +462,8 @@ def test_api_users_list_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -386,7 +503,8 @@ def test_api_users_list_pagination_page_size(
page_size,
):
"""Page's size on pagination should work as expected."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -410,7 +528,8 @@ def test_api_users_list_pagination_wrong_page_size(
page_size,
):
"""Page's size on pagination should be limited to "max_page_size"."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -444,7 +563,8 @@ 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."""
user = factories.UserFactory()
identity = factories.IdentityFactory(is_main=True)
user = identity.user
client = APIClient()
client.force_login(user)
@@ -462,8 +582,8 @@ def test_api_users_retrieve_me_authenticated():
assert response.status_code == HTTP_200_OK
assert response.json() == {
"id": str(user.id),
"name": str(user.name),
"email": str(user.email),
"name": str(identity.name),
"email": str(identity.email),
"language": user.language,
"timezone": str(user.timezone),
"is_device": False,
@@ -488,7 +608,8 @@ def test_api_users_retrieve_authenticated_self():
Authenticated users should be allowed to retrieve their own user.
The returned object should not contain the password.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -505,10 +626,12 @@ def test_api_users_retrieve_authenticated_other():
Authenticated users should be able to retrieve another user's detail view with
limited information.
"""
user, other_user = factories.UserFactory.create_batch(2)
identity = factories.IdentityFactory()
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
other_user = factories.UserFactory()
response = client.get(
f"/api/v1.0/users/{other_user.id!s}/",
@@ -535,7 +658,8 @@ def test_api_users_create_anonymous():
def test_api_users_create_authenticated():
"""Authenticated users should not be able to create users via the API."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -582,7 +706,8 @@ def test_api_users_update_authenticated_self():
Authenticated users should be able to update their own user but only "language"
and "timezone" fields.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -610,10 +735,10 @@ def test_api_users_update_authenticated_self():
def test_api_users_update_authenticated_other():
"""Authenticated users should not be allowed to update other users."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
@@ -663,7 +788,8 @@ def test_api_users_patch_authenticated_self():
Authenticated users should be able to patch their own user but only "language"
and "timezone" fields.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -692,27 +818,27 @@ def test_api_users_patch_authenticated_self():
def test_api_users_patch_authenticated_other():
"""Authenticated users should not be allowed to patch other users."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
other_user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=other_user).data)
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
for key, new_value in new_user_values.items():
response = client.put(
f"/api/v1.0/users/{other_user.id!s}/",
f"/api/v1.0/users/{user.id!s}/",
{key: new_value},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
other_user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=other_user).data)
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
@@ -730,11 +856,11 @@ def test_api_users_delete_list_anonymous():
def test_api_users_delete_list_authenticated():
"""Authenticated users should not be allowed to delete a list of users."""
user = factories.UserFactory()
factories.UserFactory.create_batch(2)
identity = factories.IdentityFactory()
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.delete(
"/api/v1.0/users/",
@@ -758,10 +884,11 @@ def test_api_users_delete_authenticated():
"""
Authenticated users should not be allowed to delete a user other than themselves.
"""
user, other_user = factories.UserFactory.create_batch(2)
identity = factories.IdentityFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.delete(f"/api/v1.0/users/{other_user.id!s}/")
@@ -771,13 +898,13 @@ def test_api_users_delete_authenticated():
def test_api_users_delete_self():
"""Authenticated users should not be able to delete their own user."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
client = APIClient()
client.force_login(user)
client.force_login(identity.user)
response = client.delete(
f"/api/v1.0/users/{user.id!s}/",
f"/api/v1.0/users/{identity.user.id!s}/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
@@ -0,0 +1,204 @@
"""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
@@ -0,0 +1,183 @@
"""
Unit tests for the Identity model
"""
from django.core.exceptions import ValidationError
import pytest
from core import factories, models
pytestmark = pytest.mark.django_db
def test_models_identities_str_main():
"""The str representation should be the email address with indication that it is main."""
identity = factories.IdentityFactory(email="david@example.com")
assert str(identity) == "david@example.com[main]"
def test_models_identities_str_secondary():
"""The str representation of a secondary email should be the email address."""
main_identity = factories.IdentityFactory()
secondary_identity = factories.IdentityFactory(
user=main_identity.user, email="david@example.com"
)
assert str(secondary_identity) == "david@example.com"
def test_models_identities_is_main_automatic():
"""The first identity created for a user should automatically be set as main."""
user = factories.UserFactory()
identity = models.Identity.objects.create(
user=user, sub="123", email="david@example.com"
)
assert identity.is_main is True
def test_models_identities_is_main_exists():
"""A user should always keep one and only one of its identities as main."""
user = factories.UserFactory()
main_identity, _secondary_identity = factories.IdentityFactory.create_batch(
2, user=user
)
assert main_identity.is_main is True
main_identity.is_main = False
with pytest.raises(
ValidationError, match="A user should have one and only one main identity."
):
main_identity.save()
def test_models_identities_is_main_switch():
"""Setting a secondary identity as main should reset the existing main identity."""
user = factories.UserFactory()
first_identity, second_identity = factories.IdentityFactory.create_batch(
2, user=user
)
assert first_identity.is_main is True
second_identity.is_main = True
second_identity.save()
second_identity.refresh_from_db()
assert second_identity.is_main is True
first_identity.refresh_from_db()
assert first_identity.is_main is False
def test_models_identities_email_not_required():
"""The 'email' field can be blank."""
user = factories.UserFactory()
models.Identity.objects.create(user=user, sub="123", email=None)
def test_models_identities_user_required():
"""The 'user' field is required."""
with pytest.raises(models.User.DoesNotExist, match="Identity has no user."):
models.Identity.objects.create(user=None, email="david@example.com")
def test_models_identities_email_unique_same_user():
"""The 'email' field should be unique for a given user."""
email = factories.IdentityFactory()
with pytest.raises(
ValidationError,
match="Identity with this User and Email address already exists.",
):
factories.IdentityFactory(user=email.user, email=email.email)
def test_models_identities_email_unique_different_users():
"""The 'email' field should not be unique among users."""
email = factories.IdentityFactory()
factories.IdentityFactory(email=email.email)
def test_models_identities_email_normalization():
"""The 'email' field should be automatically normalized upon saving."""
email = factories.IdentityFactory()
email.email = "Thomas.Jefferson@Example.com"
email.save()
assert email.email == "Thomas.Jefferson@example.com"
def test_models_identities_ordering():
"""Identities should be returned ordered by main status then by their email address."""
user = factories.UserFactory()
factories.IdentityFactory.create_batch(5, user=user)
emails = models.Identity.objects.all()
assert emails[0].is_main is True
for i in range(3):
assert emails[i + 1].is_main is False
assert emails[i + 2].email >= emails[i + 1].email
def test_models_identities_sub_null():
"""The 'sub' field should not be null."""
user = factories.UserFactory()
with pytest.raises(ValidationError, match="This field cannot be null."):
models.Identity.objects.create(user=user, sub=None)
def test_models_identities_sub_blank():
"""The 'sub' field should not be blank."""
user = factories.UserFactory()
with pytest.raises(ValidationError, match="This field cannot be blank."):
models.Identity.objects.create(user=user, email="david@example.com", sub="")
def test_models_identities_sub_unique():
"""The 'sub' field should be unique."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
with pytest.raises(ValidationError, match="Identity with this Sub already exists."):
models.Identity.objects.create(user=user, sub=identity.sub)
def test_models_identities_sub_max_length():
"""The 'sub' field should be 255 characters maximum."""
factories.IdentityFactory(sub="a" * 255)
with pytest.raises(ValidationError) as excinfo:
factories.IdentityFactory(sub="a" * 256)
assert (
str(excinfo.value)
== "{'sub': ['Ensure this value has at most 255 characters (it has 256).']}"
)
def test_models_identities_sub_special_characters():
"""The 'sub' field should accept periods, dashes, +, @ and underscores."""
identity = factories.IdentityFactory(sub="dave.bowman-1+2@hal_9000")
assert identity.sub == "dave.bowman-1+2@hal_9000"
def test_models_identities_sub_spaces():
"""The 'sub' field should not accept spaces."""
with pytest.raises(ValidationError) as excinfo:
factories.IdentityFactory(sub="a b")
assert str(excinfo.value) == (
"{'sub': ['Enter a valid sub. This value may contain only letters, numbers, "
"and @/./+/-/_ characters.']}"
)
def test_models_identities_sub_upper_case():
"""The 'sub' field should accept upper case characters."""
identity = factories.IdentityFactory(sub="John")
assert identity.sub == "John"
def test_models_identities_sub_ascii():
"""The 'sub' field should accept non ASCII letters."""
identity = factories.IdentityFactory(sub="rené")
assert identity.sub == "rené"
@@ -84,7 +84,7 @@ def test_models_invitations__is_expired(settings):
def test_models_invitation__new_user__convert_invitations_to_accesses():
"""
Upon creating a new user, invitations linked to that email
Upon creating a new identity, invitations linked to that email
should be converted to accesses and then deleted.
"""
# Two invitations to the same mail but to different teams
@@ -95,14 +95,16 @@ def test_models_invitation__new_user__convert_invitations_to_accesses():
team=invitation_to_team2.team
) # another person invited to team2
new_user = factories.UserFactory(email=invitation_to_team1.email)
new_identity = factories.IdentityFactory(
is_main=True, email=invitation_to_team1.email
)
# The invitation regarding
assert models.TeamAccess.objects.filter(
team=invitation_to_team1.team, user=new_user
team=invitation_to_team1.team, user=new_identity.user
).exists()
assert models.TeamAccess.objects.filter(
team=invitation_to_team2.team, user=new_user
team=invitation_to_team2.team, user=new_identity.user
).exists()
assert not models.Invitation.objects.filter(
team=invitation_to_team1.team, email=invitation_to_team1.email
@@ -117,7 +119,7 @@ def test_models_invitation__new_user__convert_invitations_to_accesses():
def test_models_invitation__new_user__filter_expired_invitations():
"""
Upon creating a new user, valid invitations should be converted into accesses
Upon creating a new identity, valid invitations should be converted into accesses
and expired invitations should remain unchanged.
"""
with freeze_time("2020-01-01"):
@@ -125,11 +127,11 @@ def test_models_invitation__new_user__filter_expired_invitations():
user_email = expired_invitation.email
valid_invitation = factories.InvitationFactory(email=user_email)
new_user = factories.UserFactory(email=user_email)
new_identity = factories.IdentityFactory(is_main=True, email=user_email)
# valid invitation should have granted access to the related team
assert models.TeamAccess.objects.filter(
team=valid_invitation.team, user=new_user
team=valid_invitation.team, user=new_identity.user
).exists()
assert not models.Invitation.objects.filter(
team=valid_invitation.team, email=user_email
@@ -137,14 +139,14 @@ def test_models_invitation__new_user__filter_expired_invitations():
# expired invitation should not have been consumed
assert not models.TeamAccess.objects.filter(
team=expired_invitation.team, user=new_user
team=expired_invitation.team, user=new_identity.user
).exists()
assert models.Invitation.objects.filter(
team=expired_invitation.team, email=user_email
).exists()
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 4), (1, 7), (20, 7)])
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 8), (1, 11), (20, 11)])
def test_models_invitation__new_user__user_creation_constant_num_queries(
django_assert_num_queries, num_invitations, num_queries
):
@@ -158,14 +160,15 @@ def test_models_invitation__new_user__user_creation_constant_num_queries(
for _ in range(0, num_invitations):
factories.InvitationFactory(email=user_email, team=factories.TeamFactory())
factories.UserFactory()
user = factories.UserFactory()
# with no invitation, we skip an "if", resulting in 8 requests
# otherwise, we should have 11 queries with any number of invitations
with django_assert_num_queries(num_queries):
models.User.objects.create(
models.Identity.objects.create(
is_main=True,
email=user_email,
password="!",
user=user,
name="Prudence C.",
sub=uuid.uuid4(),
)
@@ -254,14 +257,13 @@ def test_models_team_invitations_email():
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
(email,) = mail.outbox
assert email.to == [invitation.email]
assert email.subject == "Invitation to join Desk!"
email_content = " ".join(email.body.split())
assert "Invitation to join Desk!" in email_content
assert "[//example.com]" in email_content
@mock.patch(
@@ -2,16 +2,12 @@
Unit tests for the TeamAccess model
"""
import json
import re
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
import pytest
import responses
from core import factories, models
from core import factories
pytestmark = pytest.mark.django_db
@@ -20,13 +16,16 @@ def test_models_team_accesses_str():
"""
The str representation should include user name, team full name and role.
"""
user = factories.UserFactory()
contact = factories.ContactFactory(full_name="David Bowman")
user = contact.owner
user.profile_contact = contact
user.save()
access = factories.TeamAccessFactory(
role="member",
user=user,
team__name="admins",
)
assert str(access) == f"{user} is {access.role} in team {access.team}"
assert str(access) == "David Bowman is member in team admins"
def test_models_team_accesses_unique():
@@ -40,94 +39,6 @@ def test_models_team_accesses_unique():
factories.TeamAccessFactory(user=access.user, team=access.team)
def test_models_team_accesses_create_webhook():
"""
When the team has a webhook, creating a team access should fire a call.
"""
user = factories.UserFactory()
team = factories.TeamFactory()
webhook = factories.TeamWebhookFactory(team=team)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
models.TeamAccess.objects.create(user=user, team=team)
assert rsp.call_count == 1
assert rsps.calls[0].request.url == webhook.url
# Payload sent to scim provider
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(user.id),
"email": user.email,
"type": "User",
}
],
}
],
}
def test_models_team_accesses_delete_webhook():
"""
When the team has a webhook, deleting a team access should fire a call.
"""
team = factories.TeamFactory()
webhook = factories.TeamWebhookFactory(team=team)
access = factories.TeamAccessFactory(team=team)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
access.delete()
assert rsp.call_count == 1
assert rsps.calls[0].request.url == webhook.url
# Payload sent to scim provider
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "remove",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": access.user.email,
"type": "User",
}
],
}
],
}
assert models.TeamAccess.objects.exists() is False
# get_abilities
+19 -113
View File
@@ -14,18 +14,13 @@ pytestmark = pytest.mark.django_db
def test_models_users_str():
"""
user str representation should return name or email when avalaible.
Otherwise, it should return the sub.
"""
"""The str representation should be the full name."""
user = factories.UserFactory()
assert str(user) == user.name
contact = factories.ContactFactory(full_name="david bowman", owner=user)
user.profile_contact = contact
user.save()
no_name_user = factories.UserFactory(name=None)
assert str(no_name_user) == no_name_user.email
no_name_no_email_user = factories.UserFactory(name=None, email=None)
assert str(no_name_no_email_user) == f"User {no_name_no_email_user.sub}"
assert str(user) == "david bowman"
def test_models_users_id_unique():
@@ -35,101 +30,23 @@ def test_models_users_id_unique():
factories.UserFactory(id=user.id)
def test_models_users_sub_null():
"""The 'sub' field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null.") as excinfo:
models.User.objects.create(sub=None, password="password")
assert str(excinfo.value) == "{'sub': ['This field cannot be null.']}"
def test_models_users_sub_blank():
"""The 'sub' field should not be blank."""
with pytest.raises(ValidationError, match="This field cannot be blank.") as excinfo:
models.User.objects.create(sub="", password="password")
assert str(excinfo.value) == "{'sub': ['This field cannot be blank.']}"
def test_models_users_sub_unique():
"""The 'sub' field should be unique."""
def test_models_users_email_unique():
"""The "email" field should be unique except for the null value."""
user = factories.UserFactory()
with pytest.raises(ValidationError, match="User with this Sub already exists."):
models.User.objects.create(sub=user.sub, password="password")
def test_models_users_sub_max_length():
"""The 'sub' field should be 255 characters maximum."""
factories.UserFactory(sub="a" * 255)
with pytest.raises(ValidationError) as excinfo:
factories.UserFactory(sub="a" * 256)
assert (
str(excinfo.value)
== "{'sub': ['Ensure this value has at most 255 characters (it has 256).']}"
)
def test_models_users_sub_special_characters():
"""The 'sub' field should accept periods, dashes, +, @ and underscores."""
user = factories.UserFactory(sub="dave.bowman-1+2@hal_9000")
assert user.sub == "dave.bowman-1+2@hal_9000"
def test_models_users_sub_spaces():
"""The 'sub' field should not accept spaces."""
with pytest.raises(ValidationError) as excinfo:
factories.UserFactory(sub="a b")
assert str(excinfo.value) == (
"{'sub': ['Enter a valid sub. This value may contain only letters, numbers, "
"and @/./+/-/_ characters.']}"
)
def test_models_users_sub_upper_case():
"""The 'sub' field should accept upper case characters."""
user = factories.UserFactory(sub="John")
assert user.sub == "John"
def test_models_users_sub_ascii():
"""The 'sub' field should accept non ASCII letters."""
user = factories.UserFactory(sub="rené")
assert user.sub == "rené"
def test_models_users_email_not_required():
"""The 'email' field can be blank."""
user = factories.UserFactory(email=None)
assert user.email is None
def test_models_users_email_normalization():
"""The 'email' field should be automatically normalized upon saving."""
user = factories.UserFactory()
user.email = "Thomas.Jefferson@Example.com"
user.save()
assert user.email == "Thomas.Jefferson@example.com"
with pytest.raises(
ValidationError, match="User with this Email address already exists."
):
models.User.objects.create(email=user.email)
def test_models_users_email_several_null():
"""Several users with a null value for the "email" field can co-exist."""
factories.UserFactory(email=None)
models.User.objects.create(email=None, sub="123", password="foo.")
models.User.objects.create(email=None, password="foo.")
assert models.User.objects.filter(email__isnull=True).count() == 2
def test_models_users_email_not_unique():
"""The 'email' field should not necessarily be unique among users."""
user = factories.UserFactory()
other_user = factories.UserFactory(email=user.email)
assert user.email == other_user.email
def test_models_users_profile_not_owned():
"""A user cannot declare as profile a contact that not is owned."""
user = factories.UserFactory()
@@ -161,9 +78,10 @@ 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 email address."""
user = factories.UserFactory(email="dave@example.com")
factories.UserFactory.create_batch(2)
"""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)
with mock.patch("django.core.mail.send_mail") as mock_send:
user.email_user("my subject", "my message")
@@ -173,23 +91,11 @@ def test_models_users_send_mail_main_existing():
)
def test_models_users_send_mail_main_admin():
"""
The 'email_user' method should send mail to the user's email address.
"""
user = factories.UserFactory()
with mock.patch("django.core.mail.send_mail") as mock_send:
user.email_user("my subject", "my message")
mock_send.assert_called_once_with("my subject", "my message", None, [user.email])
def test_models_users_send_mail_main_missing():
"""The 'email_user' method should fail if the user has no email address."""
user = factories.UserFactory(email=None)
user = factories.UserFactory()
with pytest.raises(ValueError) as excinfo:
with pytest.raises(models.Identity.DoesNotExist) as excinfo:
user.email_user("my subject", "my message")
assert str(excinfo.value) == "You must first set an email for the user."
assert str(excinfo.value) == "Identity matching query does not exist."
+2 -1
View File
@@ -16,7 +16,8 @@ def test_throttle():
"""
Throttle protection should block requests if too many.
"""
user = factories.UserFactory()
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
@@ -1,341 +0,0 @@
"""Test Team synchronization webhooks."""
import json
import random
import re
from logging import Logger
from unittest import mock
import pytest
import responses
from core import factories
from core.utils.webhooks import scim_synchronizer
pytestmark = pytest.mark.django_db
def test_utils_webhooks_add_user_to_group_no_webhooks():
"""If no webhook is declared on the team, the function should not make any request."""
access = factories.TeamAccessFactory()
with responses.RequestsMock():
scim_synchronizer.add_user_to_group(access.team, access.user)
assert len(responses.calls) == 0
@mock.patch.object(Logger, "info")
def test_utils_webhooks_add_user_to_group_success(mock_info):
"""The user passed to the function should get added."""
user = factories.UserFactory()
access = factories.TeamAccessFactory(user=user)
webhooks = factories.TeamWebhookFactory.create_batch(2, team=access.team)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
# Check headers
headers = rsps.calls[i].request.headers
assert "Authorization" not in headers
assert headers["Content-Type"] == "application/json"
# Payload sent to scim provider
for call in rsps.calls:
payload = json.loads(call.request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": user.email,
"type": "User",
}
],
}
],
}
# Logger
assert mock_info.call_count == 2
for i, webhook in enumerate(webhooks):
assert mock_info.call_args_list[i][0] == (
"%s synchronization succeeded with %s",
"add_user_to_group",
webhook.url,
)
# Status
for webhook in webhooks:
webhook.refresh_from_db()
assert webhook.status == "success"
@mock.patch.object(Logger, "info")
def test_utils_webhooks_remove_user_from_group_success(mock_info):
"""The user passed to the function should get removed."""
user = factories.UserFactory()
access = factories.TeamAccessFactory(user=user)
webhooks = factories.TeamWebhookFactory.create_batch(2, team=access.team)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
scim_synchronizer.remove_user_from_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
# Payload sent to scim provider
for call in rsps.calls:
payload = json.loads(call.request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "remove",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": user.email,
"type": "User",
}
],
}
],
}
# Logger
assert mock_info.call_count == 2
for i, webhook in enumerate(webhooks):
assert mock_info.call_args_list[i][0] == (
"%s synchronization succeeded with %s",
"remove_user_from_group",
webhook.url,
)
# Status
for webhook in webhooks:
webhook.refresh_from_db()
assert webhook.status == "success"
@mock.patch.object(Logger, "error")
@mock.patch.object(Logger, "info")
def test_utils_webhooks_add_user_to_group_failure(mock_info, mock_error):
"""The logger should be called on webhook call failure."""
user = factories.UserFactory()
access = factories.TeamAccessFactory(user=user)
webhooks = factories.TeamWebhookFactory.create_batch(2, team=access.team)
with responses.RequestsMock() as rsps:
# Simulate webhook failure using "responses":
rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=random.choice([404, 301, 302]),
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
# Payload sent to scim provider
for call in rsps.calls:
payload = json.loads(call.request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": user.email,
"type": "User",
}
],
}
],
}
# Logger
assert not mock_info.called
assert mock_error.call_count == 2
for i, webhook in enumerate(webhooks):
assert mock_error.call_args_list[i][0] == (
"%s synchronization failed with %s",
"add_user_to_group",
webhook.url,
)
# Status
for webhook in webhooks:
webhook.refresh_from_db()
assert webhook.status == "failure"
@mock.patch.object(Logger, "error")
@mock.patch.object(Logger, "info")
def test_utils_webhooks_add_user_to_group_retries(mock_info, mock_error):
"""webhooks synchronization supports retries."""
user = factories.UserFactory()
access = factories.TeamAccessFactory(user=user)
webhook = factories.TeamWebhookFactory(team=access.team)
url = re.compile(r".*/Groups/.*")
with responses.RequestsMock() as rsps:
# Make webhook fail 3 times before succeeding using "responses"
all_rsps = [
rsps.add(rsps.PATCH, url, status=500, content_type="application/json"),
rsps.add(rsps.PATCH, url, status=500, content_type="application/json"),
rsps.add(rsps.PATCH, url, status=500, content_type="application/json"),
rsps.add(rsps.PATCH, url, status=200, content_type="application/json"),
]
scim_synchronizer.add_user_to_group(access.team, access.user)
for i in range(4):
assert all_rsps[i].call_count == 1
assert rsps.calls[i].request.url == webhook.url
payload = json.loads(rsps.calls[i].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": user.email,
"type": "User",
}
],
}
],
}
# Logger
assert not mock_error.called
assert mock_info.call_count == 1
assert mock_info.call_args_list[0][0] == (
"%s synchronization succeeded with %s",
"add_user_to_group",
webhook.url,
)
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
@mock.patch.object(Logger, "error")
@mock.patch.object(Logger, "info")
def test_utils_synchronize_course_runs_max_retries_exceeded(mock_info, mock_error):
"""Webhooks synchronization has exceeded max retries and should get logged."""
user = factories.UserFactory()
access = factories.TeamAccessFactory(user=user)
webhook = factories.TeamWebhookFactory(team=access.team)
with responses.RequestsMock() as rsps:
# Simulate webhook temporary failure using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=random.choice([500, 502]),
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
assert rsp.call_count == 5
assert rsps.calls[0].request.url == webhook.url
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": user.email,
"type": "User",
}
],
}
],
}
# Logger
assert not mock_info.called
assert mock_error.call_count == 1
assert mock_error.call_args_list[0][0] == (
"%s synchronization failed due to max retries exceeded with url %s",
"add_user_to_group",
webhook.url,
)
# Status
webhook.refresh_from_db()
assert webhook.status == "failure"
def test_utils_webhooks_add_user_to_group_authorization():
"""Secret token should be passed in authorization header when set."""
user = factories.UserFactory()
access = factories.TeamAccessFactory(user=user)
webhook = factories.TeamWebhookFactory(team=access.team, secret="123")
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
assert rsps.calls[0].request.url == webhook.url
# Check headers
headers = rsps.calls[0].request.headers
assert headers["Authorization"] == "Bearer 123"
assert headers["Content-Type"] == "application/json"
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
View File
-70
View File
@@ -1,70 +0,0 @@
"""A minimalist SCIM client to synchronize with remote service providers."""
import logging
import requests
from urllib3.util import Retry
logger = logging.getLogger(__name__)
adapter = requests.adapters.HTTPAdapter(
max_retries=Retry(
total=4,
backoff_factor=0.1,
status_forcelist=[500, 502],
allowed_methods=["PATCH"],
)
)
session = requests.Session()
session.mount("http://", adapter)
session.mount("https://", adapter)
class SCIMClient:
"""A minimalist SCIM client for our needs."""
def add_user_to_group(self, webhook, user):
"""Add a user to a group from its ID or email."""
payload = {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{"value": str(user.id), "email": user.email, "type": "User"}
],
}
],
}
return session.patch(
webhook.url,
json=payload,
headers=webhook.get_headers(),
verify=False,
timeout=3,
)
def remove_user_from_group(self, webhook, user):
"""Remove a user from a group by its ID or email."""
payload = {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "remove",
"path": "members",
"value": [
{"value": str(user.id), "email": user.email, "type": "User"}
],
}
],
}
return session.patch(
webhook.url,
json=payload,
headers=webhook.get_headers(),
verify=False,
timeout=3,
)
-75
View File
@@ -1,75 +0,0 @@
"""Fire webhooks with synchronous retries"""
import logging
import requests
from core.enums import WebhookStatusChoices
from .scim import SCIMClient
logger = logging.getLogger(__name__)
class WebhookSCIMClient:
"""Wraps the SCIM client to record call results on webhooks."""
def __getattr__(self, name):
"""Handle calls from webhooks to synchronize a team access with a distant application."""
def wrapper(team, user):
"""
Wrap SCIMClient calls to handle retries, error handling and storing result in the
calling Webhook instance.
"""
for webhook in team.webhooks.all():
if not webhook.url:
continue
client = SCIMClient()
status = WebhookStatusChoices.FAILURE
try:
response = getattr(client, name)(webhook, user)
except requests.exceptions.RetryError as exc:
logger.error(
"%s synchronization failed due to max retries exceeded with url %s",
name,
webhook.url,
exc_info=exc,
)
except requests.exceptions.RequestException as exc:
logger.error(
"%s synchronization failed with %s.",
name,
webhook.url,
exc_info=exc,
)
else:
extra = {
"response": response.content,
}
# pylint: disable=no-member
if response.status_code == requests.codes.ok:
logger.info(
"%s synchronization succeeded with %s",
name,
webhook.url,
extra=extra,
)
status = WebhookStatusChoices.SUCCESS
else:
logger.error(
"%s synchronization failed with %s",
name,
webhook.url,
extra=extra,
)
webhook._meta.model.objects.filter(id=webhook.id).update(status=status) # noqa
return wrapper
scim_synchronizer = WebhookSCIMClient()
-6
View File
@@ -4,7 +4,6 @@ from django.urls import path
from .views import (
DebugViewHtml,
DebugViewNewMailboxHtml,
DebugViewTxt,
)
@@ -19,9 +18,4 @@ urlpatterns = [
DebugViewTxt.as_view(),
name="debug.mail.invitation_txt",
),
path(
"__debug__/mail/new_mailbox_html",
DebugViewNewMailboxHtml.as_view(),
name="debug.mail.new_mailbox_html",
),
]
-14
View File
@@ -25,17 +25,3 @@ class DebugViewTxt(DebugBaseView):
"""Debug View for Text Email Layout"""
template_name = "mail/text/invitation.txt"
class DebugViewNewMailboxHtml(DebugBaseView):
"""Debug view for new mailbox email layout"""
template_name = "mail/html/new_mailbox.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["mailbox_data"] = {
"email": "john.doe@example.com",
"password": "6HGVAsjoog_v",
}
return context
-1
View File
@@ -4,5 +4,4 @@ NB_OBJECTS = {
"users": 1000,
"teams": 100,
"max_users_per_team": 100,
"domains": 20,
}
@@ -10,15 +10,12 @@ from uuid import uuid4
from django import db
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.utils.text import slugify
from faker import Faker
from core import models
from demo import defaults
from mailbox_manager import models as mailbox_models
from mailbox_manager.enums import MailDomainStatusChoices
fake = Faker()
@@ -118,9 +115,7 @@ def create_demo(stdout):
for i in range(defaults.NB_OBJECTS["users"]):
queue.push(
models.User(
sub=uuid4(),
email=f"user{i:d}@example.com" if random.random() < 0.97 else None,
name=fake.name() if random.random() < 0.97 else None,
email=f"user{i:d}@example.com",
password="!",
is_superuser=False,
is_active=True,
@@ -130,6 +125,27 @@ def create_demo(stdout):
)
queue.flush()
with Timeit(stdout, "Creating identities"):
users_values = list(models.User.objects.values("id", "email"))
for user_dict in users_values:
for i in range(
random.choices(range(5), weights=[5, 50, 30, 10, 5], k=1)[0]
):
user_email = user_dict["email"]
queue.push(
models.Identity(
user_id=user_dict["id"],
sub=uuid4(),
is_main=(i == 0),
# Leave 3% of emails and names empty
email=f"identity{i:d}{user_email:s}"
if random.random() < 0.97
else None,
name=fake.name() if random.random() < 0.97 else None,
)
)
queue.flush()
with Timeit(stdout, "Creating teams"):
for i in range(defaults.NB_OBJECTS["teams"]):
queue.push(
@@ -155,35 +171,6 @@ def create_demo(stdout):
)
queue.flush()
with Timeit(stdout, "Creating domains"):
for _i in range(defaults.NB_OBJECTS["domains"]):
name = fake.domain_name()
slug = slugify(name)
queue.push(
mailbox_models.MailDomain(
name=name,
# slug should be automatic but bulk_create doesn't use save
slug=slug,
status=random.choice(MailDomainStatusChoices.choices)[0],
)
)
queue.flush()
with Timeit(stdout, "Creating accesses to domains"):
domains_ids = list(
mailbox_models.MailDomain.objects.values_list("id", flat=True)
)
for domain_id in domains_ids:
queue.push(
mailbox_models.MailDomainAccess(
domain_id=domain_id,
user_id=random.choice(users_ids),
role=models.RoleChoices.OWNER,
)
)
queue.flush()
class Command(BaseCommand):
"""A management command to create a demo database."""
@@ -10,17 +10,15 @@ User = get_user_model()
class Command(BaseCommand):
"""
Management command to create superusers from a username and a password for demo purposes.
"""
"""Management command to create superusers from an email and a password"""
help = "Create a superuser with a username and a password for demo purposes"
help = "Create a superuser with an email and a password"
def add_arguments(self, parser):
"""Define required arguments "username" and "password"."""
"""Define required arguments "email" and "password"."""
parser.add_argument(
"--username",
help=("Username for the user."),
"--email",
help=("Email for the user."),
)
parser.add_argument(
"--password",
@@ -32,11 +30,11 @@ class Command(BaseCommand):
Given an email and a password, create a superuser or upgrade the existing
user to superuser status.
"""
username = options.get("username")
email = options.get("email")
try:
user = User.objects.get(sub=username)
user = User.objects.get(email=email)
except User.DoesNotExist:
user = User(sub=username)
user = User(email=email)
message = "Superuser created successfully."
else:
if user.is_superuser and user.is_staff:
@@ -10,13 +10,12 @@ import pytest
from core import models
from demo import defaults
from mailbox_manager import models as mailbox_models
TEST_NB_OBJECTS = {
"users": 5,
"teams": 3,
"max_identities_per_user": 3,
"max_users_per_team": 5,
"domains": 2,
}
pytestmark = pytest.mark.django_db
@@ -28,21 +27,7 @@ def test_commands_create_demo():
"""The create_demo management command should create objects as expected."""
call_command("create_demo")
assert models.User.objects.count() == TEST_NB_OBJECTS["users"]
assert models.Team.objects.count() == TEST_NB_OBJECTS["teams"]
assert models.TeamAccess.objects.count() >= TEST_NB_OBJECTS["teams"]
assert mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"]
assert mailbox_models.MailDomainAccess.objects.count() == TEST_NB_OBJECTS["domains"]
def test_commands_createsuperuser():
"""
The createsuperuser management command should create a user
with superuser permissions.
"""
call_command("createsuperuser", username="admin", password="admin")
assert models.User.objects.count() == 1
user = models.User.objects.get()
assert user.sub == "admin"
assert models.User.objects.count() == 5
assert models.Identity.objects.exists()
assert models.Team.objects.count() == 3
assert models.TeamAccess.objects.count() >= 3
Binary file not shown.
+89 -235
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-10-15 10:52+0000\n"
"POT-Creation-Date: 2024-03-21 19:26+0000\n"
"PO-Revision-Date: 2024-01-03 23:15\n"
"Last-Translator: \n"
"Language-Team: French\n"
@@ -17,251 +17,212 @@ msgstr ""
"X-Crowdin-File: backend.pot\n"
"X-Crowdin-File-ID: 2\n"
#: core/admin.py:45
#: core/admin.py:70
msgid "Personal info"
msgstr ""
#: core/admin.py:47
#: core/admin.py:72
msgid "Permissions"
msgstr ""
#: core/admin.py:59
#: core/admin.py:84
msgid "Important dates"
msgstr ""
#: core/admin.py:97
msgid "User"
msgstr ""
#: core/authentication/backends.py:82
#: core/authentication.py:81
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:90
msgid "User account is disabled"
msgstr ""
#: core/authentication/backends.py:102
#: core/authentication.py:114
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/enums.py:23
msgid "Failure"
msgstr ""
#: core/enums.py:24 mailbox_manager/enums.py:20
msgid "Pending"
msgstr ""
#: core/enums.py:25
msgid "Success"
msgstr ""
#: core/models.py:42
#: core/models.py:38
msgid "Member"
msgstr ""
#: core/models.py:43 mailbox_manager/enums.py:13
#: core/models.py:39
msgid "Administrator"
msgstr ""
#: core/models.py:44 mailbox_manager/enums.py:14
#: core/models.py:40
msgid "Owner"
msgstr ""
#: core/models.py:56
#: core/models.py:52
msgid "id"
msgstr ""
#: core/models.py:57
#: core/models.py:53
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:63
#: core/models.py:59
msgid "created at"
msgstr ""
#: core/models.py:64
#: core/models.py:60
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:69
#: core/models.py:65
msgid "updated at"
msgstr ""
#: core/models.py:70
#: core/models.py:66
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:101
#: core/models.py:97
msgid "full name"
msgstr ""
#: core/models.py:102
#: core/models.py:98
msgid "short name"
msgstr ""
#: core/models.py:107
#: core/models.py:103
msgid "contact information"
msgstr ""
#: core/models.py:108
#: core/models.py:104
msgid "A JSON object containing the contact information"
msgstr ""
#: core/models.py:122
#: core/models.py:118
msgid "contact"
msgstr ""
#: core/models.py:123
#: core/models.py:119
msgid "contacts"
msgstr ""
#: core/models.py:167
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
#: core/models.py:173
msgid "sub"
msgstr ""
#: core/models.py:175
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
#: core/models.py:181 core/models.py:489
#: core/models.py:160 core/models.py:263 core/models.py:483
msgid "email address"
msgstr ""
#: core/models.py:182 mailbox_manager/models.py:20
msgid "name"
msgstr ""
#: core/models.py:194
#: core/models.py:172
msgid "language"
msgstr ""
#: core/models.py:195
#: core/models.py:173
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:201
#: core/models.py:179
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:204
#: core/models.py:182
msgid "device"
msgstr ""
#: core/models.py:206
#: core/models.py:184
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:209
#: core/models.py:187
msgid "staff status"
msgstr ""
#: core/models.py:211
#: core/models.py:189
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:214
#: core/models.py:192
msgid "active"
msgstr ""
#: core/models.py:217
#: core/models.py:195
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:229
#: core/models.py:207
msgid "user"
msgstr ""
#: core/models.py:230
#: core/models.py:208
msgid "users"
msgstr ""
#: core/models.py:306
#: core/models.py:248
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
#: core/models.py:255
msgid "sub"
msgstr ""
#: core/models.py:257
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
#: core/models.py:264
msgid "name"
msgstr ""
#: core/models.py:266
msgid "main"
msgstr ""
#: core/models.py:268
msgid "Designates whether the email is the main one."
msgstr ""
#: core/models.py:274
msgid "identity"
msgstr ""
#: core/models.py:275
msgid "identities"
msgstr ""
#: core/models.py:282
msgid "This email address is already declared for this user."
msgstr ""
#: core/models.py:356
msgid "Team"
msgstr ""
#: core/models.py:307
#: core/models.py:357
msgid "Teams"
msgstr ""
#: core/models.py:367
#: core/models.py:417
msgid "Team/user relation"
msgstr ""
#: core/models.py:368
#: core/models.py:418
msgid "Team/user relations"
msgstr ""
#: core/models.py:373
#: core/models.py:423
msgid "This user is already in this team."
msgstr ""
#: core/models.py:462
msgid "url"
msgstr ""
#: core/models.py:463
msgid "secret"
msgstr ""
#: core/models.py:472
msgid "Team webhook"
msgstr ""
#: core/models.py:473
msgid "Team webhooks"
msgstr ""
#: core/models.py:506
#: core/models.py:500
msgid "Team invitation"
msgstr ""
#: core/models.py:507
#: core/models.py:501
msgid "Team invitations"
msgstr ""
#: core/models.py:532
#: core/models.py:526
msgid "This email is already associated to a registered user."
msgstr ""
#: core/models.py:574 core/models.py:580
#: core/models.py:568 core/models.py:573
msgid "Invitation to join Desk!"
msgstr ""
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
#: core/templates/mail/html/hello.html:221
#, python-format
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
@@ -285,7 +246,7 @@ msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
"We are delighted to welcome you to our community on Régie, your new "
"We are delighted to welcome you to our community on Equipes, your new "
"companion to simplify the management of your groups efficiently, "
"intuitively, and securely."
msgstr ""
@@ -299,7 +260,7 @@ msgstr ""
#: core/templates/mail/html/invitation.html:236
#: core/templates/mail/text/invitation.txt:16
msgid "With Régie, you will be able to:"
msgid "With Equipes, you will be able to:"
msgstr ""
#: core/templates/mail/html/invitation.html:237
@@ -333,13 +294,13 @@ msgstr ""
#: core/templates/mail/html/invitation.html:252
#: core/templates/mail/text/invitation.txt:23
msgid "Visit Régie"
msgid "Visit Equipes"
msgstr ""
#: core/templates/mail/html/invitation.html:261
#: core/templates/mail/text/invitation.txt:25
msgid ""
"We are confident that Régie will help you increase efficiency and "
"We are confident that Equipes will help you increase efficiency and "
"productivity while strengthening the bond among members."
msgstr ""
@@ -359,126 +320,19 @@ msgid ""
msgstr ""
#: core/templates/mail/html/invitation.html:278
#: core/templates/mail/html/new_mailbox.html:272
#: core/templates/mail/text/invitation.txt:29
#: core/templates/mail/text/new_mailbox.txt:15
msgid "Sincerely,"
msgstr "Cordialement,"
msgstr ""
#: core/templates/mail/html/invitation.html:279
#: core/templates/mail/text/invitation.txt:31
msgid "The La Suite Numérique Team"
msgstr "L'équipe de La Suite Numérique"
#: core/templates/mail/html/new_mailbox.html:159
#: core/templates/mail/text/new_mailbox.txt:3
msgid "La Messagerie"
msgstr "La Messagerie"
#: core/templates/mail/html/new_mailbox.html:188
#: core/templates/mail/text/new_mailbox.txt:5
msgid "Welcome to La Messagerie"
msgstr "Bienvenue dans La Messagerie"
#: core/templates/mail/html/new_mailbox.html:193
#: core/templates/mail/text/new_mailbox.txt:6
msgid "La Messagerie is the email solution of La Suite."
msgstr "La Messagerie est la solution de mail de La Suite."
#: core/templates/mail/html/new_mailbox.html:199
#: core/templates/mail/text/new_mailbox.txt:7
msgid "Your mailbox has been created."
msgstr "Votre boîte mail a été créée."
#: core/templates/mail/html/new_mailbox.html:204
#: core/templates/mail/text/new_mailbox.txt:8
msgid "Please find below your login info: "
msgstr "Voici vos identifiants de connexion :"
#: core/templates/mail/html/new_mailbox.html:228
#: core/templates/mail/text/new_mailbox.txt:10
msgid "Email address: "
msgstr "Adresse email : "
#: core/templates/mail/html/new_mailbox.html:233
#: core/templates/mail/text/new_mailbox.txt:11
msgid "Temporary password (to be modify on first login): "
msgstr "Mot de passe temporaire (à modifier à la première connexion) : "
#: core/templates/mail/html/new_mailbox.html:261
#: core/templates/mail/text/new_mailbox.txt:13
msgid "Go to La Messagerie"
msgstr "Accéder à La Messagerie"
#: core/templates/mail/html/new_mailbox.html:273
#: core/templates/mail/text/new_mailbox.txt:17
msgid "La Suite Team"
msgstr "L'équipe de La Suite"
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: mailbox_manager/enums.py:12
msgid "Viewer"
msgstr ""
#: mailbox_manager/enums.py:21
msgid "Enabled"
msgstr ""
#: mailbox_manager/enums.py:22
msgid "Failed"
msgstr ""
#: mailbox_manager/enums.py:23
msgid "Disabled"
msgstr ""
#: mailbox_manager/models.py:31
msgid "Mail domain"
msgstr ""
#: mailbox_manager/models.py:32
msgid "Mail domains"
msgstr ""
#: mailbox_manager/models.py:98
msgid "User/mail domain relation"
msgstr ""
#: mailbox_manager/models.py:99
msgid "User/mail domain relations"
msgstr ""
#: mailbox_manager/models.py:171
msgid "local_part"
msgstr ""
#: mailbox_manager/models.py:185
msgid "secondary email address"
msgstr ""
#: mailbox_manager/models.py:190
msgid "Mailbox"
msgstr ""
#: mailbox_manager/models.py:191
msgid "Mailboxes"
msgstr ""
#: mailbox_manager/utils/dimail.py:137
msgid "Your new mailbox information"
msgstr "Informations concernant votre nouvelle boîte mail"
#: people/settings.py:134
#: people/settings.py:133
msgid "English"
msgstr ""
#: people/settings.py:135
#: people/settings.py:134
msgid "French"
msgstr ""
#~ msgid "Regards,"
#~ msgstr "Cordialement,"
-59
View File
@@ -1,59 +0,0 @@
"""Admin classes and registrations for People's mailbox manager app."""
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from mailbox_manager import models
class UserMailDomainAccessInline(admin.TabularInline):
"""Inline admin class for mail domain accesses."""
extra = 0
model = models.MailDomainAccess
readonly_fields = ("created_at", "updated_at", "domain", "user")
@admin.register(models.MailDomain)
class MailDomainAdmin(admin.ModelAdmin):
"""Mail domain admin interface declaration."""
list_display = (
"name",
"created_at",
"updated_at",
"slug",
"status",
)
search_fields = ("name",)
readonly_fields = ["created_at", "slug"]
inlines = (UserMailDomainAccessInline,)
@admin.register(models.MailDomainAccess)
class MailDomainAccessAdmin(admin.ModelAdmin):
"""Admin for mail domain accesses model."""
list_display = (
"user",
"domain",
"role",
"created_at",
"updated_at",
)
class MailDomainAccessInline(admin.TabularInline):
"""Inline admin class for mail domain accesses."""
extra = 0
autocomplete_fields = ["user", "domain"]
model = models.MailDomainAccess
readonly_fields = ("created_at", "updated_at")
@admin.register(models.Mailbox)
class MailboxAdmin(admin.ModelAdmin):
"""Admin for mailbox model."""
list_display = ("__str__", "first_name", "last_name")
@@ -1,32 +0,0 @@
"""Permission handlers for the People mailbox manager app."""
from core.api import permissions as core_permissions
from mailbox_manager import models
class AccessPermission(core_permissions.IsAuthenticated):
"""Permission class for access objects."""
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
class MailBoxPermission(core_permissions.IsAuthenticated):
"""Permission class to manage mailboxes for a mail domain"""
def has_permission(self, request, view):
domain = models.MailDomain.objects.get(slug=view.kwargs.get("domain_slug", ""))
abilities = domain.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
class MailDomainAccessRolePermission(core_permissions.IsAuthenticated):
"""Permission class to manage mailboxes for a mail domain"""
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
@@ -1,179 +0,0 @@
"""Client serializers for People's mailbox manager app."""
import json
from rest_framework import exceptions, serializers
from core.api.serializers import UserSerializer
from core.models import User
from mailbox_manager import enums, models
from mailbox_manager.utils.dimail import DimailAPIClient
class MailboxSerializer(serializers.ModelSerializer):
"""Serialize mailbox."""
class Meta:
model = models.Mailbox
fields = ["id", "first_name", "last_name", "local_part", "secondary_email"]
# everything is actually read-only as we do not allow update for now
read_only_fields = ["id"]
def create(self, validated_data):
"""
Override create function to fire a request on mailbox creation.
"""
# send new mailbox request to dimail
client = DimailAPIClient()
response = client.send_mailbox_request(
validated_data, self.context["request"].user.sub
)
# fix format to have actual json, and remove uuid
mailbox_data = json.loads(response.content.decode("utf-8").replace("'", '"'))
del mailbox_data["uuid"]
# actually save mailbox on our database
instance = models.Mailbox.objects.create(**validated_data)
# send confirmation email
client.send_new_mailbox_notification(
recipient=validated_data["secondary_email"], mailbox_data=mailbox_data
)
return instance
class MailDomainSerializer(serializers.ModelSerializer):
"""Serialize mail domain."""
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.MailDomain
lookup_field = "slug"
fields = [
"id",
"name",
"slug",
"status",
"abilities",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"slug",
"status",
"abilities",
"created_at",
"updated_at",
]
def get_abilities(self, domain) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return domain.get_abilities(request.user)
return {}
class MailDomainAccessSerializer(serializers.ModelSerializer):
"""Serialize mail domain access."""
user = UserSerializer(read_only=True, fields=["id", "name", "email"])
can_set_role_to = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.MailDomainAccess
fields = ["id", "user", "role", "can_set_role_to"]
read_only_fields = ["id", "can_set_role_to"]
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
def get_can_set_role_to(self, access):
"""Return roles available to set for the authenticated user"""
return access.get_can_set_role_to(self.context.get("request").user)
def validate(self, attrs):
"""
Check access rights specific to writing (update/create)
"""
request = self.context.get("request")
authenticated_user = getattr(request, "user", None)
role = attrs.get("role")
# Update
if self.instance:
can_set_role_to = self.instance.get_can_set_role_to(authenticated_user)
if role and role not in can_set_role_to:
message = (
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
if can_set_role_to
else "You are not allowed to modify role for this user."
)
raise exceptions.PermissionDenied(message)
# Create
else:
# A domain slug has to be set to create a new access
try:
domain_slug = self.context["domain_slug"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a domain slug in kwargs to create a new domain access."
) from exc
try:
access = authenticated_user.mail_domain_accesses.get(
domain__slug=domain_slug
)
except models.MailDomainAccess.DoesNotExist as exc:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this domain."
) from exc
# Authenticated user must be owner or admin of current domain to set new roles
if access.role not in [
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
]:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this domain."
)
# only an owner can set an owner role to another user
if (
role == enums.MailDomainRoleChoices.OWNER
and access.role != enums.MailDomainRoleChoices.OWNER
):
raise exceptions.PermissionDenied(
"Only owners of a domain can assign other users as owners."
)
attrs["user"] = User.objects.get(pk=self.initial_data["user"])
attrs["domain"] = models.MailDomain.objects.get(
slug=self.context["domain_slug"]
)
return attrs
class MailDomainAccessReadOnlySerializer(MailDomainAccessSerializer):
"""Serialize mail domain access for list and retrieve actions."""
class Meta:
model = models.MailDomainAccess
fields = [
"id",
"user",
"role",
"can_set_role_to",
]
read_only_fields = [
"id",
"user",
"role",
"can_set_role_to",
]
-217
View File
@@ -1,217 +0,0 @@
"""API endpoints"""
from django.db.models import Subquery
from rest_framework import exceptions, filters, mixins, viewsets
from core import models as core_models
from mailbox_manager import enums, models
from mailbox_manager.api import permissions, serializers
# pylint: disable=too-many-ancestors
class MailDomainViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
"""
MailDomain viewset.
GET /api/<version>/mail-domains/
Return a list of mail domains user has access to.
GET /api/<version>/mail-domains/<domain-slug>/
Return details for a mail domain user has access to.
POST /api/<version>/mail-domains/ with expected data:
- name: str
Return newly created domain
"""
permission_classes = [permissions.AccessPermission]
serializer_class = serializers.MailDomainSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "name"]
ordering = ["-created_at"]
lookup_field = "slug"
queryset = models.MailDomain.objects.all()
def get_queryset(self):
return self.queryset.filter(accesses__user=self.request.user)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created mail domain."""
domain = serializer.save()
models.MailDomainAccess.objects.create(
user=self.request.user,
domain=domain,
role=core_models.RoleChoices.OWNER,
)
# pylint: disable=too-many-ancestors
class MailDomainAccessViewSet(
viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
):
"""
API ViewSet for all interactions with mail domain accesses.
GET /api/v1.0/mail-domains/<domain_slug>/accesses/:<domain_access_id>
Return list of all domain accesses related to the logged-in user and one
domain access if an id is provided.
POST /api/v1.0/mail-domains/<domain_slug>/accesses/ with expected data:
- user: str
- role: str [owner|admin|viewer]
Return newly created mail domain access
PUT /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/ with expected data:
- role: str [owner|admin|viewer]
Return updated domain access
PATCH /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/ with expected data:
- role: str [owner|admin|viewer]
Return partially updated domain access
DELETE /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/
Delete targeted domain access
"""
permission_classes = [permissions.MailDomainAccessRolePermission]
serializer_class = serializers.MailDomainAccessSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["role", "user__email", "user__name"]
ordering = ["-created_at"]
queryset = (
models.MailDomainAccess.objects.all()
.select_related("user")
.order_by("-created_at")
)
list_serializer_class = serializers.MailDomainAccessReadOnlySerializer
detail_serializer_class = serializers.MailDomainAccessSerializer
def get_serializer_class(self):
if self.action in {"list", "retrieve"}:
return self.list_serializer_class
return self.detail_serializer_class
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["domain_slug"] = self.kwargs["domain_slug"]
context["authenticated_user"] = self.request.user
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(domain__slug=self.kwargs["domain_slug"])
if self.action in {"list", "retrieve"}:
# Determine which role the logged-in user has in the domain
user_role_query = models.MailDomainAccess.objects.filter(
user=self.request.user, domain__slug=self.kwargs["domain_slug"]
).values("role")[:1]
queryset = (
# The logged-in user should be part of a domain to see its accesses
queryset.filter(
domain__accesses__user=self.request.user,
)
# Abilities are computed based on logged-in user's role and
# the user role on each domain access
.annotate(
user_role=Subquery(user_role_query),
)
.select_related("user")
.distinct()
)
return queryset
def perform_update(self, serializer):
"""Check that we don't change the role if it leads to losing the last owner."""
instance = serializer.instance
# Check if the role is being updated and the new role is not "owner"
if (
"role" in self.request.data
and self.request.data["role"] != enums.MailDomainRoleChoices.OWNER
):
domain = instance.domain
# Check if the access being updated is the last owner access for the domain
if (
instance.role == enums.MailDomainRoleChoices.OWNER
and domain.accesses.filter(
role=enums.MailDomainRoleChoices.OWNER
).count()
== 1
):
message = "Cannot change the role to a non-owner role for the last owner access."
raise exceptions.PermissionDenied({"role": message})
serializer.save()
def destroy(self, request, *args, **kwargs):
"""Forbid deleting the last owner access"""
instance = self.get_object()
domain = instance.domain
# Check if the access being deleted is the last owner access for the domain
if (
instance.role == enums.MailDomainRoleChoices.OWNER
and domain.accesses.filter(role=enums.MailDomainRoleChoices.OWNER).count()
== 1
):
message = "Cannot delete the last owner access for the domain."
raise exceptions.PermissionDenied({"detail": message})
return super().destroy(request, *args, **kwargs)
class MailBoxViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""MailBox ViewSet
GET /api/<version>/mail-domains/<domain-slug>/mailboxes/
Return a list of mailboxes on the domain
POST /api/<version>/mail-domains/<domain-slug>/mailboxes/ with expected data:
- first_name: str
- last_name: str
- local_part: str
- secondary_email: str
Sends request to email provisioning API and returns newly created mailbox
"""
permission_classes = [permissions.MailBoxPermission]
serializer_class = serializers.MailboxSerializer
filter_backends = [filters.OrderingFilter]
ordering = ["-created_at"]
queryset = models.Mailbox.objects.all()
def get_queryset(self):
"""Custom queryset to get mailboxes related to a mail domain."""
domain_slug = self.kwargs.get("domain_slug", "")
if domain_slug:
return self.queryset.filter(domain__slug=domain_slug)
return self.queryset
def perform_create(self, serializer):
"""Create new mailbox."""
domain_slug = self.kwargs.get("domain_slug", "")
if domain_slug:
serializer.validated_data["domain"] = models.MailDomain.objects.get(
slug=domain_slug
)
super().perform_create(serializer)
-1
View File
@@ -1 +0,0 @@
"""People additionnal application, to manage email adresses."""
-23
View File
@@ -1,23 +0,0 @@
"""
Application enums declaration
"""
from django.db import models
from django.utils.translation import gettext_lazy as _
class MailDomainRoleChoices(models.TextChoices):
"""Defines the possible roles a user can have to access to a mail domain."""
VIEWER = "viewer", _("Viewer")
ADMIN = "administrator", _("Administrator")
OWNER = "owner", _("Owner")
class MailDomainStatusChoices(models.TextChoices):
"""Defines the possible statuses in which a mail domain can be."""
PENDING = "pending", _("Pending")
ENABLED = "enabled", _("Enabled")
FAILED = "failed", _("Failed")
DISABLED = "disabled", _("Disabled")
-77
View File
@@ -1,77 +0,0 @@
"""
Mailbox manager application factories
"""
from django.utils.text import slugify
import factory.fuzzy
from faker import Faker
from core import factories as core_factories
from core import models as core_models
from mailbox_manager import enums, models
fake = Faker()
class MailDomainFactory(factory.django.DjangoModelFactory):
"""A factory to create mail domain. Please not this is a factory to create mail domain with
default values. So the status is pending and no mailbox can be created from it,
until the mail domain is enabled."""
class Meta:
model = models.MailDomain
django_get_or_create = ("name",)
skip_postgeneration_save = True
name = factory.Faker("domain_name")
slug = factory.LazyAttribute(lambda o: slugify(o.name))
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to domain from a given list of users with or without roles."""
if not create or not extracted:
return
for user_entry in extracted:
if isinstance(user_entry, core_models.User):
MailDomainAccessFactory(domain=self, user=user_entry)
else:
MailDomainAccessFactory(
domain=self, user=user_entry[0], role=user_entry[1]
)
class MailDomainEnabledFactory(MailDomainFactory):
"""A factory to create mail domain enabled."""
status = enums.MailDomainStatusChoices.ENABLED
class MailDomainAccessFactory(factory.django.DjangoModelFactory):
"""A factory to create mail domain accesses."""
class Meta:
model = models.MailDomainAccess
user = factory.SubFactory(core_factories.UserFactory)
domain = factory.SubFactory(MailDomainEnabledFactory)
role = factory.fuzzy.FuzzyChoice(
[r[0] for r in enums.MailDomainRoleChoices.choices]
)
class MailboxFactory(factory.django.DjangoModelFactory):
"""A factory to create mailboxes for a mail domain."""
class Meta:
model = models.Mailbox
first_name = factory.Faker("first_name", locale="fr_FR")
last_name = factory.Faker("last_name", locale="de_DE")
local_part = factory.LazyAttribute(
lambda a: f"{slugify(a.first_name)}.{slugify(a.last_name)}"
)
domain = factory.SubFactory(MailDomainEnabledFactory)
secondary_email = factory.Faker("email")
@@ -1,148 +0,0 @@
"""Management command creating a dimail-api container, for test purposes."""
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
import requests
from rest_framework import status
User = get_user_model()
DIMAIL_URL = "http://host.docker.internal:8001"
admin = {"username": "admin", "password": "admin"}
regie = {"username": "la_regie", "password": "password"}
class Command(BaseCommand):
"""
Management command populate local dimail database, to ease dev
"""
help = "Populate local dimail database, for dev purposes."
def handle(self, *args, **options):
"""Handling of the management command."""
if not settings.DEBUG:
raise CommandError(
("This command is meant to run in local dev environment.")
)
# Create a first superuser for dimail-api container. User creation is usually
# protected behind admin rights but dimail allows to create a first user
# when database is empty
self.create_user(
auth=(None, None),
name=admin["username"],
password=admin["password"],
perms=[],
)
# Create Regie user, auth for all remaining requests
# and your own dev
self.create_user(
auth=(admin["username"], admin["password"]),
name=regie["username"],
password=regie["password"],
perms=["new_domain", "create_users", "manage_users"],
)
# we create a dimail user for keycloak+regie user John Doe
# This way, la Régie will be able to make request in the name of
# this user
people_base_user = User.objects.get(name="John Doe")
self.create_user(name=people_base_user.sub, password="whatever") # noqa S106
# we create a domain and add John Doe to it
domain_name = "test.domain.com"
self.create_domain(domain_name)
self.create_allows(people_base_user.sub, domain_name)
self.stdout.write("DONE", ending="\n")
def create_user(
self,
name,
password,
perms=None,
auth=(regie["username"], regie["password"]),
):
"""
Send a request to create a new user.
"""
response = requests.post(
url=f"{DIMAIL_URL}/users/",
json={
"name": name,
"password": password,
"is_admin": name == admin["username"],
"perms": perms or [],
},
auth=auth,
timeout=10,
)
if response.status_code == status.HTTP_201_CREATED:
self.stdout.write(self.style.SUCCESS(f"Creating user {name}......... OK"))
else:
self.stdout.write(
self.style.ERROR(
f"Creating user {name} ......... failed: {response.json()['detail']}"
)
)
def create_domain(self, name, auth=(regie["username"], regie["password"])):
"""
Send a request to create a new domain.
"""
response = requests.post(
url=f"{DIMAIL_URL}/domains/",
json={
"name": name,
"context_name": "context",
"features": ["webmail", "mailbox", "alias"],
},
auth=auth,
timeout=10,
)
if response.status_code == status.HTTP_201_CREATED:
self.stdout.write(
self.style.SUCCESS(f"Creating domain '{name}' ........ OK")
)
else:
self.stdout.write(
self.style.ERROR(
f"Creating domain '{name}' ........ failed: {response.json()['detail']}"
)
)
def create_allows(self, user, domain, auth=(regie["username"], regie["password"])):
"""
Send a request to create a new allows between user and domain.
"""
response = requests.post(
url=f"{DIMAIL_URL}/allows/",
json={
"domain": domain,
"user": user,
},
auth=auth,
timeout=10,
)
if response.status_code == status.HTTP_201_CREATED:
self.stdout.write(
self.style.SUCCESS(
f"Creating permissions for {user} on {domain} ........ OK"
)
)
else:
self.stdout.write(
self.style.ERROR(
f"Creating permissions for {user} on {domain}\
........ failed: {response.json()['detail']}"
)
)
@@ -1,67 +0,0 @@
# Generated by Django 5.0.3 on 2024-04-16 12:51
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='MailDomain',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('name', models.CharField(max_length=150, unique=True, verbose_name='name')),
],
options={
'verbose_name': 'Mail domain',
'verbose_name_plural': 'Mail domains',
'db_table': 'people_mail_domain',
},
),
migrations.CreateModel(
name='Mailbox',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('local_part', models.CharField(max_length=150, validators=[django.core.validators.RegexValidator(regex='^[a-zA-Z0-9_.+-]+$')], verbose_name='local_part')),
('secondary_email', models.EmailField(max_length=254, verbose_name='secondary email address')),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mail_domain', to='mailbox_manager.maildomain')),
],
options={
'verbose_name': 'Mailbox',
'verbose_name_plural': 'Mailboxes',
'db_table': 'people_mail_box',
'unique_together': {('local_part', 'domain')},
},
),
migrations.CreateModel(
name='MailDomainAccess',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mail_domain_accesses', to='mailbox_manager.maildomain')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mail_domain_accesses', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'User/mail domain relation',
'verbose_name_plural': 'User/mail domain relations',
'db_table': 'people_mail_domain_accesses',
'unique_together': {('user', 'domain')},
},
),
]
@@ -1,19 +0,0 @@
# Generated by Django 5.0.3 on 2024-04-17 11:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='maildomainaccess',
name='domain',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='mailbox_manager.maildomain'),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.0.6 on 2024-06-03 14:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0002_alter_maildomainaccess_domain'),
]
operations = [
migrations.AddField(
model_name='maildomain',
name='slug',
field=models.SlugField(blank=True, max_length=80),
),
]

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