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