mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-15 01:13:43 +00:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2e8b4d8cc | |||
| d040f2035d | |||
| 0c9d4af51e | |||
| 32f64409bb | |||
| f7c3dc9044 | |||
| f63f09b27e | |||
| b610e077da | |||
| 9bc712009a | |||
| 44f598a672 | |||
| 10437d6ae1 | |||
| 44bfef69ca | |||
| eb422309b9 | |||
| 4854fc0c13 | |||
| d930fa1ba6 | |||
| 6f1414b34e | |||
| 0194f7f3f3 | |||
| 0bde103911 | |||
| c7c41abcb5 | |||
| 531774fbda | |||
| 86d97b427c | |||
| b0bb5bc61b | |||
| 3773e43d93 | |||
| 0d79e6418a | |||
| 811540c85f | |||
| cee2109726 | |||
| 7fa9c3fa4f | |||
| 191e6d49d8 | |||
| 5867e079c6 | |||
| 96b28da7be | |||
| 84450b6f3a | |||
| 10ac8925fb | |||
| c2007929f4 | |||
| ddf19deb82 | |||
| 81876a10d4 | |||
| f123c79741 | |||
| deb1ef9ed6 | |||
| 080c347129 | |||
| 0450c74f53 | |||
| 2ac457c43e | |||
| e9b523b522 | |||
| 70d348337e | |||
| 258722adfd | |||
| 0a6724e7ad | |||
| c1c2d93932 | |||
| 9efb9577c4 | |||
| 2137c6b444 | |||
| aa01733f6b | |||
| 03db669e51 | |||
| 97a73bf4f2 | |||
| 267a2265d9 | |||
| 14507d57a9 | |||
| c075db25a4 | |||
| c0dfd88776 | |||
| 94d2b80c17 | |||
| bd2dfcae80 | |||
| 0b4a83c92e | |||
| 9187173cae | |||
| 364bbf4f0b | |||
| a6a12ef586 | |||
| 2622d89f63 | |||
| f2d50770cf | |||
| 11e8470aa5 | |||
| 3bf78f0f5b | |||
| ddd5e3fce1 | |||
| 5a9e1cb012 | |||
| c49cee3ab4 | |||
| bbc30de490 | |||
| 14b3395e1c | |||
| f673c07cb8 |
@@ -88,6 +88,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Create writable /data
|
- name: Create writable /data
|
||||||
run: |
|
run: |
|
||||||
@@ -159,6 +161,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install ffmpeg
|
- name: Install ffmpeg
|
||||||
run: |
|
run: |
|
||||||
@@ -186,6 +190,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd src/frontend/ && npm ci --ignore-scripts
|
run: cd src/frontend/ && npm ci --ignore-scripts
|
||||||
@@ -196,6 +202,27 @@ jobs:
|
|||||||
- name: Check format
|
- name: Check format
|
||||||
run: cd src/frontend/ && npm run check
|
run: cd src/frontend/ && npm run check
|
||||||
|
|
||||||
|
test-front:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd src/frontend/ && npm ci --ignore-scripts
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cd src/frontend/ && npm test
|
||||||
|
|
||||||
lint-sdk:
|
lint-sdk:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
@@ -206,6 +233,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --ignore-scripts
|
run: npm ci --ignore-scripts
|
||||||
@@ -227,6 +256,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --ignore-scripts
|
run: npm ci --ignore-scripts
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
|
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
|
||||||
extra-path=gitlint/
|
extra-path=gitlint/
|
||||||
|
|
||||||
|
# Use "search" semantics for regexes (gitlint's future default)
|
||||||
|
regex-style-search=True
|
||||||
|
|
||||||
# [title-max-length]
|
# [title-max-length]
|
||||||
# line-length=80
|
# line-length=80
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,55 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
|
||||||
|
- ✨(backend) add room soft-deletion to the external API
|
||||||
|
- ✨(all) allow any authenticated user to start a recording
|
||||||
|
|
||||||
|
## [1.35.0] - 2026-10-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(frontend) let users pick the recording encoding
|
||||||
|
- ✨(backend) introduce a token exchange endpoint for iframe embeds
|
||||||
|
- ⚡️(backend) send recording notification emails from a Celery task
|
||||||
|
- ✨(backend) allow static feature flags without an analytics backend #1809
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ⚡️(backend) hash application secrets with SHA-256
|
||||||
|
- ⚡️(backend) reduce domain queries on the application token endpoint
|
||||||
|
- ♻️️️(backend) use a dedicated auth scheme for LiveKit token auth
|
||||||
|
- ♻️(all) stop relying on cookies for the lobby flow
|
||||||
|
- ♿️(frontend) show a visible focus outline on menu items #1797
|
||||||
|
- ⬆️(backend) upgrade pylint to 4.1.3
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🔒️(backend) prevent editing client id and secret in Django admin
|
||||||
|
- 🔒️(frontend) upgrade tiff to fix CVE-2026-4775
|
||||||
|
- 🔒️(backend) bind accepted lobby entries to the current username
|
||||||
|
- 🔒️(backend) sign lobby participant id
|
||||||
|
- 🐛(frontend) omit the default speaker id sent to LiveKit
|
||||||
|
- 🐛(frontend) play the speaker test when no output is picked
|
||||||
|
- 🐛(frontend) keep the admin panel in place when changing a setting #1804
|
||||||
|
|
||||||
|
## [1.34.0] - 2026-10-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(helm) import environment variables from Secrets and ConfigMaps
|
||||||
- 🔒(backend) throttle meeting link generation
|
- 🔒(backend) throttle meeting link generation
|
||||||
- 🔒️(backend) add a daily cap on room creation
|
- 🔒️(backend) add a daily cap on room creation
|
||||||
|
- 🔧(summary) add setting to control Sentry traces sampling rate
|
||||||
|
- ✨(frontend) let signed-out visitors start a meeting
|
||||||
|
- ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration
|
||||||
|
- ✅(frontend) add vitest so the frontend can carry unit tests
|
||||||
|
- ♿️(frontend) make participant pagination readable and keyboard reachable #1775
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ✨(frontend) warn users when the connection falls back to TURN
|
||||||
|
- 🔧(backend) configure the technical documentation url
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -19,6 +66,13 @@ and this project adheres to
|
|||||||
- 🔒️(agents) fix util-linux CVEs reported by Cyberwatch
|
- 🔒️(agents) fix util-linux CVEs reported by Cyberwatch
|
||||||
- 🔒️(backend) fix HIGH CVEs in Django and urllib3
|
- 🔒️(backend) fix HIGH CVEs in Django and urllib3
|
||||||
- 🔒️(agents) upgrade libpcre2-8-0 to fix CVE-2026-103111
|
- 🔒️(agents) upgrade libpcre2-8-0 to fix CVE-2026-103111
|
||||||
|
- 🔒️(frontend) upgrade pcre2 to fix CVE-2026-103111
|
||||||
|
- 🐛(summary) disable default S3 checksums for GCS-compatible storage
|
||||||
|
- 🔒️(summary) redact meeting content from Sentry events
|
||||||
|
- 🐛(frontend) hide tooltips until they have a computed placement
|
||||||
|
- 🐛(brevo) use django-lasuite for marketing management
|
||||||
|
- ♿️(frontend) expose loading state to assistive technology
|
||||||
|
- 🐛(frontend) honour Keep hand raised when picture-in-picture is open
|
||||||
|
|
||||||
## [1.33.0] - 2026-09-30
|
## [1.33.0] - 2026-09-30
|
||||||
|
|
||||||
@@ -35,6 +89,7 @@ and this project adheres to
|
|||||||
- ♻️(agents) replace the minio client by boto3
|
- ♻️(agents) replace the minio client by boto3
|
||||||
- 🔧(compose) replace MinIO by Garage for local development
|
- 🔧(compose) replace MinIO by Garage for local development
|
||||||
- 🔧(helm) point media services to Garage by default
|
- 🔧(helm) point media services to Garage by default
|
||||||
|
- 💥(backend) replace recording encoding options with a profile model
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -37,14 +37,13 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||||||
uv sync --locked --no-dev
|
uv sync --locked --no-dev
|
||||||
|
|
||||||
# ---- mails ----
|
# ---- mails ----
|
||||||
FROM node:22 AS mail-builder
|
FROM node:22-alpine AS mail-builder
|
||||||
|
|
||||||
COPY ./src/mail /mail/app
|
COPY ./src/mail /mail/app
|
||||||
|
|
||||||
WORKDIR /mail/app
|
WORKDIR /mail/app
|
||||||
|
|
||||||
RUN yarn install --frozen-lockfile && \
|
RUN npm ci --ignore-scripts && npm run build
|
||||||
yarn build
|
|
||||||
|
|
||||||
|
|
||||||
# ---- static link collector ----
|
# ---- static link collector ----
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ run-summary: ## start only the summary application and all needed services
|
|||||||
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
|
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
|
||||||
.PHONY: run-summary
|
.PHONY: run-summary
|
||||||
|
|
||||||
run-agents: ## start the multi-user-transcriber agent
|
run-agents: ## start the LiveKit agents (opt-in, see docs/developping_locally.md)
|
||||||
@$(MAKE) run-agent-multi-user-transcriber
|
@$(MAKE) run-agent-multi-user-transcriber
|
||||||
@$(MAKE) run-agent-metadata-collector
|
@$(MAKE) run-agent-metadata-collector
|
||||||
.PHONY: run-agents
|
.PHONY: run-agents
|
||||||
@@ -186,7 +186,6 @@ run:
|
|||||||
run: ## start the wsgi (production) and development server
|
run: ## start the wsgi (production) and development server
|
||||||
@$(MAKE) run-backend
|
@$(MAKE) run-backend
|
||||||
@$(MAKE) run-summary
|
@$(MAKE) run-summary
|
||||||
@$(MAKE) run-agents
|
|
||||||
@$(COMPOSE) up --force-recreate -d frontend
|
@$(COMPOSE) up --force-recreate -d frontend
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
|
|
||||||
@@ -260,7 +259,8 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
|
|||||||
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
||||||
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||||
$(MAKE) test-back-parallel ARGS="$${args}" && \
|
$(MAKE) test-back-parallel ARGS="$${args}" && \
|
||||||
$(MAKE) test-summary ARGS="$${args}"
|
$(MAKE) test-summary ARGS="$${args}" && \
|
||||||
|
$(MAKE) test-frontend
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|
||||||
test-back: ## run back-end tests (pass extra pytest args via ARGS)
|
test-back: ## run back-end tests (pass extra pytest args via ARGS)
|
||||||
@@ -278,6 +278,10 @@ test-summary: ## run summary tests (pass extra pytest args via ARGS)
|
|||||||
bin/pytest-summary $${args}
|
bin/pytest-summary $${args}
|
||||||
.PHONY: test-summary
|
.PHONY: test-summary
|
||||||
|
|
||||||
|
test-frontend: ## run the frontend unit tests
|
||||||
|
cd $(PATH_FRONT) && npm test
|
||||||
|
.PHONY: test-frontend
|
||||||
|
|
||||||
makemigrations: ## run django makemigrations for the Meet project.
|
makemigrations: ## run django makemigrations for the Meet project.
|
||||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||||
@$(COMPOSE) up -d postgresql
|
@$(COMPOSE) up -d postgresql
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/meet"/>
|
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/meet"/>
|
||||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
|
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
|
||||||
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
|
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
|
||||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
<img alt="GitHub license" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://digitalpublicgoods.net/r/la-suite-meet-simple-video-conferencing">
|
<a href="https://digitalpublicgoods.net/r/la-suite-meet-simple-video-conferencing">
|
||||||
<img src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="DPG Badge"/>
|
<img src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="DPG Badge"/>
|
||||||
@@ -49,8 +49,8 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
|
|||||||
- Telephony integration
|
- Telephony integration
|
||||||
- Secure participation with robust authentication and access control
|
- Secure participation with robust authentication and access control
|
||||||
- Customizable frontend style
|
- Customizable frontend style
|
||||||
- LiveKit Advances features including :
|
- LiveKit advanced features including:
|
||||||
- speaker detection
|
- speaker detection
|
||||||
- simulcast
|
- simulcast
|
||||||
- end-to-end optimizations
|
- end-to-end optimizations
|
||||||
- selective subscription
|
- selective subscription
|
||||||
|
|||||||
+188
-1
@@ -16,6 +16,193 @@ the following command inside your docker container:
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Purging deleted rooms
|
||||||
|
|
||||||
|
Deleting a room now only soft deletes it: it is hidden but keeps its slug and PIN code. The `purge_inactive_rooms` command also permanently deletes soft-deleted rooms. See [the room purge documentation](docs/features/room-purge.md).
|
||||||
|
|
||||||
|
- Inactive soft-deleted rooms are purged along with the other inactive rooms: if `ROOM_INACTIVITY_DELETION_DAYS` is already set, nothing else is needed.
|
||||||
|
- The new `ROOM_DELETED_RETENTION_DAYS` setting purges soft-deleted rooms that many days after their deletion, whether they are inactive or not. It is unset by default.
|
||||||
|
- As for inactive rooms, a deleted room holding a saved recording its users may still access is kept until that recording expires.
|
||||||
|
|
||||||
|
### Recording permission
|
||||||
|
|
||||||
|
We've introduced the ability for any authenticated participant to start a recording or transcription, but this behavior remains configurable.
|
||||||
|
You can keep recording and transcription restricted to room admins and owners by setting `RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED` to `false`.
|
||||||
|
|
||||||
|
## [v1.34.0]
|
||||||
|
|
||||||
|
### Marketing / Brevo integration now uses `django-lasuite`
|
||||||
|
|
||||||
|
The in-house marketing service (`core.services.marketing`) has been removed and
|
||||||
|
replaced by the shared implementation from `django-lasuite`
|
||||||
|
(`lasuite.marketing`). This fixes a bug where updating a user's contact on
|
||||||
|
Brevo overwrote their list memberships, removing lists set by other
|
||||||
|
La Suite products. Existing lists are now preserved and merged.
|
||||||
|
|
||||||
|
**Celery worker required.** Newsletter signup on login
|
||||||
|
(`SIGNUP_NEW_USER_TO_MARKETING_EMAIL=True`) is now dispatched as an
|
||||||
|
asynchronous Celery task (`lasuite.marketing.tasks.create_or_update_contact`)
|
||||||
|
instead of a synchronous call with a 1s timeout. Make sure a Celery worker is
|
||||||
|
running alongside the backend, otherwise contacts will never be pushed to Brevo.
|
||||||
|
|
||||||
|
**Configuration changes.** The following environment variables / settings are
|
||||||
|
**removed** and no longer read:
|
||||||
|
|
||||||
|
- `MARKETING_SERVICE_CLASS`
|
||||||
|
- `BREVO_API_KEY`
|
||||||
|
- `BREVO_API_CONTACT_LIST_IDS`
|
||||||
|
- `BREVO_API_CONTACT_ATTRIBUTES` (previous default: `{"VISIO_USER": True}`)
|
||||||
|
- `BREVO_API_TIMEOUT`
|
||||||
|
|
||||||
|
They are replaced by a single `LASUITE_MARKETING` setting, configured through:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| ------------------------------ | ------------------------------------------------ | -------------------------------------------- |
|
||||||
|
| `LASUITE_MARKETING_BACKEND` | `lasuite.marketing.backends.dummy.DummyBackend` | Backend class path |
|
||||||
|
| `LASUITE_MARKETING_PARAMETERS` | `{}` | Keyword arguments passed to the backend |
|
||||||
|
|
||||||
|
⚠️ The default backend is now a **dummy** (no-op). If you previously used
|
||||||
|
Brevo, you must explicitly configure it, otherwise signups are silently dropped:
|
||||||
|
|
||||||
|
LASUITE_MARKETING_BACKEND=lasuite.marketing.backends.brevo.BrevoBackend
|
||||||
|
LASUITE_MARKETING_PARAMETERS={"api_key": "<your-brevo-api-key>", "api_contact_list_ids": [1, 2], "api_contact_attributes": {"VISIO_USER": True}}
|
||||||
|
|
||||||
|
Migration mapping:
|
||||||
|
|
||||||
|
- `BREVO_API_KEY` → `api_key`
|
||||||
|
- `BREVO_API_CONTACT_LIST_IDS` → `api_contact_list_ids`
|
||||||
|
- `BREVO_API_CONTACT_ATTRIBUTES` → `api_contact_attributes` (re-add
|
||||||
|
`{"VISIO_USER": True}` if you relied on the old default)
|
||||||
|
- `BREVO_API_TIMEOUT` → no equivalent (the request runs in a background task)
|
||||||
|
|
||||||
|
Note: `BREVO_API_KEY` used to support being read from a secret file; the API key
|
||||||
|
now lives inside `LASUITE_MARKETING_PARAMETERS`, so adapt how you inject that
|
||||||
|
secret (e.g. build the whole variable from your secret store).
|
||||||
|
|
||||||
|
### Recording encoding settings replaced by a resolution/profile model
|
||||||
|
|
||||||
|
The `RECORDING_ENCODING_*` settings introduced in v1.16.0 exposed raw encoder
|
||||||
|
values (width, height, framerate, bitrate). They are replaced by two named and configurable sets of
|
||||||
|
dimensions, a **resolution** (default: `540p`, `720p`, `1080p`) and a **profile**
|
||||||
|
(default: `talking_heads`, `text`, `mixed`, `full`), which are resolved to the width, height,
|
||||||
|
fps and video bitrate.
|
||||||
|
|
||||||
|
**The following environment variables are no longer read. If they are still set in
|
||||||
|
your deployment they are silently ignored, and your recordings will be encoded with
|
||||||
|
the new defaults instead of your tuned values.**
|
||||||
|
|
||||||
|
| Removed variable | Replaced by |
|
||||||
|
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `RECORDING_ENCODING_ENABLED` | Nothing. A default encoding is now always built (see below). **Not** `RECORDING_CUSTOM_ENCODING_ENABLED`, which gates a different feature. |
|
||||||
|
| `RECORDING_ENCODING_WIDTH` | The `width` of the entry selected by `RECORDING_ENCODING_DEFAULT_RESOLUTION` in `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. |
|
||||||
|
| `RECORDING_ENCODING_HEIGHT` | The `height` of that same entry. |
|
||||||
|
| `RECORDING_ENCODING_FRAMERATE` | The `fps` of the profile selected by `RECORDING_ENCODING_DEFAULT_PROFILE` in `RECORDING_ENCODING_AVAILABLE_PROFILES`. |
|
||||||
|
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | That profile's `kbps`. |
|
||||||
|
|
||||||
|
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
|
||||||
|
keep their names and meaning. The keyframe interval now defaults to `0` (unset,
|
||||||
|
encoder's choice) instead of `4.0`.
|
||||||
|
|
||||||
|
#### If you never set `RECORDING_ENCODING_ENABLED=True`
|
||||||
|
|
||||||
|
The shipped defaults (`RECORDING_ENCODING_DEFAULT_PROFILE=full`,
|
||||||
|
`RECORDING_ENCODING_DEFAULT_RESOLUTION=720p`) match LiveKit's built-in
|
||||||
|
`H264_720P_30` preset: 1280×720, 30 fps, 3000 kbps H.264 MAIN, 128 kbps AAC.
|
||||||
|
Video output is therefore unchanged.
|
||||||
|
|
||||||
|
Audio and keyframing may not be. These values are now sent explicitly as advanced
|
||||||
|
`EncodingOptions` rather than relying on LiveKit's preset, so
|
||||||
|
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
|
||||||
|
now apply to every recording. They previously applied only when
|
||||||
|
`RECORDING_ENCODING_ENABLED` was `True`. **If you set either of them while the
|
||||||
|
feature was disabled, they had no effect and now do**; check them before upgrading.
|
||||||
|
|
||||||
|
If you never set them, no action is required: 128 kbps AAC is what the preset used,
|
||||||
|
and the keyframe interval now defaults to `0`, which leaves the field unset so the
|
||||||
|
encoder keeps picking it as before. Set `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0`
|
||||||
|
if you want fixed 4-second keyframes (the value the setting defaulted to while it
|
||||||
|
was gated behind `RECORDING_ENCODING_ENABLED`).
|
||||||
|
|
||||||
|
To keep letting LiveKit pick the encoding instead, set either default to an empty
|
||||||
|
value:
|
||||||
|
|
||||||
|
```
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE=
|
||||||
|
```
|
||||||
|
|
||||||
|
#### If you had tuned `RECORDING_ENCODING_*` values
|
||||||
|
|
||||||
|
Translate your old values into a default resolution and a default profile. Declare your own resolution and/or profile. Both maps are read from the
|
||||||
|
environment as a single-line Python/JSON dict literal (parsed with
|
||||||
|
`ast.literal_eval`, so use double-quoted keys and no trailing commas, and do not
|
||||||
|
add outer quotes in `.env`-style files):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
|
||||||
|
RECORDING_ENCODING_AVAILABLE_PROFILES={"my_old_profile": {"fps": 15, "kbps": {"540p": 350, "720p": 600, "1080p": 1100}}}
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE=my_old_profile
|
||||||
|
```
|
||||||
|
|
||||||
|
Both maps are validated at startup and a malformed one raises a `ValueError`:
|
||||||
|
|
||||||
|
- every entry of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` must declare `width` and
|
||||||
|
`height`, and every entry of `RECORDING_ENCODING_AVAILABLE_PROFILES` an `fps` and a
|
||||||
|
`kbps` map;
|
||||||
|
- every profile must define a `kbps` entry for **exactly** the keys of
|
||||||
|
`RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`; overriding one of the two maps usually
|
||||||
|
means overriding both;
|
||||||
|
- `RECORDING_ENCODING_DEFAULT_RESOLUTION` and `RECORDING_ENCODING_DEFAULT_PROFILE`,
|
||||||
|
when non-empty, must be keys of their respective map.
|
||||||
|
|
||||||
|
#### Breaking: custom worker services must accept `encoding_options`
|
||||||
|
|
||||||
|
Only concerns deployments pointing `RECORDING_WORKER_CLASSES` at their own worker
|
||||||
|
class. The shipped `VideoCompositeEgressService` and `AudioCompositeEgressService`
|
||||||
|
are already updated.
|
||||||
|
|
||||||
|
The `WorkerService` protocol's `start()` takes a third argument, and the mediator
|
||||||
|
now always passes it as a keyword when the recording carries no per-recording encoding:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# before
|
||||||
|
def start(self, room_id: str, recording_id: str) -> str: ...
|
||||||
|
|
||||||
|
# now
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
room_id: str,
|
||||||
|
recording_id: str,
|
||||||
|
encoding_options: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> str: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Optional: per-recording encoding
|
||||||
|
|
||||||
|
`RECORDING_CUSTOM_ENCODING_ENABLED` (default `False`) toggles whether the
|
||||||
|
start-recording API accepts an `encoding` object
|
||||||
|
(`{"resolution": "720p", "profile": "talking_heads"}`, `profile` optional. It
|
||||||
|
falls back to `RECORDING_ENCODING_DEFAULT_PROFILE`) that overrides the default for
|
||||||
|
a single recording. It does not enable or disable the
|
||||||
|
default encoding, which is built from the two `RECORDING_ENCODING_DEFAULT_*`
|
||||||
|
settings either way. Leaving it at `False` preserves the previous behaviour, where
|
||||||
|
every recording uses the server-side encoding: requests carrying
|
||||||
|
`options.encoding` are rejected with a `400` before the recording is created, so
|
||||||
|
nothing is persisted and no egress is started.
|
||||||
|
|
||||||
|
Before enabling it:
|
||||||
|
|
||||||
|
- clients can only pick keys you declared; there is no way to send a raw width or bitrate
|
||||||
|
- as of this implementation, the frontend never sends `encoding`
|
||||||
|
- `encoding` is accepted but ignored for `transcript` recordings, whose audio-only
|
||||||
|
egress has no video encoding to configure.
|
||||||
|
|
||||||
|
See [docs/features/recording.md](docs/features/recording.md#tuning-recording-encoding)
|
||||||
|
for the full setting reference, the shipped profile table and the tuning caveats.
|
||||||
|
|
||||||
|
## v1.33.0
|
||||||
|
|
||||||
### Purging inactive rooms
|
### Purging inactive rooms
|
||||||
|
|
||||||
Rooms now keep track of the last time they were started (`last_started_at`), fed by LiveKit's `room_started` webhook. A new `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for `ROOM_INACTIVITY_DELETION_DAYS` days. See [the room purge documentation](docs/features/room-purge.md).
|
Rooms now keep track of the last time they were started (`last_started_at`), fed by LiveKit's `room_started` webhook. A new `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for `ROOM_INACTIVITY_DELETION_DAYS` days. See [the room purge documentation](docs/features/room-purge.md).
|
||||||
@@ -44,7 +231,7 @@ To migrate a local environment:
|
|||||||
### Summary service and metadata collector: boto3 replaces the minio client
|
### Summary service and metadata collector: boto3 replaces the minio client
|
||||||
|
|
||||||
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings.
|
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings.
|
||||||
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`, then to `us-east-1`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
|
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
|
||||||
|
|
||||||
Also:
|
Also:
|
||||||
- Signed URLs to transcripts and summaries are now always path-style (`<endpoint>/<bucket>/<key>`), whereas the minio client used virtual-hosted-style URLs
|
- Signed URLs to transcripts and summaries are now always path-style (`<endpoint>/<bucket>/<key>`), whereas the minio client used virtual-hosted-style URLs
|
||||||
|
|||||||
+9
-8
@@ -104,15 +104,16 @@ k8s_yaml(secret_yaml_generic(
|
|||||||
|
|
||||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
|
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
|
||||||
|
|
||||||
k8s_resource('garage-cors', resource_deps=['garage'])
|
k8s_resource('dev-backend-garage-cors', resource_deps=['dev-backend-garage'])
|
||||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'garage-cors', 'redis', 'livekit-livekit-server'])
|
k8s_resource('dev-backend-keycloak', resource_deps=['dev-backend-keycloak-pg'])
|
||||||
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
k8s_resource('meet-backend', resource_deps=['dev-backend-postgres', 'dev-backend-garage-cors', 'dev-backend-redis', 'dev-backend-keycloak', 'livekit-livekit-server'])
|
||||||
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
k8s_resource('meet-celery-backend', resource_deps=['dev-backend-redis'])
|
||||||
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
|
k8s_resource('meet-celery-summarize', resource_deps=['dev-backend-redis'])
|
||||||
k8s_resource('meet-celery-transcribe-default', resource_deps=['redis'])
|
k8s_resource('meet-celery-summary-backend', resource_deps=['dev-backend-redis'])
|
||||||
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
k8s_resource('meet-celery-transcribe-default', resource_deps=['dev-backend-redis'])
|
||||||
|
k8s_resource('livekit-livekit-server', resource_deps=['dev-backend-redis'])
|
||||||
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
||||||
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
|
k8s_resource('livekit-egress', resource_deps=['livekit-livekit-server'])
|
||||||
# Trigger once on launch
|
# Trigger once on launch
|
||||||
k8s_resource(
|
k8s_resource(
|
||||||
'meet-backend-createsuperuser',
|
'meet-backend-createsuperuser',
|
||||||
|
|||||||
+19
-21
@@ -5,15 +5,17 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- env.d/development/postgresql
|
- env.d/development/postgresql
|
||||||
ports:
|
ports:
|
||||||
- "15432:5432"
|
- "127.0.0.1:15432:5432"
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:5
|
image: redis:5
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:6379:6379"
|
||||||
|
|
||||||
mailcatcher:
|
mailcatcher:
|
||||||
image: sj26/mailcatcher:latest
|
image: sj26/mailcatcher:latest
|
||||||
ports:
|
ports:
|
||||||
- "1081:1080"
|
- "127.0.0.1:1081:1080"
|
||||||
|
|
||||||
garage:
|
garage:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
@@ -69,7 +71,7 @@ services:
|
|||||||
- env.d/development/common
|
- env.d/development/common
|
||||||
- env.d/development/postgresql
|
- env.d/development/postgresql
|
||||||
ports:
|
ports:
|
||||||
- "8071:8000"
|
- "127.0.0.1:8071:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/backend:/app
|
- ./src/backend:/app
|
||||||
- ./data/static:/data/static
|
- ./data/static:/data/static
|
||||||
@@ -135,7 +137,7 @@ services:
|
|||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.25
|
image: nginx:1.25
|
||||||
ports:
|
ports:
|
||||||
- "8083:8083"
|
- "127.0.0.1:8083:8083"
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -153,10 +155,11 @@ services:
|
|||||||
target: frontend-production
|
target: frontend-production
|
||||||
args:
|
args:
|
||||||
VITE_API_BASE_URL: "http://localhost:8071"
|
VITE_API_BASE_URL: "http://localhost:8071"
|
||||||
|
VITE_MEDIA_BASE_URL: "http://localhost:8083"
|
||||||
VITE_APP_TITLE: "LaSuite Meet"
|
VITE_APP_TITLE: "LaSuite Meet"
|
||||||
image: meet:frontend-development
|
image: meet:frontend-development
|
||||||
ports:
|
ports:
|
||||||
- "3000:8080"
|
- "127.0.0.1:3000:8080"
|
||||||
|
|
||||||
dockerize:
|
dockerize:
|
||||||
image: jwilder/dockerize
|
image: jwilder/dockerize
|
||||||
@@ -172,7 +175,7 @@ services:
|
|||||||
working_dir: /app
|
working_dir: /app
|
||||||
|
|
||||||
node:
|
node:
|
||||||
image: node:22
|
image: node:22-alpine
|
||||||
user: "${DOCKER_USER:-1000}"
|
user: "${DOCKER_USER:-1000}"
|
||||||
environment:
|
environment:
|
||||||
HOME: /tmp
|
HOME: /tmp
|
||||||
@@ -182,7 +185,7 @@ services:
|
|||||||
kc_postgresql:
|
kc_postgresql:
|
||||||
image: postgres:14.3
|
image: postgres:14.3
|
||||||
ports:
|
ports:
|
||||||
- "5433:5432"
|
- "127.0.0.1:5433:5432"
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/kc_postgresql
|
- env.d/development/kc_postgresql
|
||||||
|
|
||||||
@@ -210,7 +213,7 @@ services:
|
|||||||
KC_DB_SCHEMA: public
|
KC_DB_SCHEMA: public
|
||||||
PROXY_ADDRESS_FORWARDING: 'true'
|
PROXY_ADDRESS_FORWARDING: 'true'
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
- kc_postgresql
|
- kc_postgresql
|
||||||
|
|
||||||
@@ -219,10 +222,10 @@ services:
|
|||||||
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
|
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
|
||||||
ports:
|
ports:
|
||||||
- "7880:7880"
|
- "7880:7880"
|
||||||
- "7881:7881"
|
- "127.0.0.1:7881:7881"
|
||||||
- "7882:7882/udp"
|
- "127.0.0.1:7882:7882/udp"
|
||||||
- "3478:3478/udp"
|
- "127.0.0.1:3478:3478/udp"
|
||||||
- "30000-30100:30000-30100/udp"
|
- "127.0.0.1:30000-30100:30000-30100/udp"
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -270,11 +273,6 @@ services:
|
|||||||
- ./src/agents:/app
|
- ./src/agents:/app
|
||||||
- /app/.venv
|
- /app/.venv
|
||||||
|
|
||||||
redis-summary:
|
|
||||||
image: redis
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
|
|
||||||
app-summary-dev:
|
app-summary-dev:
|
||||||
build:
|
build:
|
||||||
context: src/summary
|
context: src/summary
|
||||||
@@ -285,11 +283,11 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- env.d/development/summary
|
- env.d/development/summary
|
||||||
ports:
|
ports:
|
||||||
- "8001:8000"
|
- "127.0.0.1:8001:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/summary:/app
|
- ./src/summary:/app
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis-summary
|
- redis
|
||||||
|
|
||||||
celery-summary-transcribe:
|
celery-summary-transcribe:
|
||||||
container_name: celery-summary-transcribe
|
container_name: celery-summary-transcribe
|
||||||
@@ -303,7 +301,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./src/summary:/app
|
- ./src/summary:/app
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis-summary
|
- redis
|
||||||
- app-summary-dev
|
- app-summary-dev
|
||||||
- garage
|
- garage
|
||||||
develop:
|
develop:
|
||||||
@@ -323,7 +321,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./src/summary:/app
|
- ./src/summary:/app
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis-summary
|
- redis
|
||||||
- app-summary-dev
|
- app-summary-dev
|
||||||
- garage
|
- garage
|
||||||
develop:
|
develop:
|
||||||
|
|||||||
@@ -45,4 +45,4 @@ services:
|
|||||||
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
|
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
|
||||||
keycloak:
|
keycloak:
|
||||||
ports: !override
|
ports: !override
|
||||||
- "8081:8080"
|
- "127.0.0.1:8081:8080"
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
|||||||
|
|
||||||
USER root
|
USER root
|
||||||
RUN apk upgrade --no-cache libexpat && \
|
RUN apk upgrade --no-cache libexpat && \
|
||||||
|
apk add --no-cache --upgrade 'pcre2>=10.49-r0' 'tiff>=4.7.2-r0' && \
|
||||||
apk del curl
|
apk del curl
|
||||||
USER nginx
|
USER nginx
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,36 @@ server {
|
|||||||
server_name localhost;
|
server_name localhost;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
|
||||||
|
# Proxy auth for recordings (authorized by the recordings viewset)
|
||||||
|
location /media/recordings/ {
|
||||||
|
auth_request /media-auth-recordings;
|
||||||
|
auth_request_set $authHeader $upstream_http_authorization;
|
||||||
|
auth_request_set $authDate $upstream_http_x_amz_date;
|
||||||
|
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
||||||
|
|
||||||
|
proxy_set_header Authorization $authHeader;
|
||||||
|
proxy_set_header X-Amz-Date $authDate;
|
||||||
|
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
||||||
|
|
||||||
|
proxy_pass http://garage:9000/meet-media-storage/recordings/;
|
||||||
|
proxy_set_header Host garage:9000;
|
||||||
|
proxy_hide_header Content-Disposition;
|
||||||
|
add_header Content-Disposition "attachment";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /media-auth-recordings {
|
||||||
|
internal;
|
||||||
|
proxy_pass http://app-dev:8000/api/v1.0/recordings/media-auth/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Original-URL $request_uri;
|
||||||
|
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_set_header X-Original-Method $request_method;
|
||||||
|
}
|
||||||
|
|
||||||
# Proxy auth for media
|
# Proxy auth for media
|
||||||
location /media/ {
|
location /media/ {
|
||||||
# Auth request configuration
|
# Auth request configuration
|
||||||
|
|||||||
@@ -107,6 +107,37 @@ $ npm i
|
|||||||
$ npm run dev
|
$ npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### LiveKit agents (optional)
|
||||||
|
|
||||||
|
The LiveKit agents are not started by `make run`. Each one runs its own
|
||||||
|
container and stays connected to LiveKit, which costs CPU and memory you
|
||||||
|
don't need unless you work on the features they power. Start them only
|
||||||
|
when you need them.
|
||||||
|
|
||||||
|
| Agent | Feature | Make command | Setting in `env.d/development/common` |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `metadata-collector-dev` | Recording metadata (used to identify speakers in transcripts) | `make run-agent-metadata-collector` | `METADATA_COLLECTOR_ENABLED=True` |
|
||||||
|
| `multi-user-transcriber-dev` | Live subtitles | `make run-agent-multi-user-transcriber` | `ROOM_SUBTITLE_ENABLED=True` |
|
||||||
|
|
||||||
|
To start both at once:
|
||||||
|
```shellscript
|
||||||
|
$ make run-agents
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set the matching settings to `True` and restart the backend so it
|
||||||
|
picks them up:
|
||||||
|
```shellscript
|
||||||
|
$ make run-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
The multi-user transcriber also needs a speech-to-text provider. Configure
|
||||||
|
`STT_PROVIDER` and its credentials in
|
||||||
|
`env.d/development/multi_user_transcriber`.
|
||||||
|
|
||||||
|
Keep the settings and the agents in sync: if a setting is `True` while its
|
||||||
|
agent is stopped, the backend still dispatches jobs to it and the feature
|
||||||
|
fails silently.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Adding Content
|
## Adding Content
|
||||||
|
|||||||
+43
-35
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
La Suite Meet offers a room recording feature that is currently in beta, with ongoing improvements planned.
|
La Suite Meet offers a room recording feature that is currently in beta, with ongoing improvements planned.
|
||||||
|
|
||||||
The feature allows users to record their room sessions. When a recording is complete, the room owner receives a notification with a link to download the recorded file. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
|
The feature allows users to record their room sessions. When a recording is complete, the user who started it and the room's owners and administrators receive a notification with a link to download the recorded file. The transcript summary is sent to the user who started the recording. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
|
||||||
|
|
||||||
It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
|
It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
|
||||||
|
|
||||||
@@ -89,17 +89,18 @@ sequenceDiagram
|
|||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
| --------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
| --------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
|
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
|
||||||
|
| **RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED**| Boolean | `True` | When enabled, any authenticated participant currently connected to a room with the `trusted` or `public` access level can start and stop recordings. When disabled, only room admins and owners can. On `restricted` rooms, only admins and owners can record. |
|
||||||
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
|
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
|
||||||
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
||||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||||
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
||||||
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
|
| **RECORDING_CUSTOM_ENCODING_ENABLED** | Boolean | `False` | Whether the start-recording API accepts a per-recording `encoding` object (resolution/profile) that overrides the default. When `False`, the API rejects per-recording `encoding`; when `True`, clients may pick from the available resolutions/profiles. The default encoding below is applied regardless of this flag. See [Tuning recording encoding](#tuning-recording-encoding). |
|
||||||
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_AVAILABLE_RESOLUTIONS** | Dict | `{"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}` | Maps a resolution name to its `{"width", "height"}` in pixels. Both the default encoding and the per-recording start-recording API pick from these keys. |
|
||||||
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_AVAILABLE_PROFILES** | Dict | `{"full": {"fps": 30, "kbps": {…}}, …}` | Maps a profile name to `{"fps", "kbps": {resolution: video_bitrate_kbps}}`. Every profile must define a bitrate for each available resolution (validated at startup). |
|
||||||
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_DEFAULT_RESOLUTION** | String | `"720p"` | Resolution used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. Leave unset (together with, or instead of, the default profile) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
|
||||||
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_DEFAULT_PROFILE** | String | `"full"` | Profile used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_PROFILES`. Leave unset (together with, or instead of, the default resolution) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
|
||||||
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps used in the default encoding. |
|
||||||
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `0.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `0` leaves the field unset, letting the encoder pick; `4.0` is a standard VOD value. |
|
||||||
|
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
@@ -130,52 +131,59 @@ This allows you to verify which recordings are in progress, troubleshoot egress
|
|||||||
|
|
||||||
## Tuning recording encoding
|
## Tuning recording encoding
|
||||||
|
|
||||||
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing.
|
Every video recording is encoded from a default resolved from `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` and passed to LiveKit as advanced `EncodingOptions`. The shipped defaults (`full` profile) match LiveKit's built-in `H264_720P_30` preset. For a one-hour meeting that produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing; lowering the default profile/resolution shrinks it. If either default is left unset, no custom default encoding is built: a warning is logged at startup and LiveKit's built-in preset is used instead.
|
||||||
|
|
||||||
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives.
|
Encoding is chosen from two maps: `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` (`resolution → {"width", "height"}`) and `RECORDING_ENCODING_AVAILABLE_PROFILES` (`profile → {"fps", "kbps": {resolution: video_bitrate_kbps}}`):
|
||||||
|
|
||||||
|
- **Default**: `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` set the encoding used by every recording that doesn't override it. Leave either unset to fall back to LiveKit's built-in preset (a startup warning is emitted).
|
||||||
|
- **Per recording (opt-in)**: set `RECORDING_CUSTOM_ENCODING_ENABLED=True` to let clients override the default per recording. The start-recording API then accepts an `encoding` object selecting a `resolution` (required) and `profile` (optional): a resolution-only request keeps `RECORDING_ENCODING_DEFAULT_PROFILE` for fps and bitrate, so clients can only pick from pre-defined values. When `RECORDING_CUSTOM_ENCODING_ENABLED=False`, the API rejects any per-recording `encoding` and the default is used.
|
||||||
|
|
||||||
|
The resolved values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what the profile/resolution resolve to is what the encoder receives.
|
||||||
|
|
||||||
### How values map to GStreamer
|
### How values map to GStreamer
|
||||||
|
|
||||||
| Setting | GStreamer element | Property |
|
| Resolved value | GStreamer element | Property |
|
||||||
| ------------------------------------- | ----------------- | ---------------------------------- |
|
| ----------------------------------------- | ----------------- | ---------------------------------- |
|
||||||
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
|
| resolution `width` / `height` | capsfilter | `video/x-raw,width=W,height=H` |
|
||||||
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
|
| profile `fps` | capsfilter | `framerate=F/1` |
|
||||||
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
|
| profile `kbps[resolution]` | `x264enc` | `bitrate=kbps` (kilobits) |
|
||||||
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
|
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
|
||||||
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
|
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
|
||||||
|
|
||||||
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
|
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
|
||||||
|
|
||||||
### Reference profiles
|
### Built-in profiles
|
||||||
|
|
||||||
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware.
|
The default `RECORDING_ENCODING_AVAILABLE_PROFILES` ship four profiles. Framerate is fixed per profile; video bitrate (kbps) scales with resolution so quality stays consistent across sizes. File size scales roughly with `framerate × bitrate`, and so does egress CPU cost.
|
||||||
|
|
||||||
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
|
| Profile | FPS | 540p (kbps) | 720p (kbps) | 1080p (kbps) | Suitable for |
|
||||||
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
|
| --------------- | --- | ----------- | ----------- | ------------ | -------------------------------------------------- |
|
||||||
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
|
| `talking_heads` | 15 | 400 | 700 | 1200 | Talking-head dominant meetings + occasional slides |
|
||||||
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
|
| `text` | 15 | 600 | 1000 | 1800 | Frequent dense screen sharing (decks, IDE, docs) |
|
||||||
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
|
| `mixed` | 20 | 900 | 1500 | 2500 | Mixed content, moderate motion |
|
||||||
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
|
| `full` | 30 | 2000 | 3000 | 4500 | Highest fidelity; closest to the LiveKit default preset |
|
||||||
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
|
|
||||||
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
|
|
||||||
|
|
||||||
★ Recommended starting point for typical LaSuite Meet usage.
|
To pick a profile per recording (requires `RECORDING_CUSTOM_ENCODING_ENABLED=True`), the client sends it in the start-recording request:
|
||||||
|
|
||||||
Environment variables for the **Low CPU / small file** profile:
|
```json
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To change the default encoding applied to every recording:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RECORDING_ENCODING_ENABLED=True
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||||
RECORDING_ENCODING_WIDTH=1280
|
RECORDING_ENCODING_DEFAULT_PROFILE=talking_heads
|
||||||
RECORDING_ENCODING_HEIGHT=720
|
|
||||||
RECORDING_ENCODING_FRAMERATE=15
|
|
||||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
|
|
||||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
|
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
|
||||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Caveats
|
### Caveats
|
||||||
|
|
||||||
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame).
|
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The `talking_heads` profile (700 kbps × 15 fps) sits just above that threshold, comfortable for talking heads with occasional slide sharing. The same bitrate at 30 fps would only deliver ~23 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **`text`** profile (1000 kbps × 15 fps ≈ 67 kbits/frame).
|
||||||
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
|
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
|
||||||
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
|
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
|
||||||
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
|
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Room purge
|
# Room purge
|
||||||
|
|
||||||
Rooms pile up over time and most of them are only used once. The `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for a configurable number of days. It is disabled by default.
|
Rooms pile up over time and most of them are only used once. The `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for a configurable number of days, and the rooms deleted by their owner for longer than a configurable retention period. It is disabled by default.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -12,16 +12,21 @@ A room is inactive when:
|
|||||||
|
|
||||||
Rooms that existed before this feature was deployed are considered started on the day of the release, so none of them can be purged before a full inactivity period has elapsed.
|
Rooms that existed before this feature was deployed are considered started on the day of the release, so none of them can be purged before a full inactivity period has elapsed.
|
||||||
|
|
||||||
The command is meant to run once a day. The Helm chart schedules it in `backend.cronjobs` (`purge-inactive-rooms`, 01:00); it does nothing until `ROOM_INACTIVITY_DELETION_DAYS` is set.
|
Deleting a room through the API only soft deletes it: the room is hidden, its link tells visitors the meeting was deleted, and it keeps its slug and PIN code. A soft-deleted room is purged:
|
||||||
|
|
||||||
|
- when it is inactive, as described above: a deleted room is never considered started again, so its inactivity period keeps running, or
|
||||||
|
- when it was deleted more than `ROOM_DELETED_RETENTION_DAYS` days ago.
|
||||||
|
|
||||||
|
The command is meant to run once a day. The Helm chart schedules it in `backend.cronjobs` (`purge-inactive-rooms`, 01:00); it does nothing until `ROOM_INACTIVITY_DELETION_DAYS` or `ROOM_DELETED_RETENTION_DAYS` is set.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python manage.py purge_inactive_rooms # delete the inactive rooms
|
python manage.py purge_inactive_rooms # delete the inactive and deleted rooms
|
||||||
python manage.py purge_inactive_rooms --dry-run # only list the rooms that would be deleted
|
python manage.py purge_inactive_rooms --dry-run # only list the rooms that would be deleted
|
||||||
```
|
```
|
||||||
|
|
||||||
## Rooms that are kept
|
## Rooms that are kept
|
||||||
|
|
||||||
A recording can only be reached through its room. An inactive room is kept as long as it holds a saved recording its users may still access:
|
A recording can only be reached through its room. A room is kept, past its inactivity or retention period, as long as it holds a saved recording its users may still access:
|
||||||
|
|
||||||
- with `RECORDING_EXPIRATION_DAYS` set, a saved recording created less than that many days ago,
|
- with `RECORDING_EXPIRATION_DAYS` set, a saved recording created less than that many days ago,
|
||||||
- with `RECORDING_EXPIRATION_DAYS` unset, any saved recording.
|
- with `RECORDING_EXPIRATION_DAYS` unset, any saved recording.
|
||||||
@@ -33,7 +38,8 @@ The room is deleted from the database, along with its accesses, its telephony PI
|
|||||||
The recording **files in the bucket are left untouched**: the backend never deletes anything from the storage, it only drops the database entries pointing at it. Removing the files is the job of the bucket lifecycle policy, which should match `RECORDING_EXPIRATION_DAYS` (see the [recording documentation](recording.md)). When the two do not match, the purge leaves objects behind: they become unreachable, since serving a recording requires its database entry, but they keep costing storage.
|
The recording **files in the bucket are left untouched**: the backend never deletes anything from the storage, it only drops the database entries pointing at it. Removing the files is the job of the bucket lifecycle policy, which should match `RECORDING_EXPIRATION_DAYS` (see the [recording documentation](recording.md)). When the two do not match, the purge leaves objects behind: they become unreachable, since serving a recording requires its database entry, but they keep costing storage.
|
||||||
|
|
||||||
⚠️ When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again
|
⚠️ When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again
|
||||||
and can be reused when a meeting is created from that same URL.
|
and can be reused when a meeting is created from that same URL. This also applies to a soft-deleted room: once purged, its link
|
||||||
|
no longer tells visitors the meeting was deleted, and its PIN code can be given to another room.
|
||||||
|
|
||||||
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
|
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
|
||||||
|
|
||||||
|
|||||||
@@ -113,12 +113,33 @@ DJANGO_EMAIL_FROM=<your email address>
|
|||||||
|
|
||||||
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
||||||
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||||
|
#DJANGO_EMAIL_TIMEOUT=30 # Seconds before a stalled SMTP connection times out.
|
||||||
|
|
||||||
|
|
||||||
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
|
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
|
||||||
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
|
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Feature flags without an analytics backend
|
||||||
|
|
||||||
|
Some features, such as automatic meeting summaries (`summary-enabled`), are gated by a feature flag read from the analytics backend. You do not need an analytics backend to enable them: leave `ANALYTICS_BACKEND` unset and declare the flags in `env.d/common`. No data is sent anywhere.
|
||||||
|
|
||||||
|
```env
|
||||||
|
ANALYTICS_BACKEND_SETTINGS={"feature_flags": {"summary-enabled": True}}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also set it directly in `compose.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
ANALYTICS_BACKEND_SETTINGS: '{"feature_flags": {"summary-enabled": True}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The value is a Python dictionary, not JSON: write `True`, not `true`. Set it on every container running the backend image.
|
||||||
|
|
||||||
## Step 3: Configure your firewall
|
## Step 3: Configure your firewall
|
||||||
|
|
||||||
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
|
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ These are the environmental options available on meet backend.
|
|||||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||||
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
|
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
|
||||||
|
| FRONTEND_TECHNICAL_DOCUMENTATION_URL | URL of the technical documentation (network prerequisites) linked from the footer and the connection test. If unset, both links are hidden | |
|
||||||
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
||||||
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
||||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||||
@@ -356,6 +357,7 @@ These are the environmental options available on meet backend.
|
|||||||
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
|
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
|
||||||
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
|
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
|
||||||
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
|
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
|
||||||
|
| DJANGO_EMAIL_TIMEOUT | Seconds before a stalled SMTP connection times out | 30 |
|
||||||
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
|
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
|
||||||
| EMAIL_BRAND_NAME | Email branding name | |
|
| EMAIL_BRAND_NAME | Email branding name | |
|
||||||
| EMAIL_SUPPORT_EMAIL | Support email address | |
|
| EMAIL_SUPPORT_EMAIL | Support email address | |
|
||||||
@@ -406,7 +408,9 @@ These are the environmental options available on meet backend.
|
|||||||
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
|
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
|
||||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||||
| ROOM_INACTIVITY_DELETION_DAYS | Days without being started after which a room is purged. Unset to never purge | |
|
| ROOM_INACTIVITY_DELETION_DAYS | Days without being started after which a room is purged. Unset to never purge | |
|
||||||
|
| ROOM_DELETED_RETENTION_DAYS | Days after its deletion after which a room is purged. Unset to purge deleted rooms only once inactive | |
|
||||||
| RECORDING_ENABLE | Record meeting option | false |
|
| RECORDING_ENABLE | Record meeting option | false |
|
||||||
|
| RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED | Let any authenticated participant connected to a trusted or public room start/stop recordings. Set to false to reserve recording to room admins/owners | true |
|
||||||
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
||||||
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||||
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
||||||
@@ -414,6 +418,8 @@ These are the environmental options available on meet backend.
|
|||||||
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
||||||
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
|
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
|
||||||
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
|
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
|
||||||
|
| ANALYTICS_BACKEND | Analytics backend class, e.g. `core.analytics.posthog.PostHogAnalytics`. Nothing is sent when unset | |
|
||||||
|
| ANALYTICS_BACKEND_SETTINGS | Analytics backend settings. Without a backend, `{"feature_flags": {"summary-enabled": True}}` sets feature flags statically | {} |
|
||||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
|
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
|
||||||
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
|
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
|
||||||
| BREVO_API_KEY | Brevo API key for marketing emails | |
|
| BREVO_API_KEY | Brevo API key for marketing emails | |
|
||||||
|
|||||||
@@ -70,18 +70,33 @@ SUMMARY_SERVICE_API_TOKEN=password
|
|||||||
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
||||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||||
|
|
||||||
|
|
||||||
# Recording encoding (LiveKit Egress advanced options).
|
# Recording encoding (LiveKit Egress advanced options).
|
||||||
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
|
# Every video recording is encoded with parameters (height, width, fps, kbps) derived
|
||||||
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
|
# from the pair (profile, resolution) and passed to LiveKit as advanced EncodingOptions.
|
||||||
# file size and CPU load on the egress worker.
|
# Choose the available resolutions and profiles that default settings and users can
|
||||||
# RECORDING_ENCODING_ENABLED=False
|
# pick from. They must be defined as a single-line dict literal (parsed with
|
||||||
# RECORDING_ENCODING_WIDTH=1280
|
# ast.literal_eval: double-quoted keys, no trailing comma, no outer quotes).
|
||||||
# RECORDING_ENCODING_HEIGHT=720
|
# Every profile must define a kbps entry for exactly the keys of
|
||||||
# RECORDING_ENCODING_FRAMERATE=30
|
# RECORDING_ENCODING_AVAILABLE_RESOLUTIONS (validated at startup).
|
||||||
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000
|
# RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
|
||||||
|
# RECORDING_ENCODING_AVAILABLE_PROFILES={"talking_heads": {"fps": 15, "kbps": {"540p": 400, "720p": 700, "1080p": 1200}}, "text": {"fps": 15, "kbps": {"540p": 600, "720p": 1000, "1080p": 1800}}, "mixed": {"fps": 20, "kbps": {"540p": 900, "720p": 1500, "1080p": 2500}}, "full": {"fps": 30, "kbps": {"540p": 2000, "720p": 3000, "1080p": 4500}}}
|
||||||
|
|
||||||
|
|
||||||
|
# Choose the default named resolution and profile to use by default. These values must
|
||||||
|
# be keys of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS and RECORDING_ENCODING_AVAILABLE_PROFILES.
|
||||||
|
# RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||||
|
# RECORDING_ENCODING_DEFAULT_PROFILE=full
|
||||||
|
|
||||||
|
# Default encoding values independent of resolution/profile
|
||||||
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
|
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
|
||||||
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||||
|
|
||||||
|
# Set to True to let the start-recording API override that default per recording
|
||||||
|
# with an `encoding` object, e.g. {"resolution": "720p", "profile": "talking_heads"}.
|
||||||
|
# RECORDING_CUSTOM_ENCODING_ENABLED=False
|
||||||
|
|
||||||
|
|
||||||
# Telephony
|
# Telephony
|
||||||
ROOM_TELEPHONY_ENABLED=True
|
ROOM_TELEPHONY_ENABLED=True
|
||||||
|
|
||||||
@@ -89,10 +104,11 @@ ROOM_TELEPHONY_ENABLED=True
|
|||||||
# ROOMKIT_ENABLED = True
|
# ROOMKIT_ENABLED = True
|
||||||
# ROOMKIT_SERVER_TO_SERVER_API_TOKEN = ThisIsAnExampleKeyForDevPurposeOnly
|
# ROOMKIT_SERVER_TO_SERVER_API_TOKEN = ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
|
|
||||||
# Metadata
|
# LiveKit agents (opt-in, start them with `make run-agents`)
|
||||||
METADATA_COLLECTOR_ENABLED=True
|
# Metadata (requires the metadata-collector agent)
|
||||||
|
METADATA_COLLECTOR_ENABLED=False
|
||||||
|
|
||||||
# Subtitle
|
# Subtitle (requires the multi-user-transcriber agent)
|
||||||
ROOM_SUBTITLE_ENABLED=False
|
ROOM_SUBTITLE_ENABLED=False
|
||||||
|
|
||||||
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ AWS_S3_ACCESS_KEY_ID="meet-access-key"
|
|||||||
AWS_S3_SECRET_ACCESS_KEY="meet-secret-access-key"
|
AWS_S3_SECRET_ACCESS_KEY="meet-secret-access-key"
|
||||||
AWS_S3_REGION_NAME="local"
|
AWS_S3_REGION_NAME="local"
|
||||||
|
|
||||||
|
CELERY_BROKER_URL="redis://redis:6379/2"
|
||||||
|
CELERY_RESULT_BACKEND="redis://redis:6379/2"
|
||||||
|
TASK_TRACKER_REDIS_URL="redis://redis:6379/2"
|
||||||
|
|
||||||
WHISPERX_BASE_URL="https://configure-your-url.com"
|
WHISPERX_BASE_URL="https://configure-your-url.com"
|
||||||
WHISPERX_ASR_MODEL="large-v2"
|
WHISPERX_ASR_MODEL="large-v2"
|
||||||
WHISPERX_API_KEY="your-secret-key"
|
WHISPERX_API_KEY="your-secret-key"
|
||||||
|
|||||||
@@ -24,12 +24,6 @@
|
|||||||
"matchManagers": ["pep621"],
|
"matchManagers": ["pep621"],
|
||||||
"matchPackageNames": ["redis"]
|
"matchPackageNames": ["redis"]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"groupName": "allowed pylint versions",
|
|
||||||
"matchManagers": ["pep621"],
|
|
||||||
"matchPackageNames": ["pylint"],
|
|
||||||
"allowedVersions": "<4.0.0"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"groupName": "allowed django versions",
|
"groupName": "allowed django versions",
|
||||||
"matchManagers": ["pep621"],
|
"matchManagers": ["pep621"],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.33.0"
|
version = "1.35.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.7.0",
|
"livekit-agents==1.7.0",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.33.0"
|
version = "1.35.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "boto3" },
|
{ name = "boto3" },
|
||||||
|
|||||||
@@ -31,10 +31,6 @@ persistent=yes
|
|||||||
# Specify a configuration file.
|
# Specify a configuration file.
|
||||||
#rcfile=
|
#rcfile=
|
||||||
|
|
||||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
|
||||||
# user-friendly hints instead of false-positive error messages
|
|
||||||
suggestion-mode=yes
|
|
||||||
|
|
||||||
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
||||||
# active Python interpreter and may run arbitrary code.
|
# active Python interpreter and may run arbitrary code.
|
||||||
unsafe-load-any-extension=no
|
unsafe-load-any-extension=no
|
||||||
|
|||||||
@@ -286,9 +286,16 @@ class RoomAdmin(admin.ModelAdmin):
|
|||||||
"get_owner",
|
"get_owner",
|
||||||
"created_at",
|
"created_at",
|
||||||
"last_started_at",
|
"last_started_at",
|
||||||
|
"deleted_at",
|
||||||
|
]
|
||||||
|
list_filter = ["access_level", "created_at", "last_started_at", "deleted_at"]
|
||||||
|
readonly_fields = [
|
||||||
|
"id",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"last_started_at",
|
||||||
|
"deleted_at",
|
||||||
]
|
]
|
||||||
list_filter = ["access_level", "created_at", "last_started_at"]
|
|
||||||
readonly_fields = ["id", "created_at", "updated_at", "last_started_at"]
|
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
||||||
@@ -483,6 +490,11 @@ class ApplicationAdminForm(forms.ModelForm):
|
|||||||
if self.instance.pk and self.instance.scopes:
|
if self.instance.pk and self.instance.scopes:
|
||||||
self.fields["scopes"].initial = self.instance.scopes
|
self.fields["scopes"].initial = self.instance.scopes
|
||||||
|
|
||||||
|
# On creation: display generated credentials without allowing edits
|
||||||
|
for name in ("client_id", "client_secret"):
|
||||||
|
if name in self.fields:
|
||||||
|
self.fields[name].widget.attrs["readonly"] = True
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Application)
|
@admin.register(models.Application)
|
||||||
class ApplicationAdmin(admin.ModelAdmin):
|
class ApplicationAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def get_analytics() -> AnalyticsBackend:
|
|||||||
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
|
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
|
||||||
|
|
||||||
if not dotted_path:
|
if not dotted_path:
|
||||||
return NoOpAnalytics()
|
return NoOpAnalytics(**options)
|
||||||
|
|
||||||
backend_class = import_string(dotted_path)
|
backend_class = import_string(dotted_path)
|
||||||
return backend_class(**options)
|
return backend_class(**options)
|
||||||
|
|||||||
@@ -53,7 +53,23 @@ class AnalyticsBackend(ABC):
|
|||||||
|
|
||||||
|
|
||||||
class NoOpAnalytics(AnalyticsBackend):
|
class NoOpAnalytics(AnalyticsBackend):
|
||||||
"""Default backend: silently discards everything."""
|
"""
|
||||||
|
Default backend: discards events and serves static feature flags.
|
||||||
|
|
||||||
|
Feature flags can be declared without any analytics provider, e.g.:
|
||||||
|
|
||||||
|
ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
feature_flags: Mapping[str, bool | str | None] | None = None,
|
||||||
|
**_kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
# Other settings are ignored, so that settings meant for another
|
||||||
|
# backend never break an instance running without one.
|
||||||
|
self._feature_flags = dict(feature_flags or {})
|
||||||
|
|
||||||
def identify(self, user: User, properties=None) -> None:
|
def identify(self, user: User, properties=None) -> None:
|
||||||
"""No-op: discards identify calls."""
|
"""No-op: discards identify calls."""
|
||||||
@@ -63,3 +79,10 @@ class NoOpAnalytics(AnalyticsBackend):
|
|||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
"""No-op: nothing to flush."""
|
"""No-op: nothing to flush."""
|
||||||
|
|
||||||
|
def get_user_feature_flags(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||||
|
"""Return the feature flags declared in settings, for any user."""
|
||||||
|
return self._feature_flags
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class AnalyticsEvent(StrEnum):
|
|||||||
# Rooms
|
# Rooms
|
||||||
ROOM_CREATED = "room_created"
|
ROOM_CREATED = "room_created"
|
||||||
ROOM_UPDATED = "room_updated"
|
ROOM_UPDATED = "room_updated"
|
||||||
|
ROOM_DELETED = "room_deleted"
|
||||||
|
|
||||||
# Roomkit (meeting-room SIP devices)
|
# Roomkit (meeting-room SIP devices)
|
||||||
ROOMKIT_JOINED = "roomkit_joined"
|
ROOMKIT_JOINED = "roomkit_joined"
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ def get_frontend_configuration(request):
|
|||||||
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
|
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
|
||||||
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||||
"max_duration": settings.RECORDING_MAX_DURATION,
|
"max_duration": settings.RECORDING_MAX_DURATION,
|
||||||
|
"encoding": {
|
||||||
|
"is_custom_enabled": settings.RECORDING_CUSTOM_ENCODING_ENABLED,
|
||||||
|
"available_resolutions": list(
|
||||||
|
settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS.keys()
|
||||||
|
),
|
||||||
|
"available_profiles": list(
|
||||||
|
settings.RECORDING_ENCODING_AVAILABLE_PROFILES.keys()
|
||||||
|
),
|
||||||
|
"default_resolution": settings.RECORDING_ENCODING_DEFAULT_RESOLUTION,
|
||||||
|
"default_profile": settings.RECORDING_ENCODING_DEFAULT_PROFILE,
|
||||||
|
},
|
||||||
|
"authenticated_participants_enabled": (
|
||||||
|
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"background_image": {
|
"background_image": {
|
||||||
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
|
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
|
||||||
@@ -73,6 +87,7 @@ def get_frontend_configuration(request):
|
|||||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||||
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
|
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
|
||||||
},
|
},
|
||||||
|
"allow_unregistered_rooms": settings.ALLOW_UNREGISTERED_ROOMS,
|
||||||
"authenticated_users_can_edit_display_name": (
|
"authenticated_users_can_edit_display_name": (
|
||||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Exceptions and guards shared by the API endpoints."""
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from rest_framework import exceptions, status
|
||||||
|
|
||||||
|
from core import models
|
||||||
|
|
||||||
|
|
||||||
|
class RoomSoftDeleted(exceptions.APIException):
|
||||||
|
"""Raised when the requested room has been soft deleted."""
|
||||||
|
|
||||||
|
status_code = status.HTTP_410_GONE
|
||||||
|
default_detail = _("This room has been deleted.")
|
||||||
|
default_code = "room_deleted"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_room_not_deleted(resource):
|
||||||
|
"""Raise a 410 Gone if the resource is a soft-deleted room.
|
||||||
|
|
||||||
|
Accepts a room or its parent resource, as referenced by accesses. Call it
|
||||||
|
after permissions are checked, to avoid revealing room.
|
||||||
|
"""
|
||||||
|
if isinstance(resource, models.Room):
|
||||||
|
room = resource
|
||||||
|
else:
|
||||||
|
room = getattr(resource, "room", None)
|
||||||
|
|
||||||
|
if room is not None and room.is_deleted:
|
||||||
|
raise RoomSoftDeleted()
|
||||||
@@ -17,6 +17,7 @@ class FeatureFlag:
|
|||||||
"application": "APPLICATION_ENABLED",
|
"application": "APPLICATION_ENABLED",
|
||||||
"roomkit": "ROOMKIT_ENABLED",
|
"roomkit": "ROOMKIT_ENABLED",
|
||||||
"connection_test": "CONNECTION_TEST_ENABLED",
|
"connection_test": "CONNECTION_TEST_ENABLED",
|
||||||
|
"user_access_token": "USER_ACCESS_TOKEN_ENABLED",
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Row locks guarding the API write paths."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from core import models
|
||||||
|
from core.api.exceptions import ensure_room_not_deleted
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def room_locked_for_update(room):
|
||||||
|
"""Lock a room's row for the duration of a write."""
|
||||||
|
with transaction.atomic():
|
||||||
|
locked_room = models.Room.all_objects.select_for_update().get(pk=room.pk)
|
||||||
|
ensure_room_not_deleted(locked_room)
|
||||||
|
yield locked_room
|
||||||
@@ -187,29 +187,29 @@ class IsPresentInMeeting(permissions.BasePermission):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class CanManageLobby(permissions.BasePermission):
|
class ParticipantCapability(permissions.BasePermission):
|
||||||
"""Grant lobby management (list/accept/deny waiting participants).
|
"""Base permission for in-meeting capabilities opened up to participants.
|
||||||
|
|
||||||
- Room admins/owners can always manage the lobby.
|
- Room admins/owners always hold the capability.
|
||||||
- When the room access level is TRUSTED, any authenticated user who is
|
- When the room access level is TRUSTED or PUBLIC, any
|
||||||
currently connected to the meeting can manage the lobby. Presence is
|
session-authenticated user who is currently connected to the meeting
|
||||||
verified cache-first (Redis), falling back to the LiveKit API.
|
holds it too.
|
||||||
|
|
||||||
Access level is always read fresh from the DB; only presence is cached,
|
Access level is always read fresh from the DB; only presence is cached,
|
||||||
so changing the room to RESTRICTED takes effect immediately.
|
so changing the room access level takes effect immediately.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
message = "You are not allowed to manage this room's lobby."
|
message = "You are not allowed to perform this action."
|
||||||
|
enabled_setting = None
|
||||||
|
|
||||||
# pylint: disable=too-many-return-statements
|
def has_object_permission(self, request, view, obj):
|
||||||
def has_object_permission(self, request, view, obj): # noqa: PLR0911
|
"""Check privileges first, then the participant presence path."""
|
||||||
"""Check privileges first, then the trusted-room presence path."""
|
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
if not user or not user.is_authenticated:
|
if not user or not user.is_authenticated:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Product choice: lobby management is reserved for session-authenticated
|
# Product choice: these capabilities are reserved for session-authenticated
|
||||||
# users with a real account, not holders of a LiveKit room token.
|
# users with a real account, not holders of a LiveKit room token.
|
||||||
if request.auth and hasattr(request.auth, "video"):
|
if request.auth and hasattr(request.auth, "video"):
|
||||||
return False
|
return False
|
||||||
@@ -217,16 +217,31 @@ class CanManageLobby(permissions.BasePermission):
|
|||||||
if obj.is_administrator_or_owner(user):
|
if obj.is_administrator_or_owner(user):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if obj.access_level != RoomAccessLevel.TRUSTED:
|
is_open_room = obj.access_level in (
|
||||||
|
RoomAccessLevel.TRUSTED,
|
||||||
|
RoomAccessLevel.PUBLIC,
|
||||||
|
)
|
||||||
|
is_enabled = not self.enabled_setting or getattr(settings, self.enabled_setting)
|
||||||
|
if not (is_open_room and is_enabled):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.message = "You must be connected to the meeting to manage its lobby."
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return ParticipantsManagement().check_if_in_meeting_cached(
|
return ParticipantsManagement().check_if_in_meeting_cached(
|
||||||
room_name=str(obj.pk), identity=str(user.sub)
|
room_name=str(obj.pk), identity=str(user.sub)
|
||||||
)
|
)
|
||||||
except ParticipantNotFoundException:
|
except (ParticipantNotFoundException, ParticipantsManagementException):
|
||||||
return False
|
|
||||||
except ParticipantsManagementException:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class CanManageLobby(ParticipantCapability):
|
||||||
|
"""Grant lobby management (list/accept/deny waiting participants).
|
||||||
|
|
||||||
|
Public rooms have no lobby: the endpoints short-circuit there, so opening
|
||||||
|
them to participants exposes nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CanManageRecording(ParticipantCapability):
|
||||||
|
"""Grant starting/stopping a room recording (screen recording or transcript)."""
|
||||||
|
|
||||||
|
enabled_setting = "RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED"
|
||||||
|
|||||||
@@ -7,19 +7,26 @@ from typing import Literal
|
|||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core import signing
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
|
|
||||||
# pylint: disable=abstract-method,no-name-in-module
|
# pylint: disable=abstract-method,no-name-in-module
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from django_pydantic_field.rest_framework import SchemaField
|
from django_pydantic_field.rest_framework import SchemaField
|
||||||
from pydantic import BaseModel, Field, field_serializer
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
Field,
|
||||||
|
field_serializer,
|
||||||
|
field_validator,
|
||||||
|
)
|
||||||
from pydantic import ValidationError as PydanticValidationError
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
from core.api.exceptions import ensure_room_not_deleted
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -108,6 +115,7 @@ class ResourceAccessSerializerMixin:
|
|||||||
_("You must be administrator or owner of a room to add accesses to it.")
|
_("You must be administrator or owner of a room to add accesses to it.")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ensure_room_not_deleted(resource)
|
||||||
return resource
|
return resource
|
||||||
|
|
||||||
|
|
||||||
@@ -244,6 +252,49 @@ class BaseValidationOnlySerializer(serializers.Serializer):
|
|||||||
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
||||||
|
|
||||||
|
|
||||||
|
class EncodingConfig(BaseModel):
|
||||||
|
"""Configuration options for recording encoding.
|
||||||
|
|
||||||
|
The allowed `resolution` and `profile` values are derived at validation time
|
||||||
|
from ``settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`` and
|
||||||
|
``settings.RECORDING_ENCODING_AVAILABLE_PROFILES``, so adding a resolution or profile
|
||||||
|
to those maps is enough to make it accepted here.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
resolution: Target video resolution.
|
||||||
|
profile: Encoding profile to fps and kbps. When `None`,
|
||||||
|
`settings.RECORDING_ENCODING_DEFAULT_PROFILE` applies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
resolution: str
|
||||||
|
profile: str | None = None
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
@field_validator("resolution")
|
||||||
|
@classmethod
|
||||||
|
def _validate_resolution(cls, value):
|
||||||
|
"""Reject resolutions absent from RECORDING_ENCODING_AVAILABLE_RESOLUTIONS."""
|
||||||
|
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid resolution '{value}'. Choose from {sorted(allowed)}."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("profile")
|
||||||
|
@classmethod
|
||||||
|
def _validate_profile(cls, value):
|
||||||
|
"""Reject profiles absent from RECORDING_ENCODING_AVAILABLE_PROFILES."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_PROFILES)
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid profile '{value}'. Choose from {sorted(allowed)}."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class RecordingOptions(BaseModel):
|
class RecordingOptions(BaseModel):
|
||||||
"""Configuration options for recording.
|
"""Configuration options for recording.
|
||||||
|
|
||||||
@@ -264,7 +315,7 @@ class RecordingOptions(BaseModel):
|
|||||||
transcribe: bool | None = None
|
transcribe: bool | None = None
|
||||||
collect_metadata: bool | None = None
|
collect_metadata: bool | None = None
|
||||||
original_mode: Literal["screen_recording", "transcript"] | None = None
|
original_mode: Literal["screen_recording", "transcript"] | None = None
|
||||||
|
encoding: EncodingConfig | None = None
|
||||||
model_config = {"extra": "forbid"}
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
|
||||||
@@ -287,11 +338,47 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
|||||||
help_text="Recording options",
|
help_text="Recording options",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def validate_options(self, value: RecordingOptions):
|
||||||
|
"""Validate that custom encoding is enabled if encoding options are passed."""
|
||||||
|
if (
|
||||||
|
value is not None
|
||||||
|
and value.encoding is not None
|
||||||
|
and not settings.RECORDING_CUSTOM_ENCODING_ENABLED
|
||||||
|
):
|
||||||
|
# Per-recording encoding selection is gated by
|
||||||
|
# RECORDING_CUSTOM_ENCODING_ENABLED. When disabled, recordings use
|
||||||
|
# encoding defined by RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||||
|
# and RECORDING_ENCODING_DEFAULT_PROFILE.
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Per-recording encoding selection is disabled."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||||
"""Validate request entry data."""
|
"""Validate request entry data."""
|
||||||
|
|
||||||
username = serializers.CharField(required=True)
|
username = serializers.CharField(required=True)
|
||||||
|
participant_id = serializers.CharField(
|
||||||
|
required=False, allow_null=True, max_length=128
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sign_participant_id(participant_id):
|
||||||
|
"""Sign with Django's SECRET_KEY and a lobby-specific namespace."""
|
||||||
|
return signing.Signer(salt="core.lobby.participant").sign(participant_id)
|
||||||
|
|
||||||
|
def validate_participant_id(self, value):
|
||||||
|
"""Require a valid server signature before looking up a participant."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
participant_id = signing.Signer(salt="core.lobby.participant").unsign(value)
|
||||||
|
except signing.BadSignature as exc:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Invalid participant credential."
|
||||||
|
) from exc
|
||||||
|
return serializers.UUIDField().run_validation(participant_id)
|
||||||
|
|
||||||
|
|
||||||
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
||||||
@@ -599,3 +686,20 @@ class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
|||||||
# useless bad requests
|
# useless bad requests
|
||||||
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TransitCodeSerializer(BaseValidationOnlySerializer):
|
||||||
|
"""Validate the single-use transit code sent to the exchange endpoint."""
|
||||||
|
|
||||||
|
code = serializers.CharField(trim_whitespace=True)
|
||||||
|
|
||||||
|
def validate_code(self, value):
|
||||||
|
"""Reject codes whose length cannot match a generated one."""
|
||||||
|
|
||||||
|
# Calculates urlsafe_b64encode length without padding
|
||||||
|
expected_length = (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
|
||||||
|
|
||||||
|
if len(value) != expected_length:
|
||||||
|
raise serializers.ValidationError("Invalid transit code format.")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""Throttling modules for the API."""
|
"""Throttling modules for the API."""
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from lasuite.drf.throttling import MonitoredThrottleMixin
|
from lasuite.drf.throttling import MonitoredThrottleMixin
|
||||||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
||||||
from sentry_sdk import capture_message
|
from sentry_sdk import capture_message
|
||||||
|
|
||||||
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
def sentry_monitoring_throttle_failure(message):
|
def sentry_monitoring_throttle_failure(message):
|
||||||
"""Log when a failure occurs to detect rate limiting issues."""
|
"""Log when a failure occurs to detect rate limiting issues."""
|
||||||
@@ -69,13 +69,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
def get_cache_key(self, request, view):
|
def get_cache_key(self, request, view):
|
||||||
"""Use the lobby participant cookie ID as the throttle cache key.
|
"""Use the lobby participant cookie ID as the throttle cache key.
|
||||||
|
|
||||||
Only throttle if a cookie is already set. If no cookie exists yet,
|
Only throttle requests carrying a participant identifier. The
|
||||||
return None to skip throttling — the cookie will be set on the first
|
identifier is returned by the first request-entry response and
|
||||||
response, and throttling will apply from the second request onward.
|
echoed back by the client from the second request onward, which is
|
||||||
|
when throttling starts applying.
|
||||||
|
|
||||||
Keying on the cookie rather than the IP address prevents penalising
|
Keying on the identifier rather than the IP address prevents
|
||||||
multiple users behind the same NAT/proxy, and is consistent with how
|
penalising multiple users behind the same NAT/proxy, and is
|
||||||
LobbyService identifies participants.
|
consistent with how the lobby identifies participants.
|
||||||
|
|
||||||
Note: as per DRF documentation, application-level throttling is not a
|
Note: as per DRF documentation, application-level throttling is not a
|
||||||
security measure against brute-force or DoS attacks. This throttle exists
|
security measure against brute-force or DoS attacks. This throttle exists
|
||||||
@@ -85,10 +86,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
if request.user and request.user.is_authenticated:
|
if request.user and request.user.is_authenticated:
|
||||||
return None # Only throttle unauthenticated requests.
|
return None # Only throttle unauthenticated requests.
|
||||||
|
|
||||||
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
|
serializer = serializers.RequestEntrySerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return None
|
||||||
|
|
||||||
if participant_id is None:
|
participant_id = serializer.validated_data.get("participant_id")
|
||||||
return None # No throttling for cookieless requests
|
|
||||||
|
if not participant_id:
|
||||||
|
return None # No throttling for unidentified requests
|
||||||
|
|
||||||
return self.cache_format % {
|
return self.cache_format % {
|
||||||
"scope": self.scope,
|
"scope": self.scope,
|
||||||
@@ -124,3 +129,14 @@ class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
"""Throttle anonymous users requesting connection test tokens."""
|
"""Throttle anonymous users requesting connection test tokens."""
|
||||||
|
|
||||||
scope = "connection_test"
|
scope = "connection_test"
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeAccessTokenAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||||
|
"""Throttle anonymous transit code exchange attempts.
|
||||||
|
|
||||||
|
Abuse mitigation only, not a security boundary: DRF throttling is
|
||||||
|
best-effort. The security of the exchange rests on the codes'
|
||||||
|
entropy and single use.
|
||||||
|
"""
|
||||||
|
|
||||||
|
scope = "exchange_access_token"
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ from rest_framework.settings import api_settings
|
|||||||
|
|
||||||
from core import analytics, enums, models, utils
|
from core import analytics, enums, models, utils
|
||||||
from core.api import throttling
|
from core.api import throttling
|
||||||
|
from core.api.exceptions import ensure_room_not_deleted
|
||||||
from core.api.filters import ListFileFilter
|
from core.api.filters import ListFileFilter
|
||||||
|
from core.api.locks import room_locked_for_update
|
||||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||||
from core.recording.enums import FileExtension
|
from core.recording.enums import FileExtension
|
||||||
from core.recording.event.authentication import RecordingProcessWebhookAuthentication
|
from core.recording.event.authentication import RecordingProcessWebhookAuthentication
|
||||||
@@ -55,12 +57,14 @@ from core.recording.worker.exceptions import (
|
|||||||
RecordingStopError,
|
RecordingStopError,
|
||||||
)
|
)
|
||||||
from core.recording.worker.factories import (
|
from core.recording.worker.factories import (
|
||||||
|
build_encoding_options,
|
||||||
get_worker_service,
|
get_worker_service,
|
||||||
)
|
)
|
||||||
from core.recording.worker.mediator import (
|
from core.recording.worker.mediator import (
|
||||||
WorkerServiceMediator,
|
WorkerServiceMediator,
|
||||||
)
|
)
|
||||||
from core.services.invitation import InvitationService
|
from core.services.invitation import InvitationService
|
||||||
|
from core.services.jwt_token import JwtTokenService
|
||||||
from core.services.livekit_events import (
|
from core.services.livekit_events import (
|
||||||
LiveKitEventsService,
|
LiveKitEventsService,
|
||||||
LiveKitWebhookError,
|
LiveKitWebhookError,
|
||||||
@@ -75,12 +79,13 @@ from core.services.participants_management import (
|
|||||||
ParticipantsManagementException,
|
ParticipantsManagementException,
|
||||||
)
|
)
|
||||||
from core.services.room_creation import RoomCreation
|
from core.services.room_creation import RoomCreation
|
||||||
from core.services.room_management import RoomManagement
|
from core.services.room_management import RoomManagement, RoomManagementException
|
||||||
from core.services.room_roles import (
|
from core.services.room_roles import (
|
||||||
RoomRoleError,
|
RoomRoleError,
|
||||||
RoomRoleService,
|
RoomRoleService,
|
||||||
)
|
)
|
||||||
from core.services.subtitle import SubtitleException, SubtitleService
|
from core.services.subtitle import SubtitleException, SubtitleService
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
from core.tasks.connection_test import delete_connection_test_room
|
from core.tasks.connection_test import delete_connection_test_room
|
||||||
from core.tasks.file import process_file_deletion
|
from core.tasks.file import process_file_deletion
|
||||||
from core.utils import generate_token
|
from core.utils import generate_token
|
||||||
@@ -165,8 +170,100 @@ class UserViewSet(
|
|||||||
self.serializer_class(request.user, context=context).data
|
self.serializer_class(request.user, context=context).data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=False,
|
||||||
|
methods=["post"],
|
||||||
|
url_path="exchange-access-token",
|
||||||
|
permission_classes=[],
|
||||||
|
throttle_classes=[throttling.ExchangeAccessTokenAnonRateThrottle],
|
||||||
|
)
|
||||||
|
@FeatureFlag.require("user_access_token")
|
||||||
|
def exchange_access_token(self, request):
|
||||||
|
"""Exchange a single-use transit code for a user access token.
|
||||||
|
|
||||||
class RoomViewSet(
|
The endpoint is unauthenticated: the transit code itself, an opaque
|
||||||
|
random string obtained through the external API and delivered to
|
||||||
|
the embedded frontend via a URL fragment, is the credential. Each
|
||||||
|
code can be exchanged exactly once (consuming it deletes it from
|
||||||
|
the cache); replaying a consumed code is denied and logged.
|
||||||
|
|
||||||
|
The issued JWT authenticates the user the code was minted for on
|
||||||
|
the whole core API, exactly like a session cookie would (similar
|
||||||
|
to lib-jitsi-meet's token authentication), and never appears in
|
||||||
|
any URL. Role-based permissions apply unchanged.
|
||||||
|
"""
|
||||||
|
if request.user and request.user.is_authenticated:
|
||||||
|
logger.warning(
|
||||||
|
"Transit code exchange refused: request is already "
|
||||||
|
"session-authenticated (user_id=%s)",
|
||||||
|
request.user.id,
|
||||||
|
)
|
||||||
|
raise drf_exceptions.PermissionDenied("Already authenticated.")
|
||||||
|
|
||||||
|
serializer = serializers.TransitCodeSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
code_data = TransitCodeService().consume_code(serializer.validated_data["code"])
|
||||||
|
|
||||||
|
if code_data is None:
|
||||||
|
logger.warning("Invalid, expired or already used transit code")
|
||||||
|
raise drf_exceptions.PermissionDenied(
|
||||||
|
"Invalid, expired or already used transit code."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-check the user at exchange time so that a deactivation after
|
||||||
|
# the transit code was minted is taken into account.
|
||||||
|
user_id = code_data["user_id"]
|
||||||
|
try:
|
||||||
|
user = models.User.objects.get(id=user_id, is_active=True)
|
||||||
|
except models.User.DoesNotExist as e:
|
||||||
|
raise drf_exceptions.PermissionDenied(
|
||||||
|
"This account can no longer access the application."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
client_id = code_data.get("client_id")
|
||||||
|
if not models.Application.has_active_scope(
|
||||||
|
client_id, models.ApplicationScope.USERS_SESSION
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Transit code exchange refused: application '%s' no longer "
|
||||||
|
"holds the '%s' grant",
|
||||||
|
client_id,
|
||||||
|
models.ApplicationScope.USERS_SESSION,
|
||||||
|
)
|
||||||
|
raise drf_exceptions.PermissionDenied(
|
||||||
|
"This application can no longer create user sessions."
|
||||||
|
)
|
||||||
|
|
||||||
|
token_service = JwtTokenService(
|
||||||
|
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
|
||||||
|
token_type=settings.USER_ACCESS_TOKEN_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = token_service.generate_jwt(
|
||||||
|
user,
|
||||||
|
"user:access",
|
||||||
|
{
|
||||||
|
"client_id": client_id or "unknown",
|
||||||
|
"token_type": settings.USER_ACCESS_TOKEN_TYPE_CLAIM,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log for auditing
|
||||||
|
logger.info(
|
||||||
|
"User access token issued from transit code: user_id=%s, client_id=%s",
|
||||||
|
user_id,
|
||||||
|
client_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return drf_response.Response(data)
|
||||||
|
|
||||||
|
|
||||||
|
class RoomViewSet( # pylint: disable=too-many-public-methods
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
mixins.DestroyModelMixin,
|
mixins.DestroyModelMixin,
|
||||||
mixins.UpdateModelMixin,
|
mixins.UpdateModelMixin,
|
||||||
@@ -192,10 +289,9 @@ class RoomViewSet(
|
|||||||
filter_kwargs = {"pk": self.kwargs["pk"]}
|
filter_kwargs = {"pk": self.kwargs["pk"]}
|
||||||
except ValueError:
|
except ValueError:
|
||||||
filter_kwargs = {"slug": slugify(self.kwargs["pk"])}
|
filter_kwargs = {"slug": slugify(self.kwargs["pk"])}
|
||||||
queryset = self.filter_queryset(self.get_queryset())
|
obj = get_object_or_404(models.Room.all_objects, **filter_kwargs)
|
||||||
obj = get_object_or_404(queryset, **filter_kwargs)
|
|
||||||
# May raise a permission denied
|
|
||||||
self.check_object_permissions(self.request, obj)
|
self.check_object_permissions(self.request, obj)
|
||||||
|
ensure_room_not_deleted(obj)
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def retrieve(self, request, *args, **kwargs):
|
def retrieve(self, request, *args, **kwargs):
|
||||||
@@ -247,6 +343,27 @@ class RoomViewSet(
|
|||||||
serializer = self.get_serializer(queryset, many=True)
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
return drf_response.Response(serializer.data)
|
return drf_response.Response(serializer.data)
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
"""Soft delete the room and close its LiveKit room.
|
||||||
|
|
||||||
|
The room and its recordings are kept in database for traceability.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
RoomManagement.soft_delete(instance)
|
||||||
|
except RoomManagementException as e:
|
||||||
|
raise drf_exceptions.APIException(
|
||||||
|
"Could not delete the room, please try again."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
analytics.capture(
|
||||||
|
self.request.user,
|
||||||
|
analytics.AnalyticsEvent.ROOM_DELETED,
|
||||||
|
{
|
||||||
|
"room_id": str(instance.pk),
|
||||||
|
"access_level": instance.access_level,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Set the current user as owner of the newly created room.
|
"""Set the current user as owner of the newly created room.
|
||||||
|
|
||||||
@@ -289,12 +406,20 @@ class RoomViewSet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
"""Persist the room update, then sync metadata to LiveKit."""
|
"""Persist the room update under a row lock, then sync metadata to LiveKit.
|
||||||
|
|
||||||
old_configuration = serializer.instance.configuration
|
The lock is what keeps a concurrent soft delete from being undone: the
|
||||||
old_access_level = serializer.instance.access_level
|
full-row write of `save()` would otherwise carry back the `deleted_at`
|
||||||
|
read before the deletion committed.
|
||||||
|
"""
|
||||||
|
|
||||||
room = serializer.save()
|
with room_locked_for_update(serializer.instance) as locked_room:
|
||||||
|
serializer.instance = locked_room
|
||||||
|
|
||||||
|
old_configuration = locked_room.configuration
|
||||||
|
old_access_level = locked_room.access_level
|
||||||
|
|
||||||
|
room = serializer.save()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
room.configuration == old_configuration
|
room.configuration == old_configuration
|
||||||
@@ -309,7 +434,7 @@ class RoomViewSet(
|
|||||||
methods=["post"],
|
methods=["post"],
|
||||||
url_path="start-recording",
|
url_path="start-recording",
|
||||||
permission_classes=[
|
permission_classes=[
|
||||||
permissions.HasPrivilegesOnRoom,
|
permissions.CanManageRecording,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@FeatureFlag.require("recording")
|
@FeatureFlag.require("recording")
|
||||||
@@ -327,18 +452,22 @@ class RoomViewSet(
|
|||||||
options = serializer.validated_data.get("options")
|
options = serializer.validated_data.get("options")
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
|
options_data = options.model_dump(exclude_none=True) if options else {}
|
||||||
|
if options is not None and options.encoding is not None:
|
||||||
|
# Persist the resolved encoding (concrete width/height/framerate/
|
||||||
|
# bitrate) alongside the requested resolution/profile for traceability.
|
||||||
|
options_data["encoding"]["resolved"] = build_encoding_options(
|
||||||
|
options.encoding.resolution, options.encoding.profile
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
recording = models.Recording.objects.create(
|
recording = models.Recording.objects.create(
|
||||||
room=room,
|
room=room,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
options=options.model_dump(exclude_none=True) if options else {},
|
options=options_data,
|
||||||
)
|
|
||||||
models.RecordingAccess.objects.create(
|
|
||||||
user=self.request.user,
|
|
||||||
role=models.RoleChoices.OWNER,
|
|
||||||
recording=recording,
|
|
||||||
)
|
)
|
||||||
|
recording.grant_initial_accesses(starter=self.request.user)
|
||||||
|
|
||||||
except (DjangoValidationError, IntegrityError):
|
except (DjangoValidationError, IntegrityError):
|
||||||
# DjangoValidationError covers the Python-level check (full_clean);
|
# DjangoValidationError covers the Python-level check (full_clean);
|
||||||
@@ -382,7 +511,7 @@ class RoomViewSet(
|
|||||||
methods=["post"],
|
methods=["post"],
|
||||||
url_path="stop-recording",
|
url_path="stop-recording",
|
||||||
permission_classes=[
|
permission_classes=[
|
||||||
permissions.HasPrivilegesOnRoom,
|
permissions.CanManageRecording,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@FeatureFlag.require("recording")
|
@FeatureFlag.require("recording")
|
||||||
@@ -436,13 +565,18 @@ class RoomViewSet(
|
|||||||
|
|
||||||
participant, livekit = lobby_service.request_entry(
|
participant, livekit = lobby_service.request_entry(
|
||||||
room=room,
|
room=room,
|
||||||
request=request,
|
user=request.user,
|
||||||
**serializer.validated_data,
|
**serializer.validated_data,
|
||||||
)
|
)
|
||||||
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
return drf_response.Response(
|
||||||
lobby_service.prepare_response(response, participant.id)
|
{
|
||||||
|
**participant.to_dict(),
|
||||||
return response
|
"id": serializers.RequestEntrySerializer.sign_participant_id(
|
||||||
|
participant.id
|
||||||
|
),
|
||||||
|
"livekit": livekit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
detail=True,
|
detail=True,
|
||||||
@@ -931,6 +1065,13 @@ class ResourceAccessViewSet(
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
"""Accesses to a soft-deleted room can be read but no longer modified."""
|
||||||
|
access = super().get_object()
|
||||||
|
if self.request.method not in drf_permissions.SAFE_METHODS:
|
||||||
|
ensure_room_not_deleted(access.resource)
|
||||||
|
return access
|
||||||
|
|
||||||
|
|
||||||
class RecordingViewSet(
|
class RecordingViewSet(
|
||||||
mixins.DestroyModelMixin,
|
mixins.DestroyModelMixin,
|
||||||
|
|||||||
@@ -1,26 +1,19 @@
|
|||||||
"""Authentication Backends for the Meet core app."""
|
"""Authentication Backends for the Meet core app."""
|
||||||
|
|
||||||
import contextlib
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import (
|
from django.core.exceptions import (
|
||||||
ImproperlyConfigured,
|
|
||||||
SuspiciousOperation,
|
SuspiciousOperation,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
)
|
)
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from lasuite.marketing.tasks import create_or_update_contact
|
||||||
from lasuite.oidc_login.backends import (
|
from lasuite.oidc_login.backends import (
|
||||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||||
)
|
)
|
||||||
from rest_framework.authentication import SessionAuthentication
|
from rest_framework.authentication import SessionAuthentication
|
||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.services.marketing import (
|
|
||||||
ContactCreationError,
|
|
||||||
ContactData,
|
|
||||||
get_marketing_service,
|
|
||||||
)
|
|
||||||
from core.validators import sub_validator
|
from core.validators import sub_validator
|
||||||
|
|
||||||
|
|
||||||
@@ -67,25 +60,15 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def signup_to_marketing_email(email):
|
def signup_to_marketing_email(email):
|
||||||
"""Pragmatic approach to newsletter signup during authentication flow.
|
"""Add the user to the newsletter list on sign-in.
|
||||||
|
|
||||||
Details:
|
Uses the team's standard Brevo integration, dispatching the contact
|
||||||
1. Uses a very short timeout (1s) to prevent blocking the auth process
|
creation/update as an asynchronous task to keep authentication fast.
|
||||||
2. Silently fails if the marketing service is down/slow to prioritize user experience
|
|
||||||
3. Trade-off: May miss some signups but ensures auth flow remains fast
|
|
||||||
|
|
||||||
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
|
|
||||||
"""
|
"""
|
||||||
with contextlib.suppress(
|
create_or_update_contact.delay(
|
||||||
ContactCreationError, ImproperlyConfigured, ImportError
|
email=email,
|
||||||
):
|
attributes={"VISIO_SOURCE": ["SIGNIN"]},
|
||||||
marketing_service = get_marketing_service()
|
)
|
||||||
contact_data = ContactData(
|
|
||||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
|
||||||
)
|
|
||||||
marketing_service.create_contact(
|
|
||||||
contact_data, timeout=settings.BREVO_API_TIMEOUT
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_existing_user(self, sub, email):
|
def get_existing_user(self, sub, email):
|
||||||
"""Fetch existing user by sub or email."""
|
"""Fetch existing user by sub or email."""
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from rest_framework import authentication, exceptions
|
|||||||
|
|
||||||
UserModel = get_user_model()
|
UserModel = get_user_model()
|
||||||
|
|
||||||
|
LIVEKIT_AUTH_SCHEME = "X-LiveKit-Token"
|
||||||
|
|
||||||
|
|
||||||
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
||||||
"""Authenticate using LiveKit token and load the associated Django user."""
|
"""Authenticate using LiveKit token and load the associated Django user."""
|
||||||
@@ -20,9 +22,14 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
|||||||
return None # No authentication attempted
|
return None # No authentication attempted
|
||||||
|
|
||||||
parts = auth_header.split()
|
parts = auth_header.split()
|
||||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
if not parts or parts[0].lower() != LIVEKIT_AUTH_SCHEME.lower():
|
||||||
|
# Not our scheme (e.g. "Bearer <user access token>"): defer, another
|
||||||
|
# backend may recognize it.
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(parts) != 2:
|
||||||
raise exceptions.AuthenticationFailed(
|
raise exceptions.AuthenticationFailed(
|
||||||
"Authorization header must be: Bearer <token>"
|
f"Authorization header must be: {LIVEKIT_AUTH_SCHEME} <token>"
|
||||||
)
|
)
|
||||||
|
|
||||||
token = parts[1]
|
token = parts[1]
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""User access JWT authentication for the Meet core API.
|
||||||
|
|
||||||
|
Allows an embedded frontend (e.g. rendered in an iframe, where third-party
|
||||||
|
session cookies are blocked) to authenticate requests on the core API with
|
||||||
|
a JWT, obtained by exchanging a single-use transit code (see
|
||||||
|
core.services.transit_code and the users exchange-access-token endpoint)
|
||||||
|
and passed as a Bearer header. The JWT itself never appears in any URL.
|
||||||
|
|
||||||
|
Similar to lib-jitsi-meet's token authentication, the token is bound to a
|
||||||
|
user, not to a resource: once authenticated, the request is treated
|
||||||
|
exactly like a session-authenticated one, and the existing role-based
|
||||||
|
permissions apply unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from rest_framework import exceptions
|
||||||
|
|
||||||
|
from core.external_api.authentication import BaseJWTAuthentication
|
||||||
|
from core.models import Application, ApplicationScope
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UserAccessJWTAuthentication(BaseJWTAuthentication):
|
||||||
|
"""JWT authentication for user access tokens.
|
||||||
|
|
||||||
|
Validates user access tokens issued by the users exchange-access-token
|
||||||
|
endpoint and authenticates the user they were issued for. A bearer
|
||||||
|
token that does not verify against the user access token secret is
|
||||||
|
deferred to the next authentication backend; a token that does verify
|
||||||
|
but carries wrong claims is rejected.
|
||||||
|
|
||||||
|
When the feature is disabled (USER_ACCESS_TOKEN_ENABLED=False), the
|
||||||
|
backend is entirely inert: `BaseJWTAuthentication.authenticate`
|
||||||
|
returns None before reading the Authorization header, deferring every
|
||||||
|
request to the next authentication backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the backend with user access token settings."""
|
||||||
|
super().__init__(
|
||||||
|
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
|
||||||
|
token_type=settings.USER_ACCESS_TOKEN_TYPE,
|
||||||
|
is_enabled=settings.USER_ACCESS_TOKEN_ENABLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_payload(self, payload):
|
||||||
|
"""Validate the token type and the issuance-audit claim.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationFailed: If the token verified against the user
|
||||||
|
access token secret but does not carry the expected
|
||||||
|
claims, or if the issuing application lost its grant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if payload.get("token_type") != settings.USER_ACCESS_TOKEN_TYPE_CLAIM:
|
||||||
|
logger.warning("Wrong 'token_type' in user access token payload")
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||||
|
|
||||||
|
client_id = payload.get("client_id")
|
||||||
|
if not client_id:
|
||||||
|
logger.warning("Missing 'client_id' in user access token payload")
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||||
|
|
||||||
|
if not Application.has_active_scope(client_id, ApplicationScope.USERS_SESSION):
|
||||||
|
logger.warning(
|
||||||
|
"User access token refused: application '%s' no longer "
|
||||||
|
"holds the '%s' grant",
|
||||||
|
client_id,
|
||||||
|
ApplicationScope.USERS_SESSION,
|
||||||
|
)
|
||||||
|
raise exceptions.AuthenticationFailed("Application access revoked.")
|
||||||
@@ -48,6 +48,7 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
|
|
||||||
self.is_enabled = is_enabled
|
self.is_enabled = is_enabled
|
||||||
self._token_service = None
|
self._token_service = None
|
||||||
|
self._token_type = token_type
|
||||||
|
|
||||||
if not self.is_enabled:
|
if not self.is_enabled:
|
||||||
return
|
return
|
||||||
@@ -73,7 +74,10 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
|
|
||||||
auth_header = authentication.get_authorization_header(request).split()
|
auth_header = authentication.get_authorization_header(request).split()
|
||||||
|
|
||||||
if not auth_header or auth_header[0].lower() != b"bearer":
|
if (
|
||||||
|
not auth_header
|
||||||
|
or auth_header[0].lower() != self._token_type.lower().encode()
|
||||||
|
):
|
||||||
# Defer to next authentication backend
|
# Defer to next authentication backend
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -159,7 +163,7 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
|
|
||||||
def authenticate_header(self, request):
|
def authenticate_header(self, request):
|
||||||
"""Return authentication scheme for WWW-Authenticate header."""
|
"""Return authentication scheme for WWW-Authenticate header."""
|
||||||
return "Bearer"
|
return self._token_type
|
||||||
|
|
||||||
def authenticate_credentials(self, token):
|
def authenticate_credentials(self, token):
|
||||||
"""Validate JWT token and return authenticated user.
|
"""Validate JWT token and return authenticated user.
|
||||||
|
|||||||
@@ -20,8 +20,60 @@ class BaseScopePermission(permissions.BasePermission):
|
|||||||
|
|
||||||
scope_map: Dict[str, str] = {}
|
scope_map: Dict[str, str] = {}
|
||||||
|
|
||||||
|
def get_required_scope(self, view):
|
||||||
|
"""Return the scope required by the view's current action.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The required scope, or None for an unsupported method so
|
||||||
|
DRF's router can answer 405.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PermissionDenied: If the action is not in scope_map (deny by
|
||||||
|
default).
|
||||||
|
"""
|
||||||
|
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
|
||||||
|
action = getattr(view, "action", None)
|
||||||
|
if not action:
|
||||||
|
# DRF routers return a 405 for unsupported methods
|
||||||
|
return None
|
||||||
|
|
||||||
|
required_scope = self.scope_map.get(action)
|
||||||
|
if not required_scope:
|
||||||
|
# Action not in scope_map, deny by default
|
||||||
|
raise exceptions.PermissionDenied(
|
||||||
|
f"Insufficient permissions. Required scope: {required_scope}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return required_scope
|
||||||
|
|
||||||
|
def get_token_scopes(self, request):
|
||||||
|
"""Extract and normalize the scopes claimed by the token."""
|
||||||
|
token_scopes = (request.auth or {}).get("scope")
|
||||||
|
|
||||||
|
if not token_scopes:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Ensure scopes is a list (handle both list and space-separated string)
|
||||||
|
if isinstance(token_scopes, str):
|
||||||
|
token_scopes = token_scopes.split()
|
||||||
|
|
||||||
|
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
|
||||||
|
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
|
||||||
|
|
||||||
|
return self.strip_scope_prefix(token_scopes)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def strip_scope_prefix(token_scopes):
|
||||||
|
"""Strip the OIDC resource server prefix, when configured."""
|
||||||
|
if settings.OIDC_RS_SCOPES_PREFIX:
|
||||||
|
return [
|
||||||
|
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
|
||||||
|
for scope in token_scopes
|
||||||
|
]
|
||||||
|
return token_scopes
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
"""Check if the JWT token contains the required scope for this action.
|
"""Check if the token claims the scope required by this action.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: DRF request object with authenticated user
|
request: DRF request object with authenticated user
|
||||||
@@ -33,38 +85,15 @@ class BaseScopePermission(permissions.BasePermission):
|
|||||||
Raises:
|
Raises:
|
||||||
PermissionDenied: If required scope is missing from token
|
PermissionDenied: If required scope is missing from token
|
||||||
"""
|
"""
|
||||||
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
|
required_scope = self.get_required_scope(view)
|
||||||
action = getattr(view, "action", None)
|
if required_scope is None:
|
||||||
if not action:
|
|
||||||
# DRF routers return a 405 for unsupported methods
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
required_scope = self.scope_map.get(action)
|
token_scopes = self.get_token_scopes(request)
|
||||||
if not required_scope:
|
|
||||||
# Action not in scope_map, deny by default
|
|
||||||
raise exceptions.PermissionDenied(
|
|
||||||
f"Insufficient permissions. Required scope: {required_scope}"
|
|
||||||
)
|
|
||||||
|
|
||||||
token_payload = request.auth
|
|
||||||
token_scopes = token_payload.get("scope")
|
|
||||||
|
|
||||||
if not token_scopes:
|
if not token_scopes:
|
||||||
raise exceptions.PermissionDenied("Insufficient permissions.")
|
raise exceptions.PermissionDenied("Insufficient permissions.")
|
||||||
|
|
||||||
# Ensure scopes is a list (handle both list and space-separated string)
|
|
||||||
if isinstance(token_scopes, str):
|
|
||||||
token_scopes = token_scopes.split()
|
|
||||||
|
|
||||||
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
|
|
||||||
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
|
|
||||||
|
|
||||||
if settings.OIDC_RS_SCOPES_PREFIX:
|
|
||||||
token_scopes = [
|
|
||||||
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
|
|
||||||
for scope in token_scopes
|
|
||||||
]
|
|
||||||
|
|
||||||
if required_scope not in token_scopes:
|
if required_scope not in token_scopes:
|
||||||
raise exceptions.PermissionDenied(
|
raise exceptions.PermissionDenied(
|
||||||
f"Insufficient permissions. Required scope: {required_scope}"
|
f"Insufficient permissions. Required scope: {required_scope}"
|
||||||
@@ -73,6 +102,37 @@ class BaseScopePermission(permissions.BasePermission):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationScopePermission(BaseScopePermission):
|
||||||
|
"""Scope-based permission for application-authenticated endpoints."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def strip_scope_prefix(token_scopes):
|
||||||
|
"""Compare application scopes verbatim."""
|
||||||
|
return token_scopes
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
"""Check the scope claim, then the grant recorded in the database."""
|
||||||
|
granted = super().has_permission(request, view)
|
||||||
|
|
||||||
|
required_scope = self.get_required_scope(view)
|
||||||
|
|
||||||
|
if granted and required_scope:
|
||||||
|
client_id = (request.auth or {}).get("client_id")
|
||||||
|
|
||||||
|
if not models.Application.has_active_scope(client_id, required_scope):
|
||||||
|
logger.warning(
|
||||||
|
"Application '%s' presented scope '%s' without a matching "
|
||||||
|
"grant in database",
|
||||||
|
client_id,
|
||||||
|
required_scope,
|
||||||
|
)
|
||||||
|
raise exceptions.PermissionDenied(
|
||||||
|
f"Application is not granted the required scope: {required_scope}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return granted
|
||||||
|
|
||||||
|
|
||||||
class HasRequiredRoomScope(BaseScopePermission):
|
class HasRequiredRoomScope(BaseScopePermission):
|
||||||
"""Permission class for Room-related operations."""
|
"""Permission class for Room-related operations."""
|
||||||
|
|
||||||
@@ -86,6 +146,14 @@ class HasRequiredRoomScope(BaseScopePermission):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HasRequiredUserScope(ApplicationScopePermission):
|
||||||
|
"""Scope-based permissions for the external user endpoints."""
|
||||||
|
|
||||||
|
scope_map = {
|
||||||
|
"generate_transit_code": models.ApplicationScope.USERS_SESSION,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class RoomPermissions(permissions.BasePermission):
|
class RoomPermissions(permissions.BasePermission):
|
||||||
"""Permissions applying to the room API endpoint."""
|
"""Permissions applying to the room API endpoint."""
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import copy
|
|||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import check_password
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
|
|
||||||
@@ -22,11 +21,15 @@ from rest_framework import (
|
|||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
status as drf_status,
|
status as drf_status,
|
||||||
)
|
)
|
||||||
|
from rest_framework.generics import get_object_or_404
|
||||||
|
|
||||||
from core import analytics, api, models
|
from core import analytics, api, models
|
||||||
|
from core.api.exceptions import ensure_room_not_deleted
|
||||||
from core.api.feature_flag import FeatureFlag
|
from core.api.feature_flag import FeatureFlag
|
||||||
|
from core.api.locks import room_locked_for_update
|
||||||
from core.services.jwt_token import JwtTokenService
|
from core.services.jwt_token import JwtTokenService
|
||||||
from core.services.room_management import RoomManagement
|
from core.services.room_management import RoomManagement, RoomManagementException
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
|
|
||||||
from ..services.provisional_user_service import (
|
from ..services.provisional_user_service import (
|
||||||
ProvisionalUserCreationDisabledError,
|
ProvisionalUserCreationDisabledError,
|
||||||
@@ -74,7 +77,7 @@ class ApplicationViewSet(viewsets.ViewSet):
|
|||||||
except models.Application.DoesNotExist as e:
|
except models.Application.DoesNotExist as e:
|
||||||
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
|
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
|
||||||
|
|
||||||
if not check_password(client_secret, application.client_secret):
|
if not application.check_client_secret(client_secret):
|
||||||
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
|
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
|
||||||
|
|
||||||
if not application.is_active:
|
if not application.is_active:
|
||||||
@@ -142,6 +145,7 @@ class ApplicationViewSet(viewsets.ViewSet):
|
|||||||
|
|
||||||
class RoomViewSet(
|
class RoomViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
|
mixins.DestroyModelMixin,
|
||||||
mixins.RetrieveModelMixin,
|
mixins.RetrieveModelMixin,
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
mixins.UpdateModelMixin,
|
mixins.UpdateModelMixin,
|
||||||
@@ -159,9 +163,13 @@ class RoomViewSet(
|
|||||||
- create: Create a new room owned by the user (requires 'rooms:create' scope)
|
- create: Create a new room owned by the user (requires 'rooms:create' scope)
|
||||||
- partial_update: Update a room's access level and configuration, for
|
- partial_update: Update a room's access level and configuration, for
|
||||||
administrators and owners only (requires 'rooms:update' scope)
|
administrators and owners only (requires 'rooms:update' scope)
|
||||||
|
- destroy: Soft delete a room and close its LiveKit room, for owners only
|
||||||
|
(requires 'rooms:delete' scope)
|
||||||
|
|
||||||
|
Detail operations on a soft-deleted room answer 410 Gone.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
http_method_names = ["get", "post", "patch", "head", "options"]
|
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
|
||||||
|
|
||||||
authentication_classes = [
|
authentication_classes = [
|
||||||
authentication.ApplicationJWTAuthentication,
|
authentication.ApplicationJWTAuthentication,
|
||||||
@@ -176,6 +184,17 @@ class RoomViewSet(
|
|||||||
queryset = models.Room.objects.all()
|
queryset = models.Room.objects.all()
|
||||||
serializer_class = serializers.RoomSerializer
|
serializer_class = serializers.RoomSerializer
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
"""Get the room, answer 410 if it has been deleted.
|
||||||
|
|
||||||
|
Permissions are checked first so a deleted room is only revealed to
|
||||||
|
users who would have been granted access to it.
|
||||||
|
"""
|
||||||
|
room = get_object_or_404(models.Room.all_objects, pk=self.kwargs["pk"])
|
||||||
|
self.check_object_permissions(self.request, room)
|
||||||
|
ensure_room_not_deleted(room)
|
||||||
|
return room
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
"""Limit listed rooms to the ones related to the authenticated user."""
|
"""Limit listed rooms to the ones related to the authenticated user."""
|
||||||
|
|
||||||
@@ -239,15 +258,28 @@ class RoomViewSet(
|
|||||||
|
|
||||||
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
|
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
"""Soft delete the room, close its LiveKit room, then log and track it."""
|
||||||
|
try:
|
||||||
|
RoomManagement.soft_delete(instance)
|
||||||
|
except RoomManagementException as e:
|
||||||
|
raise drf_exceptions.APIException(
|
||||||
|
"Could not delete the room, please try again."
|
||||||
|
) from e
|
||||||
|
self._track_room_event(instance, analytics.AnalyticsEvent.ROOM_DELETED)
|
||||||
|
|
||||||
def perform_update(self, serializer: serializers.RoomSerializer):
|
def perform_update(self, serializer: serializers.RoomSerializer):
|
||||||
"""Persist the room update, sync it to LiveKit, then log and track it."""
|
"""Persist the room update under a row lock, sync it to LiveKit, then track it."""
|
||||||
|
|
||||||
previous_values = {
|
with room_locked_for_update(serializer.instance) as locked_room:
|
||||||
"access_level": serializer.instance.access_level,
|
serializer.instance = locked_room
|
||||||
"configuration": copy.deepcopy(serializer.instance.configuration),
|
|
||||||
}
|
|
||||||
|
|
||||||
room = serializer.save()
|
previous_values = {
|
||||||
|
"access_level": locked_room.access_level,
|
||||||
|
"configuration": copy.deepcopy(locked_room.configuration),
|
||||||
|
}
|
||||||
|
|
||||||
|
room = serializer.save()
|
||||||
|
|
||||||
# Report the fields that actually changed, not the ones that were submitted.
|
# Report the fields that actually changed, not the ones that were submitted.
|
||||||
updated_fields = sorted(
|
updated_fields = sorted(
|
||||||
@@ -265,3 +297,63 @@ class RoomViewSet(
|
|||||||
updated_fields=updated_fields,
|
updated_fields=updated_fields,
|
||||||
previous_access_level=previous_values["access_level"],
|
previous_access_level=previous_values["access_level"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserViewSet(viewsets.GenericViewSet):
|
||||||
|
"""Application-delegated API for user operations.
|
||||||
|
|
||||||
|
Provides JWT-authenticated access to user operations for external
|
||||||
|
applications acting on behalf of users. All operations are
|
||||||
|
scope-based. Meant to grow with the other user actions exposed to
|
||||||
|
third parties.
|
||||||
|
|
||||||
|
Supported operations:
|
||||||
|
- transit-code: Mint a single-use transit code for the delegated user
|
||||||
|
(requires 'users:session' scope)
|
||||||
|
"""
|
||||||
|
|
||||||
|
authentication_classes = [
|
||||||
|
authentication.ApplicationJWTAuthentication,
|
||||||
|
]
|
||||||
|
permission_classes = [
|
||||||
|
api.permissions.IsAuthenticated & permissions.HasRequiredUserScope
|
||||||
|
]
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=False,
|
||||||
|
methods=["post"],
|
||||||
|
url_path="transit-code",
|
||||||
|
url_name="transit-code",
|
||||||
|
)
|
||||||
|
@FeatureFlag.require("user_access_token")
|
||||||
|
def generate_transit_code(self, request):
|
||||||
|
"""Mint a transit code for the delegated user.
|
||||||
|
|
||||||
|
Returns a short-lived, single-use opaque code to pass to an embedded
|
||||||
|
frontend (e.g. via a URL fragment when cookies are unavailable). The
|
||||||
|
frontend exchanges it once on
|
||||||
|
POST /api/v1.0/users/exchange-access-token/ for a JWT access token,
|
||||||
|
equivalent to session-cookie authentication and never exposed in a URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not request.auth or not request.auth.get("client_id"):
|
||||||
|
raise drf_exceptions.AuthenticationFailed("Invalid application token.")
|
||||||
|
|
||||||
|
client_id = request.auth["client_id"]
|
||||||
|
|
||||||
|
code = TransitCodeService().create_code(request.user, client_id=client_id)
|
||||||
|
|
||||||
|
# Log for auditing
|
||||||
|
logger.info(
|
||||||
|
"Transit code issued: user_id=%s, client_id=%s",
|
||||||
|
request.user.id,
|
||||||
|
client_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return drf_response.Response(
|
||||||
|
{
|
||||||
|
"transit_code": code,
|
||||||
|
"expires_in": settings.TRANSIT_CODE_TTL,
|
||||||
|
},
|
||||||
|
status=drf_status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from logging import getLogger
|
|||||||
from django.contrib.auth.hashers import identify_hasher, make_password
|
from django.contrib.auth.hashers import identify_hasher, make_password
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
from .hashers import CLIENT_SECRET_HASH_PATTERN
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +26,14 @@ class SecretField(models.CharField):
|
|||||||
|
|
||||||
secret = getattr(model_instance, self.attname)
|
secret = getattr(model_instance, self.attname)
|
||||||
|
|
||||||
|
if CLIENT_SECRET_HASH_PATTERN.fullmatch(secret):
|
||||||
|
logger.debug(
|
||||||
|
"%s: %s is already hashed with sha256.",
|
||||||
|
model_instance,
|
||||||
|
self.attname,
|
||||||
|
)
|
||||||
|
return secret
|
||||||
|
|
||||||
try:
|
try:
|
||||||
hasher = identify_hasher(secret)
|
hasher = identify_hasher(secret)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -40,4 +50,4 @@ class SecretField(models.CharField):
|
|||||||
setattr(model_instance, self.attname, hashed_secret)
|
setattr(model_instance, self.attname, hashed_secret)
|
||||||
return hashed_secret
|
return hashed_secret
|
||||||
|
|
||||||
return super().pre_save(model_instance, add)
|
return super().pre_save(model_instance, add) # pylint: disable=no-member
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Application secrets only: keep fast hashing out of PASSWORD_HASHERS.
|
||||||
|
|
||||||
|
Secrets must be securely randomly generated, not human-chosen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.contrib.auth.hashers import check_password
|
||||||
|
from django.utils.crypto import constant_time_compare
|
||||||
|
from django.utils.encoding import force_bytes
|
||||||
|
|
||||||
|
CLIENT_SECRET_HASH_ALGORITHM = "sha256" # noqa: S105
|
||||||
|
CLIENT_SECRET_HASH_VERSION = "v0" # noqa: S105
|
||||||
|
CLIENT_SECRET_HASH_PREFIX = (
|
||||||
|
f"{CLIENT_SECRET_HASH_ALGORITHM}${CLIENT_SECRET_HASH_VERSION}$"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Accept only the versioned format: sha256$v0$<digest>.
|
||||||
|
CLIENT_SECRET_HASH_PATTERN = re.compile(
|
||||||
|
rf"{re.escape(CLIENT_SECRET_HASH_PREFIX)}(?P<digest>[0-9a-f]{{64}})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(raw_secret):
|
||||||
|
"""Return the hex SHA-256 digest of a raw secret."""
|
||||||
|
return hashlib.sha256(force_bytes(raw_secret)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_client_secret(raw_secret):
|
||||||
|
"""Hash a machine-generated application secret without key stretching."""
|
||||||
|
return f"{CLIENT_SECRET_HASH_PREFIX}{_digest(raw_secret)}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_client_secret(raw_secret, encoded):
|
||||||
|
"""Verify the versioned application format or a legacy Django password hash."""
|
||||||
|
if raw_secret is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
match = CLIENT_SECRET_HASH_PATTERN.fullmatch(encoded)
|
||||||
|
|
||||||
|
# Legacy path
|
||||||
|
if not match:
|
||||||
|
return check_password(raw_secret, encoded)
|
||||||
|
|
||||||
|
return constant_time_compare(match["digest"], _digest(raw_secret))
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Purge inactive rooms."""
|
"""Purge inactive and soft-deleted rooms."""
|
||||||
|
|
||||||
|
import operator
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from functools import reduce
|
||||||
from itertools import batched
|
from itertools import batched
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
@@ -22,10 +24,12 @@ class Command(BaseCommand):
|
|||||||
- rooms which were last started before that period
|
- rooms which were last started before that period
|
||||||
- rooms never started and created before that period
|
- rooms never started and created before that period
|
||||||
|
|
||||||
|
Also delete rooms soft deleted more than ROOM_DELETED_RETENTION_DAYS days ago.
|
||||||
|
|
||||||
Rooms holding a saved recording that has not expired are kept.
|
Rooms holding a saved recording that has not expired are kept.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
help = "Purge inactive rooms"
|
help = "Purge inactive and soft-deleted rooms"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -35,53 +39,68 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
"""Browse inactive rooms and delete them chunk by chunk."""
|
"""Browse purgeable rooms and delete them chunk by chunk."""
|
||||||
|
|
||||||
if not settings.ROOM_INACTIVITY_DELETION_DAYS:
|
if not (
|
||||||
|
settings.ROOM_INACTIVITY_DELETION_DAYS
|
||||||
|
or settings.ROOM_DELETED_RETENTION_DAYS
|
||||||
|
):
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
"Purging inactive rooms is disabled "
|
"Purging rooms is disabled (neither ROOM_INACTIVITY_DELETION_DAYS "
|
||||||
"(ROOM_INACTIVITY_DELETION_DAYS is not set)."
|
"nor ROOM_DELETED_RETENTION_DAYS is set)."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
inactive_rooms = self.get_inactive_rooms(now)
|
purgeable_rooms = self.get_purgeable_rooms(now)
|
||||||
|
|
||||||
inactive_count = inactive_rooms.count()
|
purgeable_count = purgeable_rooms.count()
|
||||||
if not inactive_count:
|
if not purgeable_count:
|
||||||
self.stdout.write("No inactive room to purge.")
|
self.stdout.write("No room to purge.")
|
||||||
return
|
return
|
||||||
|
|
||||||
if options["dry_run"]:
|
if options["dry_run"]:
|
||||||
self.stdout.write(
|
self.stdout.write(f"[dry-run] {purgeable_count} room(s) would be purged:")
|
||||||
f"[dry-run] {inactive_count} inactive room(s) would be purged:"
|
rooms = purgeable_rooms.values_list("name", "deleted_at")
|
||||||
)
|
for name, deleted_at in rooms.iterator(chunk_size=CHUNK_SIZE):
|
||||||
names = inactive_rooms.values_list("name", flat=True)
|
self.stdout.write(f"- {name}{' (deleted)' if deleted_at else ''}")
|
||||||
for name in names.iterator(chunk_size=CHUNK_SIZE):
|
|
||||||
self.stdout.write(f"- {name}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
purged_count = 0
|
purged_count = 0
|
||||||
rooms = inactive_rooms.values_list("pk", "slug").iterator(chunk_size=CHUNK_SIZE)
|
rooms = purgeable_rooms.values_list("pk", "slug", "deleted_at").iterator(
|
||||||
|
chunk_size=CHUNK_SIZE
|
||||||
|
)
|
||||||
for chunk in batched(rooms, CHUNK_SIZE, strict=False):
|
for chunk in batched(rooms, CHUNK_SIZE, strict=False):
|
||||||
for room_id, slug in chunk:
|
for room_id, slug, deleted_at in chunk:
|
||||||
logger.info("Purging inactive room %s (%s)", room_id, slug)
|
state = "deleted" if deleted_at else "inactive"
|
||||||
|
logger.info("Purging %s room %s (%s)", state, room_id, slug)
|
||||||
|
|
||||||
_, deleted_by_model = inactive_rooms.filter(
|
_, deleted_by_model = purgeable_rooms.filter(
|
||||||
pk__in=[room_id for room_id, _ in chunk]
|
pk__in=[room_id for room_id, _, _ in chunk]
|
||||||
).delete()
|
).delete()
|
||||||
purged_count += deleted_by_model.get("core.Room", 0)
|
purged_count += deleted_by_model.get("core.Room", 0)
|
||||||
|
|
||||||
self.stdout.write(f"Purged {purged_count} inactive room(s).")
|
self.stdout.write(f"Purged {purged_count} room(s).")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_inactive_rooms(now):
|
def get_purgeable_rooms(now):
|
||||||
"""Return the rooms inactive for too long that no recording protects."""
|
"""Return the inactive or long-deleted rooms that no recording protects.
|
||||||
|
|
||||||
threshold = now - timedelta(days=settings.ROOM_INACTIVITY_DELETION_DAYS)
|
Soft-deleted rooms are hidden by `Room.objects`, hence `Room.all_objects`.
|
||||||
is_inactive = Q(last_started_at__lt=threshold) | Q(
|
"""
|
||||||
last_started_at__isnull=True, created_at__lt=threshold
|
|
||||||
)
|
conditions = []
|
||||||
|
if settings.ROOM_INACTIVITY_DELETION_DAYS:
|
||||||
|
threshold = now - timedelta(days=settings.ROOM_INACTIVITY_DELETION_DAYS)
|
||||||
|
conditions.append(
|
||||||
|
Q(last_started_at__lt=threshold)
|
||||||
|
| Q(last_started_at__isnull=True, created_at__lt=threshold)
|
||||||
|
)
|
||||||
|
if settings.ROOM_DELETED_RETENTION_DAYS:
|
||||||
|
deleted_before = now - timedelta(days=settings.ROOM_DELETED_RETENTION_DAYS)
|
||||||
|
conditions.append(Q(deleted_at__lt=deleted_before))
|
||||||
|
if not conditions:
|
||||||
|
return Room.all_objects.none()
|
||||||
|
|
||||||
protected_recordings = Recording.objects.filter(
|
protected_recordings = Recording.objects.filter(
|
||||||
room=OuterRef("pk"), status__in=RecordingStatusChoices.saved_statuses()
|
room=OuterRef("pk"), status__in=RecordingStatusChoices.saved_statuses()
|
||||||
@@ -91,4 +110,6 @@ class Command(BaseCommand):
|
|||||||
created_at__gte=now - timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
|
created_at__gte=now - timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
|
||||||
)
|
)
|
||||||
|
|
||||||
return Room.objects.filter(is_inactive, ~Exists(protected_recordings))
|
return Room.all_objects.filter(
|
||||||
|
reduce(operator.or_, conditions), ~Exists(protected_recordings)
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Add a separate fast hash while preserving legacy credentials for rollback."""
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0024_room_last_started_at"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="application",
|
||||||
|
name="client_secret_sha256",
|
||||||
|
field=models.CharField(
|
||||||
|
max_length=255, null=True, blank=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-07-31 18:27
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0025_application_client_secret_sha256'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='application',
|
||||||
|
name='scopes',
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms'), ('users:session', 'Create user session tokens')], max_length=50), blank=True, default=list, size=None),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-10-07 18:45
|
||||||
|
|
||||||
|
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.models.functions import Upper
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
atomic = False
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0026_alter_application_scopes'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
AddIndexConcurrently(
|
||||||
|
model_name="user",
|
||||||
|
index=models.Index(
|
||||||
|
Upper("email"),
|
||||||
|
name="user_email_upper_idx",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-09-16 10:00
|
||||||
|
|
||||||
|
import django.db.models.manager
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0027_user_email_upper_idx'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='room',
|
||||||
|
name='deleted_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='room',
|
||||||
|
options={'default_manager_name': 'all_objects', 'ordering': ('name',), 'verbose_name': 'Room', 'verbose_name_plural': 'Rooms'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name='room',
|
||||||
|
managers=[
|
||||||
|
('all_objects', django.db.models.manager.Manager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
+165
-5
@@ -14,6 +14,7 @@ from typing import List, Optional
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import models as auth_models
|
from django.contrib.auth import models as auth_models
|
||||||
from django.contrib.auth.base_user import AbstractBaseUser
|
from django.contrib.auth.base_user import AbstractBaseUser
|
||||||
|
from django.contrib.auth.hashers import identify_hasher
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.core import mail, validators
|
from django.core import mail, validators
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
@@ -25,7 +26,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from lasuite.tools.email import get_domain_from_email
|
from lasuite.tools.email import get_domain_from_email
|
||||||
from timezone_field import TimeZoneField
|
from timezone_field import TimeZoneField
|
||||||
|
|
||||||
from . import fields, utils
|
from . import fields, hashers, utils
|
||||||
from .recording.enums import FileExtension
|
from .recording.enums import FileExtension
|
||||||
from .validators import sub_validator
|
from .validators import sub_validator
|
||||||
|
|
||||||
@@ -245,6 +246,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
name="unique_email_when_sub_is_null",
|
name="unique_email_when_sub_is_null",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email or self.admin_email or str(self.id)
|
return self.email or self.admin_email or str(self.id)
|
||||||
@@ -406,6 +410,33 @@ class ResourceAccess(BaseModel):
|
|||||||
return super().delete(*args, **kwargs)
|
return super().delete(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class RoomQuerySet(models.QuerySet):
|
||||||
|
"""QuerySet exposing the room lifecycle filters."""
|
||||||
|
|
||||||
|
def active(self):
|
||||||
|
"""Rooms that have not been soft deleted."""
|
||||||
|
return self.filter(deleted_at__isnull=True)
|
||||||
|
|
||||||
|
def deleted(self):
|
||||||
|
"""Rooms that have been soft deleted."""
|
||||||
|
return self.filter(deleted_at__isnull=False)
|
||||||
|
|
||||||
|
|
||||||
|
class RoomManager(models.Manager.from_queryset(RoomQuerySet)):
|
||||||
|
"""Manager hiding soft-deleted rooms, exposed as ``Room.objects``.
|
||||||
|
|
||||||
|
It is deliberately not the model's default manager: Django relies on
|
||||||
|
``_default_manager`` for unique validation, which must see deleted rooms as
|
||||||
|
they still guard their slug and pin code. Other object relations (e.g.
|
||||||
|
``recording.room``) go through the base manager and still resolve deleted
|
||||||
|
rooms, which keeps recordings and their notifications working.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Exclude soft-deleted rooms."""
|
||||||
|
return super().get_queryset().active()
|
||||||
|
|
||||||
|
|
||||||
class Room(Resource):
|
class Room(Resource):
|
||||||
"""Model for one room"""
|
"""Model for one room"""
|
||||||
|
|
||||||
@@ -429,6 +460,7 @@ class Room(Resource):
|
|||||||
verbose_name=_("Visio room configuration"),
|
verbose_name=_("Visio room configuration"),
|
||||||
help_text=_("Values for Visio parameters to configure the room."),
|
help_text=_("Values for Visio parameters to configure the room."),
|
||||||
)
|
)
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
pin_code = models.CharField(
|
pin_code = models.CharField(
|
||||||
max_length=None,
|
max_length=None,
|
||||||
unique=True,
|
unique=True,
|
||||||
@@ -445,8 +477,13 @@ class Room(Resource):
|
|||||||
editable=False,
|
editable=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Managers
|
||||||
|
objects = RoomManager()
|
||||||
|
all_objects = models.Manager.from_queryset(RoomQuerySet)()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "meet_room"
|
db_table = "meet_room"
|
||||||
|
default_manager_name = "all_objects"
|
||||||
ordering = ("name",)
|
ordering = ("name",)
|
||||||
verbose_name = _("Room")
|
verbose_name = _("Room")
|
||||||
verbose_name_plural = _("Rooms")
|
verbose_name_plural = _("Rooms")
|
||||||
@@ -469,6 +506,28 @@ class Room(Resource):
|
|||||||
)
|
)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_deleted(self):
|
||||||
|
"""Whether the room has been soft deleted."""
|
||||||
|
return self.deleted_at is not None
|
||||||
|
|
||||||
|
def soft_delete(self):
|
||||||
|
"""Soft delete the room.
|
||||||
|
|
||||||
|
The room is hidden from the default manager making it impossible to
|
||||||
|
list, join or update.
|
||||||
|
"""
|
||||||
|
deleted_at = timezone.now()
|
||||||
|
|
||||||
|
updated = Room.all_objects.filter(pk=self.pk, deleted_at__isnull=True).update(
|
||||||
|
deleted_at=deleted_at
|
||||||
|
)
|
||||||
|
|
||||||
|
if not updated:
|
||||||
|
raise RuntimeError("This room is already deleted.")
|
||||||
|
|
||||||
|
self.deleted_at = deleted_at
|
||||||
|
|
||||||
def clean_fields(self, exclude=None):
|
def clean_fields(self, exclude=None):
|
||||||
"""
|
"""
|
||||||
Automatically generate the slug from the name and make sure it does not look like a UUID.
|
Automatically generate the slug from the name and make sure it does not look like a UUID.
|
||||||
@@ -504,7 +563,7 @@ class Room(Resource):
|
|||||||
|
|
||||||
for _ in range(settings.ROOM_TELEPHONY_PIN_MAX_RETRIES):
|
for _ in range(settings.ROOM_TELEPHONY_PIN_MAX_RETRIES):
|
||||||
pin_code = str(secrets.randbelow(max_value)).zfill(length)
|
pin_code = str(secrets.randbelow(max_value)).zfill(length)
|
||||||
if not Room.objects.filter(pin_code=pin_code).exists():
|
if not Room.all_objects.filter(pin_code=pin_code).exists():
|
||||||
return pin_code
|
return pin_code
|
||||||
|
|
||||||
# Log a warning as a temporary measure until backend observability is implemented.
|
# Log a warning as a temporary measure until backend observability is implemented.
|
||||||
@@ -684,11 +743,38 @@ class Recording(BaseModel):
|
|||||||
return {
|
return {
|
||||||
"destroy": is_owner_or_admin and is_final_status,
|
"destroy": is_owner_or_admin and is_final_status,
|
||||||
"partial_update": False,
|
"partial_update": False,
|
||||||
"retrieve": is_owner_or_admin,
|
"retrieve": bool(roles),
|
||||||
"stop": is_owner_or_admin and not is_final_status,
|
"stop": is_owner_or_admin and not is_final_status,
|
||||||
"update": False,
|
"update": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def grant_initial_accesses(self, starter):
|
||||||
|
"""Grant the starter and the room's owners/administrators access to the recording.
|
||||||
|
|
||||||
|
The starter's access is created first, so it is the oldest one: the
|
||||||
|
transcript summary, which goes to a single user, is sent to them.
|
||||||
|
|
||||||
|
Room roles are copied when the recording starts: later changes to the
|
||||||
|
room's accesses do not affect existing recordings.
|
||||||
|
"""
|
||||||
|
room_privileged_user_ids = set(
|
||||||
|
self.room.accesses.filter(
|
||||||
|
role__in=[RoleChoices.OWNER, RoleChoices.ADMIN]
|
||||||
|
).values_list("user_id", flat=True)
|
||||||
|
)
|
||||||
|
starter_is_privileged = starter.id in room_privileged_user_ids
|
||||||
|
room_privileged_user_ids.discard(starter.id)
|
||||||
|
|
||||||
|
RecordingAccess.objects.create(
|
||||||
|
user=starter,
|
||||||
|
role=RoleChoices.OWNER if starter_is_privileged else RoleChoices.MEMBER,
|
||||||
|
recording=self,
|
||||||
|
)
|
||||||
|
RecordingAccess.objects.bulk_create(
|
||||||
|
RecordingAccess(user_id=user_id, role=RoleChoices.OWNER, recording=self)
|
||||||
|
for user_id in room_privileged_user_ids
|
||||||
|
)
|
||||||
|
|
||||||
def is_savable(self) -> bool:
|
def is_savable(self) -> bool:
|
||||||
"""Determine if the recording can be saved based on its current status."""
|
"""Determine if the recording can be saved based on its current status."""
|
||||||
|
|
||||||
@@ -799,6 +885,7 @@ class ApplicationScope(models.TextChoices):
|
|||||||
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
|
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
|
||||||
ROOMS_UPDATE = "rooms:update", _("Update rooms")
|
ROOMS_UPDATE = "rooms:update", _("Update rooms")
|
||||||
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
|
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
|
||||||
|
USERS_SESSION = "users:session", _("Create user session tokens")
|
||||||
|
|
||||||
|
|
||||||
class Application(BaseModel):
|
class Application(BaseModel):
|
||||||
@@ -824,6 +911,9 @@ class Application(BaseModel):
|
|||||||
default=utils.generate_client_secret,
|
default=utils.generate_client_secret,
|
||||||
help_text=_("Hashed on Save. Copy it now if this is a new secret."),
|
help_text=_("Hashed on Save. Copy it now if this is a new secret."),
|
||||||
)
|
)
|
||||||
|
client_secret_sha256 = models.CharField(
|
||||||
|
max_length=255, null=True, blank=True, editable=False
|
||||||
|
)
|
||||||
scopes = ArrayField(
|
scopes = ArrayField(
|
||||||
models.CharField(max_length=50, choices=ApplicationScope.choices),
|
models.CharField(max_length=50, choices=ApplicationScope.choices),
|
||||||
default=list,
|
default=list,
|
||||||
@@ -839,14 +929,84 @@ class Application(BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name!s}"
|
return f"{self.name!s}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Populate the fast hash on creation when the raw secret is available."""
|
||||||
|
if self._state.adding:
|
||||||
|
# Prevent hashing an existing hash instead of the original secret
|
||||||
|
try:
|
||||||
|
if not hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(self.client_secret):
|
||||||
|
identify_hasher(self.client_secret)
|
||||||
|
except ValueError:
|
||||||
|
# SecretField.pre_save hashes the legacy field after this method
|
||||||
|
self.client_secret_sha256 = hashers.hash_client_secret(
|
||||||
|
self.client_secret
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def rotate_client_secret(self):
|
||||||
|
"""Persist a new generated secret and return its raw value to the caller.
|
||||||
|
|
||||||
|
This is the only supported rotation path while both credential fields coexist.
|
||||||
|
Direct writes may leave a stale fast hash that still accepts the revoked secret,
|
||||||
|
while saving a stale instance may restore previous credentials.
|
||||||
|
|
||||||
|
This transitional risk is accepted until the legacy field is removed
|
||||||
|
and rotation writes only the fast hash.
|
||||||
|
"""
|
||||||
|
secret = utils.generate_client_secret()
|
||||||
|
self.client_secret = secret
|
||||||
|
self.client_secret_sha256 = hashers.hash_client_secret(secret)
|
||||||
|
self.save(update_fields=["client_secret", "client_secret_sha256"])
|
||||||
|
return secret
|
||||||
|
|
||||||
|
def check_client_secret(self, raw_secret):
|
||||||
|
"""Verify the secret and lazily populate its fast hash for future logins."""
|
||||||
|
if self.client_secret_sha256 is not None:
|
||||||
|
return hashers.verify_client_secret(raw_secret, self.client_secret_sha256)
|
||||||
|
|
||||||
|
original_hash = self.client_secret
|
||||||
|
if not hashers.verify_client_secret(raw_secret, original_hash):
|
||||||
|
return False
|
||||||
|
|
||||||
|
encoded = hashers.hash_client_secret(raw_secret)
|
||||||
|
updated = Application.objects.filter(
|
||||||
|
pk=self.pk, client_secret=original_hash, client_secret_sha256__isnull=True
|
||||||
|
).update(client_secret_sha256=encoded)
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
self.client_secret_sha256 = encoded
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.refresh_from_db()
|
||||||
|
except Application.DoesNotExist:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_hash = self.client_secret_sha256 or self.client_secret
|
||||||
|
return hashers.verify_client_secret(raw_secret, current_hash)
|
||||||
|
|
||||||
def can_delegate_email(self, email):
|
def can_delegate_email(self, email):
|
||||||
"""Check if this application can delegate the given email."""
|
"""Check if this application can delegate the given email."""
|
||||||
|
|
||||||
if not self.allowed_domains.exists():
|
allowed_domains = {d.domain for d in self.allowed_domains.all()}
|
||||||
|
if not allowed_domains:
|
||||||
return True # No domain restrictions
|
return True # No domain restrictions
|
||||||
|
|
||||||
domain = get_domain_from_email(email)
|
domain = get_domain_from_email(email)
|
||||||
return self.allowed_domains.filter(domain__iexact=domain).exists()
|
return bool(domain) and domain.lower() in allowed_domains
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def has_active_scope(cls, client_id, scope) -> bool:
|
||||||
|
"""Check that an active application holds a scope."""
|
||||||
|
if not client_id or not scope:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return cls.objects.filter(
|
||||||
|
client_id=client_id,
|
||||||
|
is_active=True,
|
||||||
|
scopes__contains=[scope],
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
class ApplicationDomain(BaseModel):
|
class ApplicationDomain(BaseModel):
|
||||||
|
|||||||
@@ -7,18 +7,18 @@ from datetime import datetime, timezone
|
|||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import send_mail
|
from django.utils.translation import get_language, gettext, gettext_noop, override
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from django.utils.translation import get_language, gettext, override
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import requests
|
import requests
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
from celery.exceptions import OperationalError as CeleryOperationalError
|
||||||
from livekit import api as livekit_api
|
from livekit import api as livekit_api
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
||||||
|
from core.tasks.mail import send_email
|
||||||
from core.utils import generate_download_s3_url
|
from core.utils import generate_download_s3_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -68,18 +68,20 @@ class NotificationService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _notify_user_by_email(recording) -> bool:
|
def _notify_user_by_email(recording) -> bool:
|
||||||
"""
|
"""
|
||||||
Send an email notification to recording owners when their recording is ready.
|
Queue an email notification to every user with access to the recording
|
||||||
|
when it is ready: its starter and the room's owners/administrators.
|
||||||
|
|
||||||
The email includes a direct link that redirects owners to a dedicated download
|
The email includes a direct link that redirects owners to a dedicated download
|
||||||
page in the frontend where they can access their specific recording.
|
page in the frontend where they can access their specific recording.
|
||||||
|
|
||||||
|
Rendering and SMTP delivery happen in a Celery worker (``send_email``), so
|
||||||
|
this only does one DB query and enqueues one message per owner.
|
||||||
|
Returns True when every email was queued (or sent, if Celery is disabled).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
owner_accesses = (
|
owner_accesses = (
|
||||||
models.RecordingAccess.objects.select_related("user")
|
models.RecordingAccess.objects.select_related("user")
|
||||||
.filter(
|
.filter(recording_id=recording.id)
|
||||||
role=models.RoleChoices.OWNER,
|
|
||||||
recording_id=recording.id,
|
|
||||||
)
|
|
||||||
.order_by("created_at")
|
.order_by("created_at")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -88,10 +90,6 @@ class NotificationService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"brandname": settings.EMAIL_BRAND_NAME,
|
|
||||||
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
|
||||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
|
||||||
"domain": settings.EMAIL_DOMAIN,
|
|
||||||
"room_name": recording.room.name,
|
"room_name": recording.room.name,
|
||||||
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||||
"link": f"{get_recording_download_base_url()}/{recording.id}",
|
"link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||||
@@ -104,37 +102,22 @@ class NotificationService:
|
|||||||
# 2. The number of recipients per recording is typically small (not thousands)
|
# 2. The number of recipients per recording is typically small (not thousands)
|
||||||
for access in owner_accesses:
|
for access in owner_accesses:
|
||||||
user = access.user
|
user = access.user
|
||||||
language = user.language or get_language()
|
local_created_at = recording.created_at.astimezone(user.timezone)
|
||||||
with override(language):
|
try:
|
||||||
personalized_context = {
|
send_email.delay(
|
||||||
"recording_date": recording.created_at.astimezone(
|
template="screen_recording",
|
||||||
user.timezone
|
subject=gettext_noop("Your recording is ready"),
|
||||||
).strftime("%Y-%m-%d"),
|
recipients=[user.email],
|
||||||
"recording_time": recording.created_at.astimezone(
|
language=user.language or get_language(),
|
||||||
user.timezone
|
context={
|
||||||
).strftime("%H:%M"),
|
**context,
|
||||||
**context,
|
"recording_date": local_created_at.strftime("%Y-%m-%d"),
|
||||||
}
|
"recording_time": local_created_at.strftime("%H:%M"),
|
||||||
msg_html = render_to_string(
|
},
|
||||||
"mail/html/screen_recording.html", personalized_context
|
|
||||||
)
|
)
|
||||||
msg_plain = render_to_string(
|
except (smtplib.SMTPException, CeleryOperationalError) as exception:
|
||||||
"mail/text/screen_recording.txt", personalized_context
|
logger.error("notification could not be sent: %s", exception)
|
||||||
)
|
has_failures = True
|
||||||
subject = gettext("Your recording is ready") # Force translation
|
|
||||||
|
|
||||||
try:
|
|
||||||
send_mail(
|
|
||||||
subject.capitalize(),
|
|
||||||
msg_plain,
|
|
||||||
settings.EMAIL_FROM,
|
|
||||||
[user.email],
|
|
||||||
html_message=msg_html,
|
|
||||||
fail_silently=False,
|
|
||||||
)
|
|
||||||
except smtplib.SMTPException as exception:
|
|
||||||
logger.error("notification could not be sent: %s", exception)
|
|
||||||
has_failures = True
|
|
||||||
|
|
||||||
return not has_failures
|
return not has_failures
|
||||||
|
|
||||||
@@ -222,6 +205,27 @@ class NotificationService:
|
|||||||
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_summary_recipient(recording: models.Recording):
|
||||||
|
"""Return the single user who receives the transcript summary.
|
||||||
|
|
||||||
|
A recording can have multiple accesses (the starter and the room's
|
||||||
|
owners/administrators), but its summary is generated only once.
|
||||||
|
|
||||||
|
Temporarily, until the document can be created and shared with all
|
||||||
|
recording accesses, the summary is assigned to the oldest access,
|
||||||
|
which belongs to the starter (see `Recording.grant_initial_accesses`).
|
||||||
|
|
||||||
|
Since the starter may have a MEMBER role, the lookup must not filter by role.
|
||||||
|
"""
|
||||||
|
oldest_access = (
|
||||||
|
models.RecordingAccess.objects.select_related("user")
|
||||||
|
.filter(recording_id=recording.id)
|
||||||
|
.order_by("created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return oldest_access.user if oldest_access else None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _notify_summary_service_v1(recording: models.Recording):
|
def _notify_summary_service_v1(recording: models.Recording):
|
||||||
"""Notify summary service about a new recording."""
|
"""Notify summary service about a new recording."""
|
||||||
@@ -233,14 +237,7 @@ class NotificationService:
|
|||||||
logger.error("Summary service not configured")
|
logger.error("Summary service not configured")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
owner_access = (
|
recipient = NotificationService._get_summary_recipient(recording)
|
||||||
models.RecordingAccess.objects.select_related("user")
|
|
||||||
.filter(
|
|
||||||
role=models.RoleChoices.OWNER,
|
|
||||||
recording_id=recording.id,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
||||||
"collect_metadata", False
|
"collect_metadata", False
|
||||||
@@ -250,7 +247,7 @@ class NotificationService:
|
|||||||
else:
|
else:
|
||||||
metadata_filename = None
|
metadata_filename = None
|
||||||
|
|
||||||
if not owner_access:
|
if not recipient:
|
||||||
logger.error("No owner found for recording %s", recording.id)
|
logger.error("No owner found for recording %s", recording.id)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -259,16 +256,16 @@ class NotificationService:
|
|||||||
)(recording.worker_id)
|
)(recording.worker_id)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"owner_id": str(owner_access.user.id),
|
"owner_id": str(recipient.id),
|
||||||
"recording_filename": recording.key,
|
"recording_filename": recording.key,
|
||||||
"metadata_filename": metadata_filename,
|
"metadata_filename": metadata_filename,
|
||||||
"email": owner_access.user.email,
|
"email": recipient.email,
|
||||||
"sub": owner_access.user.sub,
|
"sub": recipient.sub,
|
||||||
"room": recording.room.name,
|
"room": recording.room.name,
|
||||||
"language": recording.options.get("language"),
|
"language": recording.options.get("language"),
|
||||||
"owner_timezone": str(owner_access.user.timezone),
|
"owner_timezone": str(recipient.timezone),
|
||||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||||
"context_language": owner_access.user.language,
|
"context_language": recipient.language,
|
||||||
"recording_start_at": (started_at.isoformat() if started_at else None),
|
"recording_start_at": (started_at.isoformat() if started_at else None),
|
||||||
"recording_end_at": (ended_at.isoformat() if ended_at else None),
|
"recording_end_at": (ended_at.isoformat() if ended_at else None),
|
||||||
}
|
}
|
||||||
@@ -308,14 +305,7 @@ class NotificationService:
|
|||||||
logger.error("Summary service not configured")
|
logger.error("Summary service not configured")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
owner_access = (
|
recipient = NotificationService._get_summary_recipient(recording)
|
||||||
models.RecordingAccess.objects.select_related("user")
|
|
||||||
.filter(
|
|
||||||
role=models.RoleChoices.OWNER,
|
|
||||||
recording_id=recording.id,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
metadata_filename: None | str = None
|
metadata_filename: None | str = None
|
||||||
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
||||||
"collect_metadata", False
|
"collect_metadata", False
|
||||||
@@ -323,7 +313,7 @@ class NotificationService:
|
|||||||
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
|
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
|
||||||
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
|
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
|
||||||
|
|
||||||
if not owner_access:
|
if not recipient:
|
||||||
logger.error("No owner found for recording %s", recording.id)
|
logger.error("No owner found for recording %s", recording.id)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -350,8 +340,8 @@ class NotificationService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"user_sub": owner_access.user.sub,
|
"user_sub": recipient.sub,
|
||||||
"user_email": owner_access.user.email,
|
"user_email": recipient.email,
|
||||||
"cloud_storage_url": generate_download_s3_url(
|
"cloud_storage_url": generate_download_s3_url(
|
||||||
recording.key,
|
recording.key,
|
||||||
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
||||||
@@ -360,20 +350,20 @@ class NotificationService:
|
|||||||
"language": recording.options.get(
|
"language": recording.options.get(
|
||||||
"language", get_language().split("-")[0].lower()
|
"language", get_language().split("-")[0].lower()
|
||||||
),
|
),
|
||||||
"context_language": owner_access.user.language,
|
"context_language": recipient.language,
|
||||||
"push_to_docs_config": {
|
"push_to_docs_config": {
|
||||||
"user_email": owner_access.user.email,
|
"user_email": recipient.email,
|
||||||
"title": NotificationService._generate_title(
|
"title": NotificationService._generate_title(
|
||||||
locale=owner_access.user.language
|
locale=recipient.language
|
||||||
or recording.options.get("language", get_language()),
|
or recording.options.get("language", get_language()),
|
||||||
room=recording.room.name,
|
room=recording.room.name,
|
||||||
recording_datetime=started_at,
|
recording_datetime=started_at,
|
||||||
owner_timezone=str(owner_access.user.timezone),
|
owner_timezone=str(recipient.timezone),
|
||||||
),
|
),
|
||||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||||
"form_link": form_link,
|
"form_link": form_link,
|
||||||
"auto_create_summary": is_user_feature_flag_enabled(
|
"auto_create_summary": is_user_feature_flag_enabled(
|
||||||
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
recipient, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"metadata": metadata_payload,
|
"metadata": metadata_payload,
|
||||||
|
|||||||
@@ -22,6 +22,46 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
|
|||||||
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
||||||
|
|
||||||
|
|
||||||
|
def build_encoding_options(resolution, profile=None):
|
||||||
|
"""Assemble the LiveKit ``EncodingOptions`` kwargs for a resolution/profile.
|
||||||
|
|
||||||
|
Single source of truth shared by the default encoding
|
||||||
|
(``WorkerServiceConfig.from_settings``) and the per-recording encoding
|
||||||
|
persisted by the start-recording API, so both paths always produce the
|
||||||
|
same shape.
|
||||||
|
|
||||||
|
The profile-independent fields (audio bitrate, keyframe interval and the
|
||||||
|
pinned codec / frequency constants) are always included.
|
||||||
|
|
||||||
|
An omitted profile falls back to RECORDING_ENCODING_DEFAULT_PROFILE.
|
||||||
|
Framerate and bitrate are left to LiveKit only when the operator
|
||||||
|
declared no default profile at all.
|
||||||
|
"""
|
||||||
|
profile = profile or settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||||
|
|
||||||
|
options: Dict[str, Any] = {
|
||||||
|
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"video_codec": _RECORDING_VIDEO_CODEC,
|
||||||
|
"audio_codec": _RECORDING_AUDIO_CODEC,
|
||||||
|
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolution:
|
||||||
|
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[
|
||||||
|
resolution
|
||||||
|
]
|
||||||
|
options["width"] = resolution_config["width"]
|
||||||
|
options["height"] = resolution_config["height"]
|
||||||
|
|
||||||
|
if resolution and profile:
|
||||||
|
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||||
|
options["framerate"] = profile_config["fps"]
|
||||||
|
options["video_bitrate"] = profile_config["kbps"][resolution]
|
||||||
|
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class WorkerServiceConfig:
|
class WorkerServiceConfig:
|
||||||
"""Declare Worker Service common configurations"""
|
"""Declare Worker Service common configurations"""
|
||||||
@@ -38,22 +78,16 @@ class WorkerServiceConfig:
|
|||||||
|
|
||||||
logger.debug("Loading WorkerServiceConfig from settings.")
|
logger.debug("Loading WorkerServiceConfig from settings.")
|
||||||
|
|
||||||
|
# The default encoding is resolved from the default profile/resolution and
|
||||||
|
# applied to every recording that carries no per-recording encoding.
|
||||||
|
# When either default is missing, we leave this as None so LiveKit falls
|
||||||
|
# back to its built-in preset.
|
||||||
|
resolution = settings.RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||||
|
profile = settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||||
|
|
||||||
encoding_options: Optional[Dict[str, Any]] = None
|
encoding_options: Optional[Dict[str, Any]] = None
|
||||||
if settings.RECORDING_ENCODING_ENABLED:
|
if resolution and profile:
|
||||||
# Single source of truth for the EncodingOptions kwargs:
|
encoding_options = build_encoding_options(resolution, profile)
|
||||||
# operator-tunable values live in Django settings, codec / frequency
|
|
||||||
# are pinned constants. The services layer only unpacks this dict.
|
|
||||||
encoding_options = {
|
|
||||||
"width": settings.RECORDING_ENCODING_WIDTH,
|
|
||||||
"height": settings.RECORDING_ENCODING_HEIGHT,
|
|
||||||
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
|
|
||||||
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
|
|
||||||
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
|
||||||
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
|
||||||
"video_codec": _RECORDING_VIDEO_CODEC,
|
|
||||||
"audio_codec": _RECORDING_AUDIO_CODEC,
|
|
||||||
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
|
|
||||||
}
|
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
||||||
@@ -78,7 +112,12 @@ class WorkerService(Protocol):
|
|||||||
def __init__(self, config: WorkerServiceConfig):
|
def __init__(self, config: WorkerServiceConfig):
|
||||||
"""Initialize the service with the given configuration."""
|
"""Initialize the service with the given configuration."""
|
||||||
|
|
||||||
def start(self, room_id: str, recording_id: str) -> str:
|
def start(
|
||||||
|
self,
|
||||||
|
room_id: str,
|
||||||
|
recording_id: str,
|
||||||
|
encoding_options: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> str:
|
||||||
"""Start a recording for a specified room."""
|
"""Start a recording for a specified room."""
|
||||||
|
|
||||||
def stop(self, worker_id: str) -> str:
|
def stop(self, worker_id: str) -> str:
|
||||||
|
|||||||
@@ -51,8 +51,11 @@ class WorkerServiceMediator:
|
|||||||
raise RecordingStartError()
|
raise RecordingStartError()
|
||||||
|
|
||||||
room_name = str(recording.room.id)
|
room_name = str(recording.room.id)
|
||||||
|
encoding_options = (recording.options.get("encoding") or {}).get("resolved")
|
||||||
try:
|
try:
|
||||||
worker_id = self._worker_service.start(room_name, recording.id)
|
worker_id = self._worker_service.start(
|
||||||
|
room_name, recording.id, encoding_options=encoding_options
|
||||||
|
)
|
||||||
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
|
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Failed to start recording for room %s: %s", recording.room.slug, e
|
"Failed to start recording for room %s: %s", recording.room.slug, e
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from livekit import api as livekit_api
|
|||||||
|
|
||||||
from ... import utils
|
from ... import utils
|
||||||
from ..enums import FileExtension
|
from ..enums import FileExtension
|
||||||
from .exceptions import WorkerConnectionError, WorkerResponseError
|
from .exceptions import WorkerConnectionError, WorkerRequestError, WorkerResponseError
|
||||||
from .factories import WorkerServiceConfig
|
from .factories import WorkerServiceConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -108,28 +108,33 @@ class BaseEgressService:
|
|||||||
self._log_egress_error(response, "failed to stop")
|
self._log_egress_error(response, "failed to stop")
|
||||||
return "FAILED_TO_STOP"
|
return "FAILED_TO_STOP"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the egress process for a recording (not implemented in the base class).
|
"""Start the egress process for a recording (not implemented in the base class).
|
||||||
Each derived class must implement this method, providing the necessary parameters for
|
Each derived class must implement this method, providing the necessary parameters for
|
||||||
its specific egress type (e.g. audio_only, streaming output).
|
its specific egress type (e.g. audio_only, streaming output).
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError("Subclass must implement this method.")
|
raise NotImplementedError("Subclass must implement this method.")
|
||||||
|
|
||||||
def _build_encoding_options(self):
|
def _resolve_encoding_options(self, encoding_options):
|
||||||
"""Build a LiveKit EncodingOptions from the service config, or None.
|
"""Build a LiveKit EncodingOptions from a resolved kwargs dict, or None.
|
||||||
|
|
||||||
|
``encoding_options`` is the per-recording dict persisted by the API in
|
||||||
|
``recording.options["encoding"]["resolved"]``; it falls back to the
|
||||||
|
default encoding carried by the service config.
|
||||||
|
|
||||||
When None is returned, the caller should omit the `advanced` field so
|
When None is returned, the caller should omit the `advanced` field so
|
||||||
LiveKit Egress falls back to its built-in preset (H264_720P_30).
|
LiveKit Egress falls back to its built-in preset (H264_720P_30).
|
||||||
|
|
||||||
The full EncodingOptions kwargs (operator-tunable values + pinned
|
|
||||||
codec / frequency constants) are assembled in `WorkerServiceConfig`,
|
|
||||||
so this method is a thin protobuf adapter.
|
|
||||||
"""
|
"""
|
||||||
opts = self._config.encoding_options
|
encoding_options = encoding_options or self._config.encoding_options
|
||||||
if not opts:
|
if not encoding_options:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return livekit_api.EncodingOptions(**opts)
|
try:
|
||||||
|
return livekit_api.EncodingOptions(**encoding_options)
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
# Protobuf raises TypeError on a wrongly typed value (e.g. a float
|
||||||
|
# framerate) and ValueError on an unknown field or an out-of-range int.
|
||||||
|
raise WorkerRequestError(f"Invalid encoding options: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
class VideoCompositeEgressService(BaseEgressService):
|
class VideoCompositeEgressService(BaseEgressService):
|
||||||
@@ -137,7 +142,7 @@ class VideoCompositeEgressService(BaseEgressService):
|
|||||||
|
|
||||||
hrid = "video-recording-composite-livekit-egress"
|
hrid = "video-recording-composite-livekit-egress"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the video composite egress process for a recording."""
|
"""Start the video composite egress process for a recording."""
|
||||||
|
|
||||||
# Save room's recording as a mp4 video file.
|
# Save room's recording as a mp4 video file.
|
||||||
@@ -158,7 +163,7 @@ class VideoCompositeEgressService(BaseEgressService):
|
|||||||
"layout": "speaker-light",
|
"layout": "speaker-light",
|
||||||
}
|
}
|
||||||
|
|
||||||
advanced = self._build_encoding_options()
|
advanced = self._resolve_encoding_options(encoding_options)
|
||||||
if advanced is not None:
|
if advanced is not None:
|
||||||
request_kwargs["advanced"] = advanced
|
request_kwargs["advanced"] = advanced
|
||||||
|
|
||||||
@@ -177,8 +182,13 @@ class AudioCompositeEgressService(BaseEgressService):
|
|||||||
|
|
||||||
hrid = "audio-recording-composite-livekit-egress"
|
hrid = "audio-recording-composite-livekit-egress"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the audio composite egress process for a recording."""
|
"""Start the audio composite egress process for a recording.
|
||||||
|
|
||||||
|
``encoding_options`` is accepted for signature compatibility with the
|
||||||
|
WorkerService protocol but ignored: audio-only egress has no
|
||||||
|
encoding to configure.
|
||||||
|
"""
|
||||||
|
|
||||||
# Save room's recording as an ogg audio file.
|
# Save room's recording as an ogg audio file.
|
||||||
file_type = livekit_api.EncodedFileType.OGG
|
file_type = livekit_api.EncodedFileType.OGG
|
||||||
|
|||||||
@@ -271,20 +271,24 @@ class LiveKitEventsService:
|
|||||||
)
|
)
|
||||||
raise ActionFailedError("Failed to process room started event") from e
|
raise ActionFailedError("Failed to process room started event") from e
|
||||||
|
|
||||||
room_updated_count = models.Room.objects.filter(pk=room_id).update(
|
try:
|
||||||
|
room = models.Room.all_objects.get(id=room_id)
|
||||||
|
except models.Room.DoesNotExist as err:
|
||||||
|
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||||
|
|
||||||
|
# The block below is intended to fix the issue where long-lived livekit
|
||||||
|
# tokens allow users who already entered a room to re-create it,
|
||||||
|
# even if it was closed.
|
||||||
|
if room.is_deleted:
|
||||||
|
self._close_deleted_room(room_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update through the queryset to skip the full_clean run by save()
|
||||||
|
models.Room.all_objects.filter(pk=room.pk).update(
|
||||||
last_started_at=timezone.now()
|
last_started_at=timezone.now()
|
||||||
)
|
)
|
||||||
if not room_updated_count:
|
|
||||||
raise ActionFailedError(f"Room with ID {room_id} does not exist")
|
|
||||||
|
|
||||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||||
try:
|
|
||||||
room = models.Room.objects.get(pk=room_id)
|
|
||||||
except models.Room.DoesNotExist as err:
|
|
||||||
raise ActionFailedError(
|
|
||||||
f"Room with ID {room_id} does not exist"
|
|
||||||
) from err
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.sip_management.ensure_dispatch_rule(room)
|
self.sip_management.ensure_dispatch_rule(room)
|
||||||
except SIPException as e:
|
except SIPException as e:
|
||||||
@@ -292,6 +296,25 @@ class LiveKitEventsService:
|
|||||||
f"Failed to create sip dispatch rule for room {room_id}"
|
f"Failed to create sip dispatch rule for room {room_id}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _close_deleted_room(room_id):
|
||||||
|
"""Close a LiveKit room recreated after its room was soft deleted.
|
||||||
|
|
||||||
|
LiveKit auto-creates a room on join, so a participant still holding a
|
||||||
|
valid token can bring a deleted room back to life until the token expires.
|
||||||
|
"""
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"LiveKit room %s started for a deleted room, closing it", room_id
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
RoomManagement.delete_room(str(room_id))
|
||||||
|
except RoomNotFoundException:
|
||||||
|
logger.info("LiveKit room %s is already closed", room_id)
|
||||||
|
except RoomManagementException as e:
|
||||||
|
raise ActionFailedError(f"Failed to close deleted room {room_id}") from e
|
||||||
|
|
||||||
def _handle_room_finished(self, data):
|
def _handle_room_finished(self, data):
|
||||||
"""Handle 'room_finished' event."""
|
"""Handle 'room_finished' event."""
|
||||||
|
|
||||||
|
|||||||
@@ -131,23 +131,6 @@ class LobbyService:
|
|||||||
if participant_ids:
|
if participant_ids:
|
||||||
self._redis().srem(self._get_index_key(room_id), *participant_ids)
|
self._redis().srem(self._get_index_key(room_id), *participant_ids)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_or_create_participant_id(request) -> str:
|
|
||||||
"""Extract unique participant identifier from the request."""
|
|
||||||
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, str(uuid.uuid4()))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def prepare_response(response, participant_id):
|
|
||||||
"""Set participant cookie if needed."""
|
|
||||||
if not response.cookies.get(settings.LOBBY_COOKIE_NAME):
|
|
||||||
response.set_cookie(
|
|
||||||
key=settings.LOBBY_COOKIE_NAME,
|
|
||||||
value=participant_id,
|
|
||||||
httponly=True,
|
|
||||||
secure=True,
|
|
||||||
samesite="Lax",
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def can_bypass_lobby(room, user, role) -> bool:
|
def can_bypass_lobby(room, user, role) -> bool:
|
||||||
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||||
@@ -178,8 +161,9 @@ class LobbyService:
|
|||||||
def request_entry(
|
def request_entry(
|
||||||
self,
|
self,
|
||||||
room: models.Room,
|
room: models.Room,
|
||||||
request,
|
user,
|
||||||
username: str,
|
username: str,
|
||||||
|
participant_id: Optional[uuid.UUID] = None,
|
||||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||||
"""Request entry to a room for a participant.
|
"""Request entry to a room for a participant.
|
||||||
|
|
||||||
@@ -194,52 +178,51 @@ class LobbyService:
|
|||||||
5. If denied, do nothing.
|
5. If denied, do nothing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
participant_id = self._get_or_create_participant_id(request)
|
participant = None
|
||||||
participant = self._get_participant(room.id, participant_id)
|
if participant_id:
|
||||||
|
participant = self._get_participant(room.id, participant_id)
|
||||||
|
|
||||||
|
is_new_participant = participant is None
|
||||||
|
if is_new_participant:
|
||||||
|
participant = self._create_participant(username)
|
||||||
|
|
||||||
room_id = str(room.id)
|
room_id = str(room.id)
|
||||||
user_role = room.get_role(request.user)
|
user_role = room.get_role(user)
|
||||||
|
|
||||||
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
if self.can_bypass_lobby(room=room, user=user, role=user_role):
|
||||||
if participant is None:
|
if not is_new_participant:
|
||||||
participant = LobbyParticipant(
|
self.clear_participant_cache(room.id, participant.id)
|
||||||
status=LobbyParticipantStatus.ACCEPTED,
|
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||||
username=username,
|
|
||||||
id=participant_id,
|
|
||||||
color=utils.generate_color(participant_id),
|
|
||||||
entered_at=timezone.now().isoformat(),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
|
||||||
|
|
||||||
livekit_config = utils.generate_livekit_config(
|
livekit_config = utils.generate_livekit_config(
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
user=request.user,
|
user=user,
|
||||||
username=username,
|
username=participant.username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id=participant_id,
|
participant_id=participant.id,
|
||||||
role=user_role,
|
role=user_role,
|
||||||
)
|
)
|
||||||
return participant, livekit_config
|
return participant, livekit_config
|
||||||
|
|
||||||
livekit_config = None
|
livekit_config = None
|
||||||
|
|
||||||
if participant is None:
|
if is_new_participant:
|
||||||
participant = self.enter(room.id, participant_id, username)
|
self._save_participant(room.id, participant)
|
||||||
|
self._notify_entry_request(room_id)
|
||||||
|
|
||||||
elif participant.status == LobbyParticipantStatus.WAITING:
|
elif participant.status == LobbyParticipantStatus.WAITING:
|
||||||
self.refresh_waiting_status(room.id, participant_id)
|
self.refresh_waiting_status(room.id, participant.id)
|
||||||
|
|
||||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||||
# wrongly named, contains access token to join a room
|
# wrongly named, contains access token to join a room
|
||||||
livekit_config = utils.generate_livekit_config(
|
livekit_config = utils.generate_livekit_config(
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
user=request.user,
|
user=user,
|
||||||
username=username,
|
username=participant.username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id=participant_id,
|
participant_id=participant.id,
|
||||||
role=user_role,
|
role=user_role,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -257,24 +240,35 @@ class LobbyService:
|
|||||||
)
|
)
|
||||||
self._index_touch(room_id)
|
self._index_touch(room_id)
|
||||||
|
|
||||||
def enter(
|
def _create_participant(self, username: str) -> LobbyParticipant:
|
||||||
self, room_id: UUID, participant_id: str, username: str
|
"""Create a new waiting participant without persisting it.
|
||||||
) -> LobbyParticipant:
|
|
||||||
"""Add participant to waiting lobby."""
|
|
||||||
|
|
||||||
color = utils.generate_color(participant_id)
|
|
||||||
|
|
||||||
|
Participant identifiers are minted here, server-side, exclusively.
|
||||||
|
"""
|
||||||
|
participant_id = str(uuid.uuid4())
|
||||||
participant = LobbyParticipant(
|
participant = LobbyParticipant(
|
||||||
status=LobbyParticipantStatus.WAITING,
|
status=LobbyParticipantStatus.WAITING,
|
||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color=color,
|
|
||||||
entered_at=timezone.now().isoformat(),
|
entered_at=timezone.now().isoformat(),
|
||||||
|
color=utils.generate_color(participant_id),
|
||||||
)
|
)
|
||||||
|
return participant
|
||||||
|
|
||||||
|
def _save_participant(self, room_id: UUID, participant: LobbyParticipant):
|
||||||
|
"""Persist a participant in the room's lobby."""
|
||||||
|
cache.set(
|
||||||
|
self._get_cache_key(room_id, participant.id),
|
||||||
|
participant.to_dict(),
|
||||||
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
|
)
|
||||||
|
self._index_add(room_id, participant.id)
|
||||||
|
|
||||||
|
def _notify_entry_request(self, room_id: str):
|
||||||
|
"""Notify room participants of a new entry request."""
|
||||||
try:
|
try:
|
||||||
utils.notify_participants(
|
utils.notify_participants(
|
||||||
room_name=str(room_id),
|
room_name=room_id,
|
||||||
notification_data={
|
notification_data={
|
||||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||||
},
|
},
|
||||||
@@ -283,16 +277,6 @@ class LobbyService:
|
|||||||
# If room not created yet, there is no participants to notify
|
# If room not created yet, there is no participants to notify
|
||||||
logger.exception("Failed to notify room participants")
|
logger.exception("Failed to notify room participants")
|
||||||
|
|
||||||
cache_key = self._get_cache_key(room_id, participant_id)
|
|
||||||
cache.set(
|
|
||||||
cache_key,
|
|
||||||
participant.to_dict(),
|
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
|
||||||
)
|
|
||||||
self._index_add(room_id, participant_id)
|
|
||||||
|
|
||||||
return participant
|
|
||||||
|
|
||||||
def _get_participant(
|
def _get_participant(
|
||||||
self, room_id: UUID, participant_id: str
|
self, room_id: UUID, participant_id: str
|
||||||
) -> Optional[LobbyParticipant]:
|
) -> Optional[LobbyParticipant]:
|
||||||
@@ -353,7 +337,7 @@ class LobbyService:
|
|||||||
room_id: UUID,
|
room_id: UUID,
|
||||||
participant_id: str,
|
participant_id: str,
|
||||||
allow_entry: bool,
|
allow_entry: bool,
|
||||||
) -> None:
|
) -> LobbyParticipant:
|
||||||
"""Handle decision on participant entry.
|
"""Handle decision on participant entry.
|
||||||
|
|
||||||
Updates participant status based on allow_entry:
|
Updates participant status based on allow_entry:
|
||||||
@@ -371,7 +355,7 @@ class LobbyService:
|
|||||||
"timeout": settings.LOBBY_DENIED_TIMEOUT,
|
"timeout": settings.LOBBY_DENIED_TIMEOUT,
|
||||||
}
|
}
|
||||||
|
|
||||||
self._update_participant_status(room_id, participant_id, **decision)
|
return self._update_participant_status(room_id, participant_id, **decision)
|
||||||
|
|
||||||
def _update_participant_status(
|
def _update_participant_status(
|
||||||
self,
|
self,
|
||||||
@@ -379,7 +363,7 @@ class LobbyService:
|
|||||||
participant_id: str,
|
participant_id: str,
|
||||||
status: LobbyParticipantStatus,
|
status: LobbyParticipantStatus,
|
||||||
timeout: int,
|
timeout: int,
|
||||||
) -> None:
|
) -> LobbyParticipant:
|
||||||
"""Update participant status with appropriate timeout."""
|
"""Update participant status with appropriate timeout."""
|
||||||
|
|
||||||
cache_key = self._get_cache_key(room_id, participant_id)
|
cache_key = self._get_cache_key(room_id, participant_id)
|
||||||
@@ -402,6 +386,8 @@ class LobbyService:
|
|||||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||||
self._index_touch(room_id)
|
self._index_touch(room_id)
|
||||||
|
|
||||||
|
return participant
|
||||||
|
|
||||||
def clear_room_cache(self, room_id: UUID) -> None:
|
def clear_room_cache(self, room_id: UUID) -> None:
|
||||||
"""Clear all participant entries from the cache for a specific room."""
|
"""Clear all participant entries from the cache for a specific room."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
"""Marketing service in charge of pushing data for marketing automation."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Dict, List, Optional, Protocol
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
|
||||||
from django.utils.module_loading import import_string
|
|
||||||
|
|
||||||
import brevo_python
|
|
||||||
import urllib3
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ContactCreationError(Exception):
|
|
||||||
"""Raised when the contact creation fails."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ContactData:
|
|
||||||
"""Contact data for marketing service integration."""
|
|
||||||
|
|
||||||
email: str
|
|
||||||
attributes: Optional[Dict[str, str]] = None
|
|
||||||
list_ids: Optional[List[int]] = None
|
|
||||||
update_enabled: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class MarketingServiceProtocol(Protocol):
|
|
||||||
"""Interface for marketing automation service integrations."""
|
|
||||||
|
|
||||||
def create_contact(
|
|
||||||
self, contact_data: ContactData, timeout: Optional[int] = None
|
|
||||||
) -> dict:
|
|
||||||
"""Create or update a contact.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
contact_data: Contact information and attributes
|
|
||||||
timeout: API request timeout in seconds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Service response
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ContactCreationError: If contact creation fails
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class BrevoMarketingService:
|
|
||||||
"""Brevo marketing automation integration.
|
|
||||||
|
|
||||||
Handles:
|
|
||||||
- Contact management and segmentation
|
|
||||||
- Marketing campaigns and automation
|
|
||||||
- Email communications
|
|
||||||
|
|
||||||
Configuration via Django settings:
|
|
||||||
- BREVO_API_KEY: API authentication
|
|
||||||
- BREVO_API_CONTACT_LIST_IDS: Default contact lists
|
|
||||||
- BREVO_API_CONTACT_ATTRIBUTES: Default contact attributes
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize Brevo (ex-sendinblue) marketing service."""
|
|
||||||
|
|
||||||
if not settings.BREVO_API_KEY:
|
|
||||||
raise ImproperlyConfigured("Brevo API key is required")
|
|
||||||
|
|
||||||
configuration = brevo_python.Configuration()
|
|
||||||
configuration.api_key["api-key"] = settings.BREVO_API_KEY
|
|
||||||
|
|
||||||
self._api_client = brevo_python.ApiClient(configuration)
|
|
||||||
|
|
||||||
def create_contact(self, contact_data: ContactData, timeout=None) -> dict:
|
|
||||||
"""Create or update a Brevo contact.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
contact_data: Contact information and attributes
|
|
||||||
timeout: API request timeout in seconds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Brevo API response
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ContactCreationError: If contact creation fails
|
|
||||||
ImproperlyConfigured: If required settings are missing
|
|
||||||
|
|
||||||
Note:
|
|
||||||
Contact attributes must be pre-configured in Brevo.
|
|
||||||
Changes to attributes can impact existing workflows.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not settings.BREVO_API_CONTACT_LIST_IDS:
|
|
||||||
raise ImproperlyConfigured(
|
|
||||||
"Default Brevo List IDs must be configured in settings."
|
|
||||||
)
|
|
||||||
|
|
||||||
contact_api = brevo_python.ContactsApi(self._api_client)
|
|
||||||
|
|
||||||
attributes = {
|
|
||||||
**settings.BREVO_API_CONTACT_ATTRIBUTES,
|
|
||||||
**(contact_data.attributes or {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
list_ids = (contact_data.list_ids or []) + settings.BREVO_API_CONTACT_LIST_IDS
|
|
||||||
|
|
||||||
contact = brevo_python.CreateContact(
|
|
||||||
email=contact_data.email,
|
|
||||||
attributes=attributes,
|
|
||||||
list_ids=list_ids,
|
|
||||||
update_enabled=contact_data.update_enabled,
|
|
||||||
)
|
|
||||||
|
|
||||||
api_configurations = {}
|
|
||||||
|
|
||||||
if timeout is not None:
|
|
||||||
api_configurations["_request_timeout"] = timeout
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = contact_api.create_contact(contact, **api_configurations)
|
|
||||||
except (
|
|
||||||
brevo_python.rest.ApiException,
|
|
||||||
urllib3.exceptions.ReadTimeoutError,
|
|
||||||
) as err:
|
|
||||||
logger.warning("Failed to create contact in Brevo", exc_info=True)
|
|
||||||
raise ContactCreationError("Failed to create contact in Brevo") from err
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def get_marketing_service() -> MarketingServiceProtocol:
|
|
||||||
"""Return cached instance of configured marketing service."""
|
|
||||||
marketing_service_cls = import_string(settings.MARKETING_SERVICE_CLASS)
|
|
||||||
return marketing_service_cls()
|
|
||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
# pylint: disable=no-name-in-module
|
# pylint: disable=no-name-in-module
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit.api import (
|
from livekit.api import (
|
||||||
DeleteRoomRequest,
|
DeleteRoomRequest,
|
||||||
@@ -15,6 +18,9 @@ from livekit.api import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from core import utils
|
from core import utils
|
||||||
|
from core.models import Room
|
||||||
|
|
||||||
|
from .sip_management import SIPException, SIPManagement
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
@@ -115,6 +121,54 @@ class RoomManagement:
|
|||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def soft_delete(cls, room: Room):
|
||||||
|
"""Soft delete a room, remove its SIP routing and close its LiveKit room."""
|
||||||
|
|
||||||
|
room.soft_delete()
|
||||||
|
rule_deleted = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
rule_deleted = cls._delete_dispatch_rule(room)
|
||||||
|
|
||||||
|
with contextlib.suppress(RoomNotFoundException):
|
||||||
|
cls.delete_room(str(room.id))
|
||||||
|
except Exception:
|
||||||
|
Room.all_objects.filter(pk=room.pk, deleted_at=room.deleted_at).update(
|
||||||
|
deleted_at=None
|
||||||
|
)
|
||||||
|
room.deleted_at = None
|
||||||
|
if rule_deleted:
|
||||||
|
cls._restore_dispatch_rule(room)
|
||||||
|
raise
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _delete_dispatch_rule(room: Room):
|
||||||
|
"""Delete a room's SIP dispatch rule, so its PIN no longer routes calls.
|
||||||
|
|
||||||
|
This cannot be left to the room_finished webhook: a roomkit join creates
|
||||||
|
the rule before any LiveKit room exists, so no room may ever finish.
|
||||||
|
|
||||||
|
Returns whether a rule was deleted
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not (settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
return SIPManagement().delete_dispatch_rule(room.id)
|
||||||
|
except SIPException as e:
|
||||||
|
raise RoomManagementException("Could not delete dispatch rule") from e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _restore_dispatch_rule(room: Room):
|
||||||
|
"""Recreate a room's SIP dispatch rule after a failed soft delete."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
SIPManagement().ensure_dispatch_rule(room)
|
||||||
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
|
logger.exception("Could not restore dispatch rule for room %s", room.id)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def sync_room_metadata(cls, room):
|
def sync_room_metadata(cls, room):
|
||||||
"""Push a room's configuration and access level to its LiveKit room metadata.
|
"""Push a room's configuration and access level to its LiveKit room metadata.
|
||||||
|
|||||||
@@ -124,7 +124,11 @@ class SIPManagement:
|
|||||||
|
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def delete_dispatch_rule(self, room_id):
|
async def delete_dispatch_rule(self, room_id):
|
||||||
"""Delete all SIP inbound dispatch rules associated with a specific room."""
|
"""Delete all SIP inbound dispatch rules associated with a specific room.
|
||||||
|
|
||||||
|
A rule deleted meanwhile (e.g. by both a room deletion and its
|
||||||
|
room_finished webhook) is not an error.
|
||||||
|
"""
|
||||||
|
|
||||||
rules_ids = await self._list_dispatch_rules_ids(room_id)
|
rules_ids = await self._list_dispatch_rules_ids(room_id)
|
||||||
|
|
||||||
@@ -138,9 +142,16 @@ class SIPManagement:
|
|||||||
lkapi = utils.create_livekit_client()
|
lkapi = utils.create_livekit_client()
|
||||||
try:
|
try:
|
||||||
for rule_id in rules_ids:
|
for rule_id in rules_ids:
|
||||||
await lkapi.sip.delete_sip_dispatch_rule(
|
try:
|
||||||
delete=DeleteSIPDispatchRuleRequest(sip_dispatch_rule_id=rule_id)
|
await lkapi.sip.delete_sip_dispatch_rule(
|
||||||
)
|
delete=DeleteSIPDispatchRuleRequest(
|
||||||
|
sip_dispatch_rule_id=rule_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except TwirpError as e:
|
||||||
|
if e.code != TwirpErrorCode.NOT_FOUND:
|
||||||
|
raise
|
||||||
|
logger.info("Dispatch rule %s was already deleted", rule_id)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Service handling the lifecycle of transit codes.
|
||||||
|
|
||||||
|
A transit code is an opaque, cryptographically random, single-use code
|
||||||
|
handed to an embedded frontend (through a URL fragment) so it can obtain a
|
||||||
|
user access token on the core API without a session cookie. The code
|
||||||
|
carries no information by itself: everything it references (user, client)
|
||||||
|
is stored server-side in the cache, and consumed atomically on exchange.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
|
||||||
|
class TransitCodeService:
|
||||||
|
"""Create and consume single-use transit codes."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cache_key(code):
|
||||||
|
"""Build the cache key for a code.
|
||||||
|
|
||||||
|
The code is hashed so that a dump of the cache never reveals
|
||||||
|
directly usable codes.
|
||||||
|
"""
|
||||||
|
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()
|
||||||
|
return f"{settings.TRANSIT_CODE_CACHE_PREFIX}:{digest}"
|
||||||
|
|
||||||
|
def create_code(self, user, client_id):
|
||||||
|
"""Generate a transit code for a user, and store it.
|
||||||
|
|
||||||
|
The code expires after TRANSIT_CODE_TTL seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The opaque code to hand to the client.
|
||||||
|
"""
|
||||||
|
# Default 48 random bytes -> 64 url-safe characters, 384 bits of
|
||||||
|
# entropy: unguessable and safe to transit through a URL fragment.
|
||||||
|
code = secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
|
||||||
|
|
||||||
|
cache.set(
|
||||||
|
self._cache_key(code),
|
||||||
|
{
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"client_id": client_id,
|
||||||
|
},
|
||||||
|
timeout=settings.TRANSIT_CODE_TTL,
|
||||||
|
)
|
||||||
|
|
||||||
|
return code
|
||||||
|
|
||||||
|
def consume_code(self, code):
|
||||||
|
"""Consume a transit code, enforcing single use.
|
||||||
|
|
||||||
|
The code is deleted from the cache upon consumption. `cache.delete`
|
||||||
|
returns whether a key was actually deleted, so if two requests race
|
||||||
|
on the same code, only one of them wins.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict | None: The data stored at creation time ('user_id',
|
||||||
|
'client_id'), or None if the code is unknown, expired or
|
||||||
|
already consumed.
|
||||||
|
"""
|
||||||
|
if not code:
|
||||||
|
return None
|
||||||
|
|
||||||
|
key = self._cache_key(code)
|
||||||
|
data = cache.get(key)
|
||||||
|
|
||||||
|
if data is None or not cache.delete(key):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return data
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
from core.tasks.connection_test import delete_connection_test_room
|
from core.tasks.connection_test import delete_connection_test_room
|
||||||
from core.tasks.file import process_file_deletion
|
from core.tasks.file import process_file_deletion
|
||||||
|
from core.tasks.mail import send_email
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
"delete_connection_test_room",
|
"delete_connection_test_room",
|
||||||
"process_file_deletion",
|
"process_file_deletion",
|
||||||
|
"send_email",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Send mail using celery task."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.translation import get_language, gettext, override
|
||||||
|
|
||||||
|
from core.tasks._task import task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_base_email_context() -> dict:
|
||||||
|
"""Context shared by every email template (branding, support, domain)."""
|
||||||
|
return {
|
||||||
|
"brandname": settings.EMAIL_BRAND_NAME,
|
||||||
|
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
||||||
|
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||||
|
"domain": settings.EMAIL_DOMAIN,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@task(
|
||||||
|
autoretry_for=(smtplib.SMTPException, ConnectionError, TimeoutError),
|
||||||
|
retry_backoff=True,
|
||||||
|
retry_backoff_max=600,
|
||||||
|
retry_jitter=True,
|
||||||
|
max_retries=5,
|
||||||
|
)
|
||||||
|
def send_email(
|
||||||
|
*,
|
||||||
|
template: str,
|
||||||
|
subject: str,
|
||||||
|
recipients: list[str],
|
||||||
|
context: dict | None = None,
|
||||||
|
language: str | None = None,
|
||||||
|
):
|
||||||
|
"""Render a mail template and send it.
|
||||||
|
|
||||||
|
Generic on purpose: any feature can reuse it with its own template and context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Template base name, e.g. "screen_recording". Both
|
||||||
|
"mail/html/<template>.html" and "mail/text/<template>.txt" must exist.
|
||||||
|
subject: Untranslated subject msgid. Mark it with ``gettext_noop`` at the
|
||||||
|
call site; it is translated here, in ``language``.
|
||||||
|
recipients: Email addresses, all receiving the same rendered message.
|
||||||
|
context: Template context. Must be JSON-serializable (str, int, list, dict…):
|
||||||
|
pre-format dates, cast UUIDs to str. Merged over the base branding context.
|
||||||
|
language: Language to render the email in. Defaults to the active language.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OSError: SMTP or network failure, after retries are exhausted (or
|
||||||
|
immediately when Celery is disabled and the call runs synchronously).
|
||||||
|
"""
|
||||||
|
full_context = {**get_base_email_context(), **(context or {})}
|
||||||
|
|
||||||
|
with override(language or get_language()):
|
||||||
|
msg_html = render_to_string(f"mail/html/{template}.html", full_context)
|
||||||
|
msg_plain = render_to_string(f"mail/text/{template}.txt", full_context)
|
||||||
|
translated_subject = gettext(subject)
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
translated_subject,
|
||||||
|
msg_plain,
|
||||||
|
settings.EMAIL_FROM,
|
||||||
|
recipients,
|
||||||
|
html_message=msg_html,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
logger.info("Email '%s' sent to %d recipient(s)", template, len(recipients))
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for NoOpAnalytics and the analytics backend loader.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=redefined-outer-name,unused-argument
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.analytics import get_analytics
|
||||||
|
from core.analytics.base import NoOpAnalytics
|
||||||
|
from core.analytics.user_feature_flags import UserFeatureFlag
|
||||||
|
from core.factories import UserFactory
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_analytics_cache():
|
||||||
|
"""Clear the cached backend before and after each test."""
|
||||||
|
get_analytics.cache_clear()
|
||||||
|
yield
|
||||||
|
get_analytics.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# NoOpAnalytics
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
|
||||||
|
def test_noop_has_no_feature_flags_by_default():
|
||||||
|
"""Should keep every feature flag disabled when none is declared."""
|
||||||
|
backend = NoOpAnalytics()
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
assert not backend.get_user_feature_flags(user)
|
||||||
|
assert (
|
||||||
|
backend.is_user_feature_enabled(
|
||||||
|
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_noop_enables_declared_feature_flags():
|
||||||
|
"""Should enable a feature flag declared as True."""
|
||||||
|
backend = NoOpAnalytics(feature_flags={"summary-enabled": True})
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
assert backend.get_user_feature_flags(user) == {"summary-enabled": True}
|
||||||
|
assert (
|
||||||
|
backend.is_user_feature_enabled(
|
||||||
|
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [False, None, "true"])
|
||||||
|
def test_noop_only_enables_feature_flags_set_to_true(value):
|
||||||
|
"""Should keep a feature flag disabled unless its value is exactly True."""
|
||||||
|
backend = NoOpAnalytics(feature_flags={"summary-enabled": value})
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
assert (
|
||||||
|
backend.is_user_feature_enabled(
|
||||||
|
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_noop_ignores_unrelated_settings():
|
||||||
|
"""Should accept settings meant for another backend without failing."""
|
||||||
|
backend = NoOpAnalytics(api_key="my-key", host="https://eu.i.posthog.com")
|
||||||
|
|
||||||
|
assert not backend.get_user_feature_flags(UserFactory())
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# get_analytics
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_analytics_defaults_to_noop_without_feature_flags(settings):
|
||||||
|
"""Should return a no-op backend with no feature flag by default."""
|
||||||
|
settings.ANALYTICS_BACKEND = None
|
||||||
|
settings.ANALYTICS_BACKEND_SETTINGS = {}
|
||||||
|
|
||||||
|
backend = get_analytics()
|
||||||
|
|
||||||
|
assert isinstance(backend, NoOpAnalytics)
|
||||||
|
assert not backend.get_user_feature_flags(UserFactory())
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_analytics_passes_feature_flags_to_noop(settings):
|
||||||
|
"""Should forward the feature flags when no backend is configured."""
|
||||||
|
settings.ANALYTICS_BACKEND = None
|
||||||
|
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
|
||||||
|
|
||||||
|
backend = get_analytics()
|
||||||
|
|
||||||
|
assert isinstance(backend, NoOpAnalytics)
|
||||||
|
assert (
|
||||||
|
backend.is_user_feature_enabled(
|
||||||
|
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_analytics_instantiates_configured_backend_with_settings(settings):
|
||||||
|
"""Should instantiate the configured backend with its settings."""
|
||||||
|
settings.ANALYTICS_BACKEND = "core.analytics.base.NoOpAnalytics"
|
||||||
|
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
|
||||||
|
|
||||||
|
backend = get_analytics()
|
||||||
|
|
||||||
|
assert isinstance(backend, NoOpAnalytics)
|
||||||
|
assert (
|
||||||
|
backend.is_user_feature_enabled(
|
||||||
|
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from lasuite.marketing.tasks import create_or_update_contact
|
||||||
|
|
||||||
from core import models
|
from core import models
|
||||||
from core.authentication.backends import OIDCAuthenticationBackend
|
from core.authentication.backends import OIDCAuthenticationBackend
|
||||||
from core.factories import UserFactory
|
from core.factories import UserFactory
|
||||||
from core.services import marketing
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -606,8 +606,8 @@ def test_marketing_signup_existing_user(
|
|||||||
mock_signup.assert_not_called()
|
mock_signup.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
@mock.patch.object(create_or_update_contact, "delay")
|
||||||
def test_signup_to_marketing_email_success(mock_marketing):
|
def test_signup_to_marketing_email_success(mock_create_or_update_contact):
|
||||||
"""Test successful marketing signup."""
|
"""Test successful marketing signup."""
|
||||||
|
|
||||||
email = "test@example.com"
|
email = "test@example.com"
|
||||||
@@ -616,46 +616,6 @@ def test_signup_to_marketing_email_success(mock_marketing):
|
|||||||
OIDCAuthenticationBackend.signup_to_marketing_email(email)
|
OIDCAuthenticationBackend.signup_to_marketing_email(email)
|
||||||
|
|
||||||
# Verify service interaction
|
# Verify service interaction
|
||||||
mock_service = mock_marketing.return_value
|
mock_create_or_update_contact.assert_called_once_with(
|
||||||
mock_service.create_contact.assert_called_once()
|
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"error",
|
|
||||||
[
|
|
||||||
ImportError,
|
|
||||||
ImproperlyConfigured,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
|
||||||
def test_marketing_signup_handles_service_initialization_errors(
|
|
||||||
mock_marketing, error, settings
|
|
||||||
):
|
|
||||||
"""Tests errors that occur when trying to get/initialize the marketing service."""
|
|
||||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
|
||||||
|
|
||||||
mock_marketing.side_effect = error
|
|
||||||
|
|
||||||
# Should not raise any exception
|
|
||||||
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"error",
|
|
||||||
[
|
|
||||||
marketing.ContactCreationError,
|
|
||||||
ImproperlyConfigured,
|
|
||||||
ImportError,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
|
||||||
def test_marketing_signup_handles_contact_creation_errors(
|
|
||||||
mock_marketing, error, settings
|
|
||||||
):
|
|
||||||
"""Tests errors that occur during the contact creation process."""
|
|
||||||
|
|
||||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
|
||||||
mock_marketing.return_value.create_contact.side_effect = error
|
|
||||||
|
|
||||||
# Should not raise any exception
|
|
||||||
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from django.core.management import call_command
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories, models
|
from core import factories, models
|
||||||
|
|
||||||
@@ -19,14 +20,25 @@ COMMAND_MODULE = "core.management.commands.purge_inactive_rooms"
|
|||||||
BEFORE_PERIOD = timedelta(days=366)
|
BEFORE_PERIOD = timedelta(days=366)
|
||||||
WITHIN_PERIOD = timedelta(days=364)
|
WITHIN_PERIOD = timedelta(days=364)
|
||||||
|
|
||||||
|
BEFORE_RETENTION = timedelta(days=31)
|
||||||
|
WITHIN_RETENTION = timedelta(days=29)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="purge_enabled", autouse=True)
|
@pytest.fixture(name="purge_enabled", autouse=True)
|
||||||
def fixture_purge_enabled(settings):
|
def fixture_purge_enabled(settings):
|
||||||
"""Enable the purge of the rooms inactive for a year."""
|
"""Enable the purge of the rooms inactive for a year."""
|
||||||
settings.ROOM_INACTIVITY_DELETION_DAYS = 365
|
settings.ROOM_INACTIVITY_DELETION_DAYS = 365
|
||||||
|
settings.ROOM_DELETED_RETENTION_DAYS = None
|
||||||
settings.RECORDING_EXPIRATION_DAYS = 30
|
settings.RECORDING_EXPIRATION_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="retention_only")
|
||||||
|
def fixture_retention_only(settings):
|
||||||
|
"""Only purge the rooms soft deleted more than 30 days ago."""
|
||||||
|
settings.ROOM_INACTIVITY_DELETION_DAYS = None
|
||||||
|
settings.ROOM_DELETED_RETENTION_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
def create_at(date, factory, **kwargs):
|
def create_at(date, factory, **kwargs):
|
||||||
"""Build an object with the factory as if it was created at the given date."""
|
"""Build an object with the factory as if it was created at the given date."""
|
||||||
with mock.patch("django.utils.timezone.now", return_value=date):
|
with mock.patch("django.utils.timezone.now", return_value=date):
|
||||||
@@ -41,12 +53,12 @@ def call_purge(*args):
|
|||||||
|
|
||||||
|
|
||||||
def room_exists(room):
|
def room_exists(room):
|
||||||
"""Tell whether the room is still in database."""
|
"""Tell whether the room is still in database, soft deleted or not."""
|
||||||
return models.Room.objects.filter(pk=room.pk).exists()
|
return models.Room.all_objects.filter(pk=room.pk).exists()
|
||||||
|
|
||||||
|
|
||||||
def test_purge_inactive_rooms_disabled(settings):
|
def test_purge_inactive_rooms_disabled(settings):
|
||||||
"""Should delete nothing when no inactivity period is configured."""
|
"""Should delete nothing when neither period is configured."""
|
||||||
settings.ROOM_INACTIVITY_DELETION_DAYS = None
|
settings.ROOM_INACTIVITY_DELETION_DAYS = None
|
||||||
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||||
|
|
||||||
@@ -68,7 +80,7 @@ def test_purge_inactive_rooms_without_recording_expiration(settings):
|
|||||||
status=models.RecordingStatusChoices.SAVED,
|
status=models.RecordingStatusChoices.SAVED,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert call_purge() == "Purged 1 inactive room(s).\n"
|
assert call_purge() == "Purged 1 room(s).\n"
|
||||||
|
|
||||||
assert not room_exists(room)
|
assert not room_exists(room)
|
||||||
assert room_exists(room_with_recording)
|
assert room_exists(room_with_recording)
|
||||||
@@ -97,7 +109,7 @@ def test_purge_inactive_rooms_started_before_period(caplog):
|
|||||||
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
|
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
|
||||||
output = call_purge()
|
output = call_purge()
|
||||||
|
|
||||||
assert output == "Purged 1 inactive room(s).\n"
|
assert output == "Purged 1 room(s).\n"
|
||||||
assert not room_exists(room)
|
assert not room_exists(room)
|
||||||
assert f"Purging inactive room {room.pk} ({room.slug})" in caplog.text
|
assert f"Purging inactive room {room.pk} ({room.slug})" in caplog.text
|
||||||
|
|
||||||
@@ -120,7 +132,7 @@ def test_purge_inactive_rooms_started_within_period():
|
|||||||
last_started_at=now - WITHIN_PERIOD,
|
last_started_at=now - WITHIN_PERIOD,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert call_purge() == "No inactive room to purge.\n"
|
assert call_purge() == "No room to purge.\n"
|
||||||
|
|
||||||
assert room_exists(room)
|
assert room_exists(room)
|
||||||
|
|
||||||
@@ -129,7 +141,7 @@ def test_purge_inactive_rooms_never_started_created_within_period():
|
|||||||
"""Should keep a room that was never started but created within the period."""
|
"""Should keep a room that was never started but created within the period."""
|
||||||
room = create_at(timezone.now() - WITHIN_PERIOD, factories.RoomFactory)
|
room = create_at(timezone.now() - WITHIN_PERIOD, factories.RoomFactory)
|
||||||
|
|
||||||
assert call_purge() == "No inactive room to purge.\n"
|
assert call_purge() == "No room to purge.\n"
|
||||||
|
|
||||||
assert room_exists(room)
|
assert room_exists(room)
|
||||||
|
|
||||||
@@ -221,7 +233,7 @@ def test_purge_inactive_rooms_dry_run():
|
|||||||
factories.RoomFactory(name="Recent room")
|
factories.RoomFactory(name="Recent room")
|
||||||
|
|
||||||
assert call_purge("--dry-run") == (
|
assert call_purge("--dry-run") == (
|
||||||
"[dry-run] 2 inactive room(s) would be purged:\n- Alpha room\n- Beta room\n"
|
"[dry-run] 2 room(s) would be purged:\n- Alpha room\n- Beta room\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert all(room_exists(room) for room in rooms)
|
assert all(room_exists(room) for room in rooms)
|
||||||
@@ -235,5 +247,134 @@ def test_purge_inactive_rooms_several_chunks():
|
|||||||
with mock.patch(f"{COMMAND_MODULE}.CHUNK_SIZE", 2):
|
with mock.patch(f"{COMMAND_MODULE}.CHUNK_SIZE", 2):
|
||||||
output = call_purge()
|
output = call_purge()
|
||||||
|
|
||||||
assert output == "Purged 5 inactive room(s).\n"
|
assert output == "Purged 5 room(s).\n"
|
||||||
assert not any(room_exists(room) for room in rooms)
|
assert not any(room_exists(room) for room in rooms)
|
||||||
|
|
||||||
|
|
||||||
|
def test_purge_inactive_rooms_soft_deleted(caplog):
|
||||||
|
"""Should delete an inactive room even if it was soft deleted recently."""
|
||||||
|
now = timezone.now()
|
||||||
|
room = create_at(
|
||||||
|
now - BEFORE_PERIOD,
|
||||||
|
factories.RoomFactory,
|
||||||
|
deleted_at=now - timedelta(days=1),
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
|
||||||
|
output = call_purge()
|
||||||
|
|
||||||
|
assert output == "Purged 1 room(s).\n"
|
||||||
|
assert not room_exists(room)
|
||||||
|
assert f"Purging deleted room {room.pk} ({room.slug})" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("retention_only")
|
||||||
|
def test_purge_deleted_rooms_before_retention(caplog):
|
||||||
|
"""Should delete a room soft deleted before the retention period."""
|
||||||
|
room = factories.RoomFactory(deleted_at=timezone.now() - BEFORE_RETENTION)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
|
||||||
|
output = call_purge()
|
||||||
|
|
||||||
|
assert output == "Purged 1 room(s).\n"
|
||||||
|
assert not room_exists(room)
|
||||||
|
assert f"Purging deleted room {room.pk} ({room.slug})" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("retention_only")
|
||||||
|
def test_purge_deleted_rooms_within_retention():
|
||||||
|
"""Should keep a room soft deleted within the retention period."""
|
||||||
|
room = factories.RoomFactory(deleted_at=timezone.now() - WITHIN_RETENTION)
|
||||||
|
|
||||||
|
assert call_purge() == "No room to purge.\n"
|
||||||
|
|
||||||
|
assert room_exists(room)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("retention_only")
|
||||||
|
def test_purge_deleted_rooms_retention_only_keeps_inactive_rooms():
|
||||||
|
"""Should keep inactive rooms that are not deleted when only retention is set."""
|
||||||
|
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||||
|
|
||||||
|
assert call_purge() == "No room to purge.\n"
|
||||||
|
|
||||||
|
assert room_exists(room)
|
||||||
|
|
||||||
|
|
||||||
|
def test_purge_deleted_rooms_within_retention_but_inactive(settings):
|
||||||
|
"""Should delete an inactive room even if it was deleted within the retention."""
|
||||||
|
settings.ROOM_DELETED_RETENTION_DAYS = 30
|
||||||
|
now = timezone.now()
|
||||||
|
room = create_at(
|
||||||
|
now - BEFORE_PERIOD,
|
||||||
|
factories.RoomFactory,
|
||||||
|
deleted_at=now - WITHIN_RETENTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
call_purge()
|
||||||
|
|
||||||
|
assert not room_exists(room)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("retention_only")
|
||||||
|
def test_purge_deleted_rooms_recording_not_expired():
|
||||||
|
"""Should keep a deleted room holding a saved recording that has not expired."""
|
||||||
|
room = factories.RoomFactory(deleted_at=timezone.now() - BEFORE_RETENTION)
|
||||||
|
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.SAVED)
|
||||||
|
|
||||||
|
assert call_purge() == "No room to purge.\n"
|
||||||
|
|
||||||
|
assert room_exists(room)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("retention_only")
|
||||||
|
def test_purge_deleted_rooms_recording_expired():
|
||||||
|
"""Should delete a deleted room along with its expired recordings."""
|
||||||
|
now = timezone.now()
|
||||||
|
room = create_at(
|
||||||
|
now - timedelta(days=40),
|
||||||
|
factories.RoomFactory,
|
||||||
|
deleted_at=now - BEFORE_RETENTION,
|
||||||
|
)
|
||||||
|
recording = create_at(
|
||||||
|
now - timedelta(days=40),
|
||||||
|
factories.RecordingFactory,
|
||||||
|
room=room,
|
||||||
|
status=models.RecordingStatusChoices.SAVED,
|
||||||
|
)
|
||||||
|
|
||||||
|
call_purge()
|
||||||
|
|
||||||
|
assert not room_exists(room)
|
||||||
|
assert not models.Recording.objects.filter(pk=recording.pk).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_purge_rooms_dry_run_flags_deleted_rooms(settings):
|
||||||
|
"""Should flag the soft-deleted rooms listed on a dry run."""
|
||||||
|
settings.ROOM_DELETED_RETENTION_DAYS = 30
|
||||||
|
now = timezone.now()
|
||||||
|
rooms = [
|
||||||
|
create_at(now - BEFORE_PERIOD, factories.RoomFactory, name="Alpha room"),
|
||||||
|
factories.RoomFactory(name="Beta room", deleted_at=now - BEFORE_RETENTION),
|
||||||
|
]
|
||||||
|
factories.RoomFactory(name="Gamma room", deleted_at=now - WITHIN_RETENTION)
|
||||||
|
|
||||||
|
assert call_purge("--dry-run") == (
|
||||||
|
"[dry-run] 2 room(s) would be purged:\n- Alpha room\n- Beta room (deleted)\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert all(room_exists(room) for room in rooms)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("retention_only")
|
||||||
|
def test_purge_deleted_rooms_frees_slug():
|
||||||
|
"""Should let a new room take the slug of a purged deleted room."""
|
||||||
|
factories.RoomFactory(name="my room", deleted_at=timezone.now() - BEFORE_RETENTION)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(factories.UserFactory())
|
||||||
|
|
||||||
|
call_purge()
|
||||||
|
response = client.post("/api/v1.0/rooms/", {"name": "My Room!"})
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert models.Room.objects.get().slug == "my-room"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from unittest import mock
|
|||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from celery.exceptions import OperationalError as CeleryOperationalError
|
||||||
|
|
||||||
from core import factories, models
|
from core import factories, models
|
||||||
from core.analytics import UserFeatureFlag
|
from core.analytics import UserFeatureFlag
|
||||||
@@ -99,7 +100,7 @@ def test_notify_external_services_unknown_mode(caplog):
|
|||||||
|
|
||||||
# pylint: disable=too-many-locals
|
# pylint: disable=too-many-locals
|
||||||
def test_notify_user_by_email_success(mocked_current_site, settings):
|
def test_notify_user_by_email_success(mocked_current_site, settings):
|
||||||
"""Test successful email notification to recording owners."""
|
"""Test successful email notification to every user with access to the recording."""
|
||||||
settings.EMAIL_BRAND_NAME = "ACME"
|
settings.EMAIL_BRAND_NAME = "ACME"
|
||||||
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
||||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||||
@@ -132,21 +133,19 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
|
|||||||
recording=recording, role=models.RoleChoices.OWNER, user=english_user
|
recording=recording, role=models.RoleChoices.OWNER, user=english_user
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create non-owner users to verify they don't receive emails
|
# Members (e.g. a starter with no privileged room role) are notified too
|
||||||
|
member = factories.UserFactory(email="member@test.com", language="en-us")
|
||||||
factories.UserRecordingAccessFactory(
|
factories.UserRecordingAccessFactory(
|
||||||
recording=recording, role=models.RoleChoices.MEMBER
|
recording=recording, role=models.RoleChoices.MEMBER, user=member
|
||||||
)
|
|
||||||
factories.UserRecordingAccessFactory(
|
|
||||||
recording=recording, role=models.RoleChoices.ADMIN
|
|
||||||
)
|
)
|
||||||
|
|
||||||
notification_service = NotificationService()
|
notification_service = NotificationService()
|
||||||
|
|
||||||
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
|
with mock.patch("core.tasks.mail.send_mail") as mock_send_mail:
|
||||||
result = notification_service._notify_user_by_email(recording)
|
result = notification_service._notify_user_by_email(recording)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
assert mock_send_mail.call_count == 3
|
assert mock_send_mail.call_count == 4
|
||||||
|
|
||||||
call_args_list = mock_send_mail.call_args_list
|
call_args_list = mock_send_mail.call_args_list
|
||||||
|
|
||||||
@@ -210,6 +209,9 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
|
|||||||
assert recipients3 == ["john@test.com"]
|
assert recipients3 == ["john@test.com"]
|
||||||
assert sender3 == "notifications@acme.com"
|
assert sender3 == "notifications@acme.com"
|
||||||
|
|
||||||
|
_, _, _, recipients4 = call_args_list[3][0]
|
||||||
|
assert recipients4 == ["member@test.com"]
|
||||||
|
|
||||||
|
|
||||||
def test_notify_user_by_email_no_owners(mocked_current_site, caplog):
|
def test_notify_user_by_email_no_owners(mocked_current_site, caplog):
|
||||||
"""Test email notification when no owners are found."""
|
"""Test email notification when no owners are found."""
|
||||||
@@ -237,7 +239,7 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
|||||||
notification_service = NotificationService()
|
notification_service = NotificationService()
|
||||||
|
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"core.recording.event.notification.send_mail",
|
"core.tasks.mail.send_mail",
|
||||||
side_effect=smtplib.SMTPException("SMTP Error"),
|
side_effect=smtplib.SMTPException("SMTP Error"),
|
||||||
) as mock_send_mail:
|
) as mock_send_mail:
|
||||||
result = notification_service._notify_user_by_email(recording)
|
result = notification_service._notify_user_by_email(recording)
|
||||||
@@ -247,6 +249,60 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
|||||||
assert "notification could not be sent:" in caplog.text
|
assert "notification could not be sent:" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_user_by_email_queues_generic_task(settings):
|
||||||
|
"""One generic send_email task is queued per owner, with serializable args."""
|
||||||
|
settings.RECORDING_DOWNLOAD_BASE_URL = "https://acme.com/recordings"
|
||||||
|
settings.RECORDING_EXPIRATION_DAYS = 7
|
||||||
|
|
||||||
|
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||||
|
recording.created_at = datetime.datetime(
|
||||||
|
2023, 5, 15, 14, 30, 0, tzinfo=datetime.timezone.utc
|
||||||
|
)
|
||||||
|
user = factories.UserFactory(
|
||||||
|
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
|
||||||
|
)
|
||||||
|
factories.UserRecordingAccessFactory(
|
||||||
|
recording=recording, role=models.RoleChoices.OWNER, user=user
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch("core.recording.event.notification.send_email.delay") as mock_delay:
|
||||||
|
result = notification_service._notify_user_by_email(recording)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_delay.assert_called_once_with(
|
||||||
|
template="screen_recording",
|
||||||
|
subject="Your recording is ready",
|
||||||
|
recipients=["franc@test.com"],
|
||||||
|
language="fr-fr",
|
||||||
|
context={
|
||||||
|
"room_name": "Conference Room A",
|
||||||
|
"recording_expiration_days": 7,
|
||||||
|
"link": f"https://acme.com/recordings/{recording.id}",
|
||||||
|
"recording_date": "2023-05-15",
|
||||||
|
"recording_time": "16:30",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Celery's JSON serializer must accept every argument
|
||||||
|
json.dumps(mock_delay.call_args.kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_user_by_email_broker_unavailable(caplog):
|
||||||
|
"""A broker outage is reported as a failure without crashing the caller."""
|
||||||
|
recording = factories.RecordingFactory()
|
||||||
|
factories.UserRecordingAccessFactory(
|
||||||
|
recording=recording, role=models.RoleChoices.OWNER
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.recording.event.notification.send_email.delay",
|
||||||
|
side_effect=CeleryOperationalError("broker down"),
|
||||||
|
):
|
||||||
|
result = notification_service._notify_user_by_email(recording)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert "notification could not be sent: broker down" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.recording.event.notification.requests.post")
|
@mock.patch("core.recording.event.notification.requests.post")
|
||||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
@@ -479,3 +535,37 @@ def test_notify_summary_service_v2_payload_json_serializable_without_timestamps(
|
|||||||
assert isinstance(title, str)
|
assert isinstance(title, str)
|
||||||
# ...so the payload serializes exactly the way ``requests`` serializes it.
|
# ...so the payload serializes exactly the way ``requests`` serializes it.
|
||||||
json.dumps(payload)
|
json.dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_recipient_is_oldest_access():
|
||||||
|
"""With several accesses, the summary goes to the oldest one."""
|
||||||
|
recording = factories.RecordingFactory()
|
||||||
|
oldest = factories.UserRecordingAccessFactory(
|
||||||
|
recording=recording, role=models.RoleChoices.OWNER
|
||||||
|
)
|
||||||
|
for _ in range(2):
|
||||||
|
factories.UserRecordingAccessFactory(
|
||||||
|
recording=recording, role=models.RoleChoices.OWNER
|
||||||
|
)
|
||||||
|
|
||||||
|
assert NotificationService._get_summary_recipient(recording) == oldest.user
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_recipient_is_member_starter():
|
||||||
|
"""A starter holding a MEMBER access still receives the summary."""
|
||||||
|
recording = factories.RecordingFactory()
|
||||||
|
starter = factories.UserRecordingAccessFactory(
|
||||||
|
recording=recording, role=models.RoleChoices.MEMBER
|
||||||
|
)
|
||||||
|
factories.UserRecordingAccessFactory(
|
||||||
|
recording=recording, role=models.RoleChoices.OWNER
|
||||||
|
)
|
||||||
|
|
||||||
|
assert NotificationService._get_summary_recipient(recording) == starter.user
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_recipient_none_without_access():
|
||||||
|
"""No access: nobody to send the summary to."""
|
||||||
|
recording = factories.RecordingFactory()
|
||||||
|
|
||||||
|
assert NotificationService._get_summary_recipient(recording) is None
|
||||||
|
|||||||
@@ -89,9 +89,9 @@ def test_api_recordings_media_auth_no_abilities():
|
|||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_api_recordings_media_auth_wrong_abilities():
|
def test_api_recordings_media_auth_member():
|
||||||
"""
|
"""
|
||||||
Test that users with insufficient role permissions cannot download recordings
|
Members of a recording (e.g. its starter) are allowed to download it.
|
||||||
"""
|
"""
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
|
|
||||||
@@ -102,13 +102,14 @@ def test_api_recordings_media_auth_wrong_abilities():
|
|||||||
|
|
||||||
UserRecordingAccessFactory(user=user, recording=recording, role="member")
|
UserRecordingAccessFactory(user=user, recording=recording, role="member")
|
||||||
|
|
||||||
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
|
original_url = f"http://localhost/media/{recording.key:s}"
|
||||||
|
|
||||||
response = client.get(
|
response = client.get(
|
||||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 200
|
||||||
|
assert "AWS4-HMAC-SHA256 Credential=" in response["Authorization"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"])
|
@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"])
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ def test_api_recording_retrieve_authenticated():
|
|||||||
|
|
||||||
def test_api_recording_retrieve_members():
|
def test_api_recording_retrieve_members():
|
||||||
"""
|
"""
|
||||||
A user who is a member of a recording should not be able to retrieve it.
|
A user who is a member of a recording (e.g. its starter) should be able
|
||||||
|
to retrieve it.
|
||||||
"""
|
"""
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
recording = RecordingFactory()
|
recording = RecordingFactory()
|
||||||
@@ -58,10 +59,8 @@ def test_api_recording_retrieve_members():
|
|||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||||
assert response.status_code == 403
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json()["id"] == str(recording.id)
|
||||||
"detail": "You do not have permission to perform this action."
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_recording_retrieve_administrators(settings):
|
def test_api_recording_retrieve_administrators(settings):
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Tests for the per-recording encoding resolution in BaseEgressService."""
|
||||||
|
|
||||||
|
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from livekit import api as livekit_api
|
||||||
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
|
|
||||||
|
from core.api.serializers import EncodingConfig
|
||||||
|
from core.recording.worker.exceptions import WorkerRequestError
|
||||||
|
from core.recording.worker.factories import build_encoding_options
|
||||||
|
from core.recording.worker.services import VideoCompositeEgressService
|
||||||
|
|
||||||
|
|
||||||
|
def make_config():
|
||||||
|
"""Build a minimal WorkerServiceConfig-like mock for service instantiation."""
|
||||||
|
config = Mock()
|
||||||
|
config.bucket_args = {
|
||||||
|
"endpoint": "https://s3.test.com",
|
||||||
|
"access_key": "test_key",
|
||||||
|
"secret": "test_secret",
|
||||||
|
"region": "test-region",
|
||||||
|
"bucket": "test-bucket",
|
||||||
|
"force_path_style": True,
|
||||||
|
}
|
||||||
|
config.encoding_options = None
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service():
|
||||||
|
"""Return a VideoCompositeEgressService with mocked handle_request."""
|
||||||
|
svc = VideoCompositeEgressService(make_config())
|
||||||
|
svc._handle_request = Mock()
|
||||||
|
return svc
|
||||||
|
|
||||||
|
|
||||||
|
# --- build_encoding_options ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_options_without_profile_uses_default_profile():
|
||||||
|
"""A resolution-only config should fall back to the default profile.
|
||||||
|
|
||||||
|
Left unset, framerate and video_bitrate take LiveKit's own EncodingOptions
|
||||||
|
defaults. The profile-independent fields (audio bitrate, keyframe interval,
|
||||||
|
codec/frequency pins) are always present, matching the default encoding.
|
||||||
|
"""
|
||||||
|
default_profile = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
|
||||||
|
settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||||
|
]
|
||||||
|
|
||||||
|
resolved = build_encoding_options("540p")
|
||||||
|
|
||||||
|
assert resolved == {
|
||||||
|
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||||
|
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||||
|
"audio_frequency": 48000,
|
||||||
|
"width": 960,
|
||||||
|
"height": 540,
|
||||||
|
"framerate": default_profile["fps"],
|
||||||
|
"video_bitrate": default_profile["kbps"]["540p"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(RECORDING_ENCODING_DEFAULT_PROFILE="")
|
||||||
|
def test_build_options_omits_profile_fields_without_default_profile():
|
||||||
|
"""With no default profile declared, framerate/bitrate are left to LiveKit."""
|
||||||
|
resolved = build_encoding_options("720p", None)
|
||||||
|
|
||||||
|
assert resolved == {
|
||||||
|
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||||
|
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||||
|
"audio_frequency": 48000,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
}
|
||||||
|
assert "framerate" not in resolved
|
||||||
|
assert "video_bitrate" not in resolved
|
||||||
|
|
||||||
|
|
||||||
|
def test_encoding_config_requires_resolution():
|
||||||
|
"""A profile-only or empty encoding config should be rejected at validation."""
|
||||||
|
with pytest.raises(PydanticValidationError):
|
||||||
|
EncodingConfig(profile="mixed")
|
||||||
|
with pytest.raises(PydanticValidationError):
|
||||||
|
EncodingConfig()
|
||||||
|
|
||||||
|
|
||||||
|
# --- _resolve_encoding_options ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("encoding_options", [None, {}])
|
||||||
|
def test_resolve_options_returns_none_when_empty(service, encoding_options):
|
||||||
|
"""Resolver should return None when the resolved dict is empty or missing."""
|
||||||
|
assert service._resolve_encoding_options(encoding_options) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"encoding_options",
|
||||||
|
[
|
||||||
|
{"framerate": 29.97},
|
||||||
|
{"width": "1280"},
|
||||||
|
{"unknown_field": 1},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resolve_options_invalid_raises_worker_request_error(service, encoding_options):
|
||||||
|
"""Malformed encoding options should surface as a WorkerRequestError."""
|
||||||
|
with pytest.raises(WorkerRequestError):
|
||||||
|
service._resolve_encoding_options(encoding_options)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"resolution",
|
||||||
|
list(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS),
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"profile",
|
||||||
|
list(settings.RECORDING_ENCODING_AVAILABLE_PROFILES),
|
||||||
|
)
|
||||||
|
def test_resolve_profile_resolution_combinations(service, profile, resolution):
|
||||||
|
"""Every (profile, resolution) pair should resolve to the values from settings."""
|
||||||
|
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
|
||||||
|
expected_width = resolution_config["width"]
|
||||||
|
expected_height = resolution_config["height"]
|
||||||
|
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||||
|
expected_fps = profile_config["fps"]
|
||||||
|
expected_bitrate = profile_config["kbps"][resolution]
|
||||||
|
|
||||||
|
resolved = build_encoding_options(resolution, profile)
|
||||||
|
result = service._resolve_encoding_options(resolved)
|
||||||
|
|
||||||
|
assert result.width == expected_width
|
||||||
|
assert result.height == expected_height
|
||||||
|
assert result.framerate == expected_fps
|
||||||
|
assert result.video_bitrate == expected_bitrate
|
||||||
|
# Profile-independent fields match the default encoding, never dropped.
|
||||||
|
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||||
|
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||||
|
assert result.audio_codec == livekit_api.AudioCodec.AAC
|
||||||
|
assert result.audio_frequency == 48000
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_options_none_profile_uses_default_profile(service):
|
||||||
|
"""A missing profile should resolve to the default profile's fps/bitrate."""
|
||||||
|
default_profile = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
|
||||||
|
settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||||
|
]
|
||||||
|
|
||||||
|
resolved = build_encoding_options("720p", None)
|
||||||
|
result = service._resolve_encoding_options(resolved)
|
||||||
|
|
||||||
|
assert result.width == 1280
|
||||||
|
assert result.height == 720
|
||||||
|
assert result.framerate == default_profile["fps"]
|
||||||
|
assert result.video_bitrate == default_profile["kbps"]["720p"]
|
||||||
|
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||||
|
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(RECORDING_ENCODING_DEFAULT_PROFILE="")
|
||||||
|
def test_resolve_options_passes_zero_when_no_default_profile(service):
|
||||||
|
"""With no default profile, fps/bitrate reach LiveKit unset (protobuf 0).
|
||||||
|
|
||||||
|
The pinned codec / audio fields are still applied.
|
||||||
|
"""
|
||||||
|
resolved = build_encoding_options("720p", None)
|
||||||
|
result = service._resolve_encoding_options(resolved)
|
||||||
|
|
||||||
|
assert result.width == 1280
|
||||||
|
assert result.height == 720
|
||||||
|
assert result.framerate == 0
|
||||||
|
assert result.video_bitrate == 0
|
||||||
|
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||||
|
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||||
|
assert result.audio_codec == livekit_api.AudioCodec.AAC
|
||||||
@@ -40,6 +40,16 @@ def test_settings():
|
|||||||
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
|
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
|
||||||
"AWS_S3_REGION_NAME": "test-region",
|
"AWS_S3_REGION_NAME": "test-region",
|
||||||
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
|
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
|
||||||
|
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS": {
|
||||||
|
"720p": {"width": 1280, "height": 720}
|
||||||
|
},
|
||||||
|
"RECORDING_ENCODING_AVAILABLE_PROFILES": {
|
||||||
|
"full": {"fps": 30, "kbps": {"720p": 3000}}
|
||||||
|
},
|
||||||
|
"RECORDING_ENCODING_DEFAULT_RESOLUTION": "720p",
|
||||||
|
"RECORDING_ENCODING_DEFAULT_PROFILE": "full",
|
||||||
|
"RECORDING_ENCODING_AUDIO_BITRATE_KBPS": 128,
|
||||||
|
"RECORDING_ENCODING_KEY_FRAME_INTERVAL_S": 4.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Use override_settings to properly patch Django settings
|
# Use override_settings to properly patch Django settings
|
||||||
@@ -66,8 +76,18 @@ def test_config_initialization(default_config):
|
|||||||
"bucket": "test-bucket",
|
"bucket": "test-bucket",
|
||||||
"force_path_style": True,
|
"force_path_style": True,
|
||||||
}
|
}
|
||||||
# Encoding override is opt-in; disabled by default.
|
# The default encoding is always resolved from the default profile/resolution.
|
||||||
assert default_config.encoding_options is None
|
assert default_config.encoding_options == {
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"framerate": 30,
|
||||||
|
"video_bitrate": 3000,
|
||||||
|
"audio_bitrate": 128,
|
||||||
|
"key_frame_interval": 4.0,
|
||||||
|
"video_codec": livekit_api_codec.VideoCodec.H264_MAIN,
|
||||||
|
"audio_codec": livekit_api_codec.AudioCodec.AAC,
|
||||||
|
"audio_frequency": 48000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_config_immutability(default_config):
|
def test_config_immutability(default_config):
|
||||||
@@ -76,6 +96,7 @@ def test_config_immutability(default_config):
|
|||||||
default_config.output_folder = "new/path"
|
default_config.output_folder = "new/path"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("custom_encoding_enabled", [True, False])
|
||||||
@override_settings(
|
@override_settings(
|
||||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||||
@@ -84,23 +105,25 @@ def test_config_immutability(default_config):
|
|||||||
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
||||||
AWS_S3_REGION_NAME="test-region",
|
AWS_S3_REGION_NAME="test-region",
|
||||||
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
||||||
RECORDING_ENCODING_ENABLED=True,
|
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"720p": {"width": 1280, "height": 720}},
|
||||||
RECORDING_ENCODING_WIDTH=1280,
|
RECORDING_ENCODING_AVAILABLE_PROFILES={"low": {"fps": 15, "kbps": {"720p": 600}}},
|
||||||
RECORDING_ENCODING_HEIGHT=720,
|
RECORDING_ENCODING_DEFAULT_RESOLUTION="720p",
|
||||||
RECORDING_ENCODING_FRAMERATE=15,
|
RECORDING_ENCODING_DEFAULT_PROFILE="low",
|
||||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
|
|
||||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
|
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
|
||||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
|
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
|
||||||
)
|
)
|
||||||
def test_config_encoding_options_enabled():
|
def test_config_encoding_options_default(custom_encoding_enabled):
|
||||||
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
|
"""The default encoding is always resolved from the default profile/resolution.
|
||||||
|
|
||||||
The dict mixes operator-tunable values from settings with pinned codec /
|
The default fallback resolves the default profile/resolution and mixes those
|
||||||
frequency constants, so the services layer can simply unpack it.
|
operator-tunable values with pinned codec / frequency constants. This works
|
||||||
|
regardless of RECORDING_CUSTOM_ENCODING_ENABLED, which only gates the
|
||||||
|
per-recording API, so both toggle states produce the same default.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
WorkerServiceConfig.from_settings.cache_clear()
|
with override_settings(RECORDING_CUSTOM_ENCODING_ENABLED=custom_encoding_enabled):
|
||||||
config = WorkerServiceConfig.from_settings()
|
WorkerServiceConfig.from_settings.cache_clear()
|
||||||
|
config = WorkerServiceConfig.from_settings()
|
||||||
|
|
||||||
assert config.encoding_options == {
|
assert config.encoding_options == {
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
@@ -115,6 +138,27 @@ def test_config_encoding_options_enabled():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("default_resolution", "default_profile"),
|
||||||
|
[("", "full"), ("720p", ""), ("", "")],
|
||||||
|
)
|
||||||
|
def test_config_encoding_options_none_when_default_missing(
|
||||||
|
test_settings, default_resolution, default_profile
|
||||||
|
):
|
||||||
|
"""A missing default resolution/profile leaves encoding_options None.
|
||||||
|
|
||||||
|
The service then omits the `advanced` field so LiveKit uses its built-in preset.
|
||||||
|
"""
|
||||||
|
with override_settings(
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=default_resolution,
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE=default_profile,
|
||||||
|
):
|
||||||
|
WorkerServiceConfig.from_settings.cache_clear()
|
||||||
|
config = WorkerServiceConfig.from_settings()
|
||||||
|
|
||||||
|
assert config.encoding_options is None
|
||||||
|
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
|
|||||||
# Verify worker service call
|
# Verify worker service call
|
||||||
expected_room_name = str(mock_recording.room.id)
|
expected_room_name = str(mock_recording.room.id)
|
||||||
mock_worker_service.start.assert_called_once_with(
|
mock_worker_service.start.assert_called_once_with(
|
||||||
expected_room_name, mock_recording.id
|
expected_room_name, mock_recording.id, encoding_options=None
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify recording updates
|
# Verify recording updates
|
||||||
@@ -64,6 +64,38 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
|
def test_start_recording_passes_resolved_encoding(
|
||||||
|
mock_update_metadata, mediator, mock_worker_service
|
||||||
|
):
|
||||||
|
"""The resolved encoding persisted in recording.options reaches the worker."""
|
||||||
|
mock_worker_service.start.return_value = "test-worker-123"
|
||||||
|
|
||||||
|
resolved = {
|
||||||
|
"key_frame_interval": 4.0,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"framerate": 15,
|
||||||
|
"video_bitrate": 700,
|
||||||
|
}
|
||||||
|
mock_recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.INITIATED,
|
||||||
|
worker_id=None,
|
||||||
|
options={
|
||||||
|
"encoding": {
|
||||||
|
"resolution": "720p",
|
||||||
|
"profile": "talking_heads",
|
||||||
|
"resolved": resolved,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mediator.start(mock_recording)
|
||||||
|
|
||||||
|
mock_worker_service.start.assert_called_once_with(
|
||||||
|
str(mock_recording.room.id), mock_recording.id, encoding_options=resolved
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from ...api.throttling import (
|
|||||||
)
|
)
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
from ...models import Room, RoomAccessLevel
|
from ...models import Room, RoomAccessLevel
|
||||||
|
from ..utils import generate_user_access_token
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -116,6 +117,29 @@ def test_api_rooms_create_authenticated_existing_slug():
|
|||||||
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_create_authenticated_slug_held_by_soft_deleted_room():
|
||||||
|
"""
|
||||||
|
A deleted room keeps its slug: creating a room with the same name should
|
||||||
|
fail validation rather than hit the database constraint.
|
||||||
|
"""
|
||||||
|
RoomFactory(name="my room").soft_delete()
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/rooms/",
|
||||||
|
{
|
||||||
|
"name": "My Room!",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
||||||
|
assert Room.all_objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_user_default_access_level():
|
def test_api_rooms_create_authenticated_user_default_access_level():
|
||||||
"""
|
"""
|
||||||
The user's default room access level should be applied to the new room
|
The user's default room access level should be applied to the new room
|
||||||
@@ -458,3 +482,16 @@ def test_api_rooms_create_daily_throttle_does_not_limit_other_actions(
|
|||||||
assert client.get("/api/v1.0/rooms/").status_code == 200
|
assert client.get("/api/v1.0/rooms/").status_code == 200
|
||||||
response = client.patch(f"/api/v1.0/rooms/{room_id}/", {"name": "Renamed"})
|
response = client.patch(f"/api/v1.0/rooms/{room_id}/", {"name": "Renamed"})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_create_authenticated_with_user_access_token():
|
||||||
|
"""A user access token should create a room exactly like a session would."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
response = client.post("/api/v1.0/rooms/", {"name": "my room"})
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
room = Room.objects.get()
|
||||||
|
assert room.accesses.filter(role="owner", user=user).exists()
|
||||||
|
|||||||
@@ -2,11 +2,16 @@
|
|||||||
Test rooms API endpoints in the Meet core app: delete.
|
Test rooms API endpoints in the Meet core app: delete.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from ...analytics import AnalyticsEvent
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
from ...models import Room
|
from ...models import Room, RoomAccessLevel
|
||||||
|
from ...services.room_management import RoomManagement, RoomNotFoundException
|
||||||
|
from ...services.sip_management import SIPManagement
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -83,12 +88,52 @@ def test_api_rooms_delete_administrators():
|
|||||||
assert Room.objects.count() == 1
|
assert Room.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_delete_owners():
|
@mock.patch("core.api.viewsets.analytics.capture")
|
||||||
|
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||||
|
@mock.patch.object(RoomManagement, "delete_room")
|
||||||
|
def test_api_rooms_delete_owners(mock_delete_room, _, mock_capture):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be able to delete a room for which they are directly
|
Authenticated users should be able to delete a room for which they are directly
|
||||||
owner.
|
owner. The room is soft deleted, its LiveKit room is closed and a ROOM_DELETED
|
||||||
|
analytics event is emitted.
|
||||||
"""
|
"""
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "owner")], access_level=RoomAccessLevel.TRUSTED)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
mock_delete_room.assert_called_once_with(str(room.id))
|
||||||
|
assert Room.objects.exists() is False
|
||||||
|
assert Room.all_objects.get(id=room.id).deleted_at is not None
|
||||||
|
|
||||||
|
mock_capture.assert_called_once_with(
|
||||||
|
user,
|
||||||
|
AnalyticsEvent.ROOM_DELETED,
|
||||||
|
{"room_id": str(room.pk), "access_level": RoomAccessLevel.TRUSTED},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||||
|
@mock.patch.object(
|
||||||
|
RoomManagement,
|
||||||
|
"delete_room",
|
||||||
|
side_effect=RoomNotFoundException("Room does not exist"),
|
||||||
|
)
|
||||||
|
def test_api_rooms_delete_owners_room_not_live(
|
||||||
|
mock_delete_room, mock_delete_dispatch_rule, settings
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Deleting a room that is not live in LiveKit should still soft delete it, and
|
||||||
|
delete its SIP dispatch rule, as no room_finished webhook will.
|
||||||
|
"""
|
||||||
|
settings.ROOM_TELEPHONY_ENABLED = True
|
||||||
|
user = UserFactory()
|
||||||
room = RoomFactory(users=[(user, "owner")])
|
room = RoomFactory(users=[(user, "owner")])
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
@@ -99,4 +144,47 @@ def test_api_rooms_delete_owners():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
mock_delete_room.assert_called_once_with(str(room.id))
|
||||||
|
mock_delete_dispatch_rule.assert_called_once_with(room.id)
|
||||||
assert Room.objects.exists() is False
|
assert Room.objects.exists() is False
|
||||||
|
assert Room.all_objects.get(id=room.id).deleted_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "delete_room")
|
||||||
|
def test_api_rooms_delete_soft_deleted(mock_delete_room):
|
||||||
|
"""Deleting a room that is already soft deleted should return a 410."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "owner")])
|
||||||
|
room.soft_delete()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 410
|
||||||
|
assert response.json() == {"detail": "This room has been deleted."}
|
||||||
|
mock_delete_room.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "delete_room")
|
||||||
|
def test_api_rooms_delete_soft_deleted_not_owner(mock_delete_room):
|
||||||
|
"""
|
||||||
|
Deleting a soft-deleted room as a non-owner should return a 403,
|
||||||
|
not revealing that the room has been deleted.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "administrator")])
|
||||||
|
room.soft_delete()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
mock_delete_room.assert_not_called()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from rest_framework.test import APIClient
|
|||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
from ...models import RoomAccessLevel
|
from ...models import RoomAccessLevel
|
||||||
|
from ..utils import generate_user_access_token
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -156,3 +157,18 @@ def test_api_rooms_list_pagination_page_size():
|
|||||||
assert len(content["results"]) == 3
|
assert len(content["results"]) == 3
|
||||||
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
|
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
|
||||||
assert content["previous"] is None
|
assert content["previous"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_list_authenticated_with_user_access_token():
|
||||||
|
"""A user access token should list rooms exactly like a session would."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "owner")])
|
||||||
|
RoomFactory() # another user's room, not listed
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
response = client.get("/api/v1.0/rooms/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["count"] == 1
|
||||||
|
assert response.data["results"][0]["id"] == str(room.id)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Test rooms API endpoints in the Meet core app: lobby functionality.
|
|||||||
import uuid
|
import uuid
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core import signing
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -15,13 +16,16 @@ from rest_framework.test import APIClient
|
|||||||
from ... import utils
|
from ... import utils
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
from ...models import RoomAccessLevel
|
from ...models import RoomAccessLevel
|
||||||
from ...services.lobby import (
|
from ...services.lobby import LobbyService
|
||||||
LobbyService,
|
|
||||||
)
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def _lobby_signer():
|
||||||
|
"""Use the polling credential's dedicated signing namespace."""
|
||||||
|
return signing.Signer(salt="core.lobby.participant")
|
||||||
|
|
||||||
|
|
||||||
# Tests for request_entry endpoint
|
# Tests for request_entry endpoint
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +35,6 @@ def test_request_entry_anonymous(settings):
|
|||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Lobby cache should be empty before the request
|
# Lobby cache should be empty before the request
|
||||||
@@ -49,11 +52,10 @@ def test_request_entry_anonymous(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was properly set
|
# The participant identifier is returned in the response body; no
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
# cookie is involved anymore
|
||||||
assert cookie is not None
|
assert not response.cookies
|
||||||
|
participant_id = response.json()["id"]
|
||||||
participant_id = cookie.value
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
@@ -70,7 +72,8 @@ def test_request_entry_anonymous(settings):
|
|||||||
assert len(lobby_keys) == 1
|
assert len(lobby_keys) == 1
|
||||||
|
|
||||||
# Verify participant data was correctly stored in cache
|
# Verify participant data was correctly stored in cache
|
||||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
|
raw_id = _lobby_signer().unsign(participant_id)
|
||||||
|
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{raw_id}")
|
||||||
assert participant_data.get("username") == "test_user"
|
assert participant_data.get("username") == "test_user"
|
||||||
|
|
||||||
|
|
||||||
@@ -82,7 +85,6 @@ def test_request_entry_authenticated_user(settings):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Lobby cache should be empty before the request
|
# Lobby cache should be empty before the request
|
||||||
@@ -100,11 +102,10 @@ def test_request_entry_authenticated_user(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was properly set
|
# The participant identifier is returned in the response body; no
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
# cookie is involved anymore
|
||||||
assert cookie is not None
|
assert not response.cookies
|
||||||
|
participant_id = response.json()["id"]
|
||||||
participant_id = cookie.value
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
@@ -121,7 +122,8 @@ def test_request_entry_authenticated_user(settings):
|
|||||||
assert len(lobby_keys) == 1
|
assert len(lobby_keys) == 1
|
||||||
|
|
||||||
# Verify participant data was correctly stored in cache
|
# Verify participant data was correctly stored in cache
|
||||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
|
raw_id = _lobby_signer().unsign(participant_id)
|
||||||
|
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{raw_id}")
|
||||||
assert participant_data.get("username") == "test_user"
|
assert participant_data.get("username") == "test_user"
|
||||||
|
|
||||||
|
|
||||||
@@ -133,7 +135,6 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
# Configure test settings for cookies and cache
|
# Configure test settings for cookies and cache
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Add two participants already waiting in the lobby
|
# Add two participants already waiting in the lobby
|
||||||
@@ -176,11 +177,10 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
# Verify successful response
|
# Verify successful response
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was properly set for the new participant
|
# The participant identifier is returned in the response body; no
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
# cookie is involved anymore
|
||||||
assert cookie is not None
|
assert not response.cookies
|
||||||
|
participant_id = response.json()["id"]
|
||||||
participant_id = cookie.value
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
@@ -197,7 +197,8 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
assert len(lobby_keys) == 3
|
assert len(lobby_keys) == 3
|
||||||
|
|
||||||
# Verify the new participant data was correctly stored in cache
|
# Verify the new participant data was correctly stored in cache
|
||||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
|
raw_id = _lobby_signer().unsign(participant_id)
|
||||||
|
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{raw_id}")
|
||||||
assert participant_data.get("username") == "test_user"
|
assert participant_data.get("username") == "test_user"
|
||||||
|
|
||||||
|
|
||||||
@@ -207,7 +208,6 @@ def test_request_entry_public_room(settings):
|
|||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Lobby cache should be empty before the request
|
# Lobby cache should be empty before the request
|
||||||
@@ -216,61 +216,8 @@ def test_request_entry_public_room(settings):
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
mock.patch.object(
|
mock.patch(
|
||||||
LobbyService, "_get_or_create_participant_id", return_value="123"
|
"core.services.lobby.uuid.uuid4",
|
||||||
),
|
|
||||||
mock.patch.object(
|
|
||||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
|
||||||
),
|
|
||||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
|
||||||
):
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
|
||||||
{"username": "test_user"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Verify the lobby cookie was set
|
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
|
||||||
assert cookie is not None
|
|
||||||
assert cookie.value == "123"
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
|
||||||
assert response.json() == {
|
|
||||||
"id": "123",
|
|
||||||
"username": "test_user",
|
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
"status": "accepted",
|
|
||||||
"color": "mocked-color",
|
|
||||||
"livekit": {"token": "test-token"},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Verify lobby cache is still empty after the request
|
|
||||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
|
||||||
assert not lobby_keys
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2025-01-01 10:00:00")
|
|
||||||
def test_request_entry_authenticated_user_public_room(settings):
|
|
||||||
"""While authenticated, entry request to public rooms should get accepted."""
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
|
||||||
user = UserFactory()
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
|
||||||
|
|
||||||
# Lobby cache should be empty before the request
|
|
||||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
|
||||||
assert not lobby_keys
|
|
||||||
|
|
||||||
with (
|
|
||||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
|
||||||
mock.patch.object(
|
|
||||||
LobbyService,
|
|
||||||
"_get_or_create_participant_id",
|
|
||||||
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
),
|
),
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
@@ -285,14 +232,9 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was set
|
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
|
||||||
assert cookie is not None
|
|
||||||
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
"id": _lobby_signer().sign("2f7f162f-e7d1-421b-90e7-02bfbfbf8def"),
|
||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
@@ -300,10 +242,55 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Verify lobby cache is still empty after the request
|
assert not cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||||
|
assert not LobbyService()._index_members(room.id)
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
|
def test_request_entry_authenticated_user_public_room(settings):
|
||||||
|
"""While authenticated, entry request to public rooms should get accepted."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
|
user = UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
|
# Lobby cache should be empty before the request
|
||||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||||
assert not lobby_keys
|
assert not lobby_keys
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
|
mock.patch(
|
||||||
|
"core.services.lobby.uuid.uuid4",
|
||||||
|
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||||
|
),
|
||||||
|
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Verify response content matches expected structure and values
|
||||||
|
assert response.json() == {
|
||||||
|
"id": _lobby_signer().sign("2f7f162f-e7d1-421b-90e7-02bfbfbf8def"),
|
||||||
|
"username": "test_user",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
|
"status": "accepted",
|
||||||
|
"color": "mocked-color",
|
||||||
|
"livekit": {"token": "test-token"},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert not cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||||
|
assert not LobbyService()._index_members(room.id)
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2025-01-01 10:00:00")
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
def test_request_entry_waiting_participant_public_room(settings):
|
def test_request_entry_waiting_participant_public_room(settings):
|
||||||
@@ -311,7 +298,6 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Add a waiting participant to the room's lobby cache
|
# Add a waiting participant to the room's lobby cache
|
||||||
@@ -326,9 +312,9 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Simulate a browser with existing participant cookie
|
LobbyService()._index_add(room.id, "2f7f162f-e7d1-421b-90e7-02bfbfbf8def")
|
||||||
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
|
|
||||||
|
|
||||||
|
# Simulate a returning participant echoing its identifier
|
||||||
with (
|
with (
|
||||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
@@ -337,19 +323,19 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
):
|
):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
{"username": "user1"},
|
{
|
||||||
|
"username": "user1",
|
||||||
|
"participant_id": _lobby_signer().sign(
|
||||||
|
"2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
||||||
|
),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was set
|
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
|
||||||
assert cookie is not None
|
|
||||||
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
"id": _lobby_signer().sign("2f7f162f-e7d1-421b-90e7-02bfbfbf8def"),
|
||||||
"username": "user1",
|
"username": "user1",
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
@@ -357,9 +343,8 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Verify participant remains in the lobby cache after acceptance
|
assert not cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
assert not LobbyService()._index_members(room.id)
|
||||||
assert len(lobby_keys) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_request_entry_invalid_data():
|
def test_request_entry_invalid_data():
|
||||||
@@ -662,15 +647,14 @@ def test_list_waiting_participants_empty(settings):
|
|||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||||
)
|
)
|
||||||
def test_request_entry_throttling_anonymous_without_cookie(
|
def test_request_entry_throttling_anonymous_unidentified(
|
||||||
mock_notify_participants, mock_generate_livekit_config, settings
|
mock_notify_participants, mock_generate_livekit_config, settings
|
||||||
):
|
):
|
||||||
"""Anonymous users without a cookie should not be throttled."""
|
"""Requests without a participant identifier should not be throttled."""
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
|
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -679,9 +663,6 @@ def test_request_entry_throttling_anonymous_without_cookie(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.cookies.get("mocked-cookie") is not None
|
|
||||||
|
|
||||||
client.cookies.clear() # Simulate a new cookieless request
|
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
@@ -695,34 +676,32 @@ def test_request_entry_throttling_anonymous_without_cookie(
|
|||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||||
)
|
)
|
||||||
def test_request_entry_throttling_anonymous_with_cookie(
|
def test_request_entry_throttling_anonymous_identified(
|
||||||
mock_notify_participants, mock_generate_livekit_config, settings
|
mock_notify_participants, mock_generate_livekit_config, settings
|
||||||
):
|
):
|
||||||
"""Anonymous users with a cookie should be throttled after exceeding the rate limit."""
|
"""Identified requests should be throttled after exceeding the rate limit."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||||
|
|
||||||
participant_id = str(uuid.uuid4())
|
participant_id = _lobby_signer().sign(str(uuid.uuid4()))
|
||||||
client.cookies.load({"mocked-cookie": participant_id})
|
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
{"username": "test_user"},
|
{"username": "test_user", "participant_id": participant_id},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
{"username": "test_user"},
|
{"username": "test_user", "participant_id": participant_id},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
{"username": "test_user"},
|
{"username": "test_user", "participant_id": participant_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 429
|
assert response.status_code == 429
|
||||||
@@ -741,7 +720,6 @@ def test_request_entry_throttling_authenticated_user(
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -762,3 +740,253 @@ def test_request_entry_throttling_authenticated_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 429
|
assert response.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_entry_with_participant_id(settings):
|
||||||
|
"""Echoing the previously issued identifier preserves the lobby identity across requests."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
|
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
participant_id = response.json()["id"]
|
||||||
|
|
||||||
|
# Echoing the identifier must be recognized as the same
|
||||||
|
# participant: no duplicate in the lobby
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user", "participant_id": participant_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["id"] == participant_id
|
||||||
|
assert response.json()["status"] == "waiting"
|
||||||
|
|
||||||
|
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||||
|
assert len(lobby_keys) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_entry_unknown_participant_id_not_seeded(settings):
|
||||||
|
"""A valid signed credential with no cached record creates a new participant."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
|
forged_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
|
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{
|
||||||
|
"username": "test_user",
|
||||||
|
"participant_id": _lobby_signer().sign(forged_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert _lobby_signer().unsign(response.json()["id"]) != forged_id
|
||||||
|
|
||||||
|
# Nothing was stored under the forged identifier
|
||||||
|
assert cache.get(f"mocked-cache-prefix_{room.id}_{forged_id}") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_entry_participant_id_bound_to_room(settings):
|
||||||
|
"""An identifier minted for one room must not be honored in another."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
other_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
|
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user"},
|
||||||
|
)
|
||||||
|
participant_id = response.json()["id"]
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{other_room.id}/request-entry/",
|
||||||
|
{"username": "test_user", "participant_id": participant_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["id"] != participant_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_entry_legacy_cookie_ignored():
|
||||||
|
"""The retired cookie channel must not be honored anymore."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
legacy_participant_id = str(uuid.uuid4())
|
||||||
|
client.cookies["lobbyParticipantId"] = legacy_participant_id
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
|
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
returned_id = response.json()["id"]
|
||||||
|
assert returned_id != legacy_participant_id
|
||||||
|
uuid.UUID(_lobby_signer().unsign(returned_id))
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_entry_malformed_participant_id(settings):
|
||||||
|
"""A malformed polling credential is rejected with a 400."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user", "participant_id": "../../../evil-key"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "participant_id" in response.json()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(utils, "notify_participants", return_value=None)
|
||||||
|
@mock.patch.object(utils, "generate_livekit_config")
|
||||||
|
def test_request_entry_rejects_unsigned_id(generate_config, _notify):
|
||||||
|
"""Knowing the public UUID must not grant admission."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
public_id = _lobby_signer().unsign(response.json()["id"])
|
||||||
|
LobbyService().handle_participant_entry(room.id, public_id, True)
|
||||||
|
|
||||||
|
response = APIClient().post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "Impersonator", "participant_id": public_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "participant_id" in response.json()
|
||||||
|
generate_config.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(utils, "notify_participants", return_value=None)
|
||||||
|
@mock.patch.object(utils, "generate_livekit_config")
|
||||||
|
def test_request_entry_rejects_tampered_credential(generate_config, _notify):
|
||||||
|
"""Modifying a signed credential must invalidate it."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
credential = response.json()["id"]
|
||||||
|
public_id = _lobby_signer().unsign(credential)
|
||||||
|
LobbyService().handle_participant_entry(room.id, public_id, True)
|
||||||
|
|
||||||
|
response = APIClient().post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "Impersonator", "participant_id": credential + "x"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "participant_id" in response.json()
|
||||||
|
generate_config.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(utils, "notify_participants", return_value=None)
|
||||||
|
@mock.patch.object(utils, "generate_livekit_config")
|
||||||
|
def test_request_entry_rejects_wrong_signing_secret(generate_config, _notify):
|
||||||
|
"""A credential signed with another secret must not grant admission."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
public_id = _lobby_signer().unsign(response.json()["id"])
|
||||||
|
LobbyService().handle_participant_entry(room.id, public_id, True)
|
||||||
|
|
||||||
|
forged = signing.Signer(
|
||||||
|
key="incorrect-test-signing-secret",
|
||||||
|
salt="core.lobby.participant",
|
||||||
|
fallback_keys=[],
|
||||||
|
).sign(public_id)
|
||||||
|
|
||||||
|
response = APIClient().post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "Impersonator", "participant_id": forged},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "participant_id" in response.json()
|
||||||
|
generate_config.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(utils, "notify_participants", return_value=None)
|
||||||
|
@mock.patch.object(
|
||||||
|
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||||
|
)
|
||||||
|
def test_request_entry_accepts_signed_credential(generate_config, _notify):
|
||||||
|
"""The signed credential grants admission using the public LiveKit UUID."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
credential = response.json()["id"]
|
||||||
|
public_id = _lobby_signer().unsign(credential)
|
||||||
|
assert credential != public_id
|
||||||
|
LobbyService().handle_participant_entry(room.id, public_id, True)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "Guest", "participant_id": credential},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "accepted"
|
||||||
|
assert response.json()["id"] == credential
|
||||||
|
assert response.json()["livekit"] == {"token": "test-token"}
|
||||||
|
generate_config.assert_called_once()
|
||||||
|
assert generate_config.call_args.kwargs["participant_id"] == public_id
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
|
def test_lobby_not_affected_by_recording_setting(mock_check, settings):
|
||||||
|
"""The recording opt-out must not leak into the lobby capability."""
|
||||||
|
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||||
|
user = UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
Test rooms API endpoints in the Meet core app: participants management.
|
Test rooms API endpoints in the Meet core app: participants management.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines
|
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||||
|
# pylint: disable=no-name-in-module,too-many-lines
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -19,8 +20,13 @@ from rest_framework import status
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import utils
|
from core import utils
|
||||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
from core.factories import (
|
||||||
from core.services.lobby import LobbyService
|
RoomFactory,
|
||||||
|
UserFactory,
|
||||||
|
UserResourceAccessFactory,
|
||||||
|
)
|
||||||
|
from core.services.lobby import LobbyParticipant, LobbyParticipantStatus, LobbyService
|
||||||
|
from core.tests.utils import generate_user_access_token
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -87,7 +93,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -113,7 +119,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -153,7 +159,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -300,7 +306,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -330,7 +336,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -361,7 +367,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -381,7 +387,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -412,7 +418,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -440,7 +446,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -469,7 +475,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -849,7 +855,16 @@ def test_remove_participant_success_lobby_cache(mock_livekit_client):
|
|||||||
participant_identity = str(uuid4())
|
participant_identity = str(uuid4())
|
||||||
|
|
||||||
# Create participant in lobby cache first
|
# Create participant in lobby cache first
|
||||||
LobbyService().enter(room.id, participant_identity, "John doe")
|
LobbyService()._save_participant(
|
||||||
|
room.id,
|
||||||
|
LobbyParticipant(
|
||||||
|
id=participant_identity,
|
||||||
|
username="John doe",
|
||||||
|
status=LobbyParticipantStatus.WAITING,
|
||||||
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Accept participant
|
# Accept participant
|
||||||
LobbyService().handle_participant_entry(room.id, participant_identity, True)
|
LobbyService().handle_participant_entry(room.id, participant_identity, True)
|
||||||
@@ -1020,3 +1035,119 @@ def test_remove_participant_not_found(mock_livekit_client):
|
|||||||
assert response.data == {"error": "Participant not found"}
|
assert response.data == {"error": "Participant not found"}
|
||||||
|
|
||||||
mock_livekit_client.aclose.assert_called_once()
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mute_participant_bearer_scheme_defers_to_next_authentication(
|
||||||
|
mock_livekit_client,
|
||||||
|
):
|
||||||
|
"""Should defer a "Bearer" header to the next authentication backend.
|
||||||
|
|
||||||
|
The LiveKit backend only claims the "X-LiveKit-Token" scheme. Any other
|
||||||
|
scheme must be left untouched so the backends declared after it get a
|
||||||
|
chance to authenticate the request.
|
||||||
|
"""
|
||||||
|
client = APIClient()
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
UserResourceAccessFactory(
|
||||||
|
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||||
|
)
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data == {"status": "success"}
|
||||||
|
|
||||||
|
mock_livekit_client.room.get_participant.assert_not_called()
|
||||||
|
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mute_participant_bearer_scheme_defers_role_permissions_still_apply(
|
||||||
|
mock_livekit_client,
|
||||||
|
):
|
||||||
|
"""Should still enforce room privileges once another backend authenticated."""
|
||||||
|
client = APIClient()
|
||||||
|
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||||
|
user = UserFactory() # no UserResourceAccess for this room
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mute_participant_unknown_scheme_defers_and_stays_anonymous(
|
||||||
|
mock_livekit_client,
|
||||||
|
):
|
||||||
|
"""Should leave the request unauthenticated when no backend claims the scheme."""
|
||||||
|
client = APIClient()
|
||||||
|
room = RoomFactory()
|
||||||
|
|
||||||
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mute_participant_livekit_scheme_is_case_insensitive(mock_livekit_client):
|
||||||
|
"""Should claim the LiveKit scheme whatever its casing, and not defer it."""
|
||||||
|
client = APIClient()
|
||||||
|
room = RoomFactory()
|
||||||
|
|
||||||
|
token = utils.generate_token(str(room.id), AnonymousUser())
|
||||||
|
|
||||||
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"x-livekit-token {token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data == {"status": "success"}
|
||||||
|
|
||||||
|
mock_livekit_client.room.get_participant.assert_called_once()
|
||||||
|
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mute_participant_livekit_scheme_malformed_header_is_rejected(
|
||||||
|
mock_livekit_client,
|
||||||
|
):
|
||||||
|
"""Should reject a malformed header once the LiveKit scheme is claimed."""
|
||||||
|
client = APIClient()
|
||||||
|
room = RoomFactory()
|
||||||
|
|
||||||
|
token = utils.generate_token(str(room.id), AnonymousUser())
|
||||||
|
|
||||||
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert response.data == {
|
||||||
|
"detail": "Authorization header must be: X-LiveKit-Token <token>"
|
||||||
|
}
|
||||||
|
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
"""Trusted and public rooms: any authenticated participant present in the meeting can record."""
|
||||||
|
|
||||||
|
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.factories import RecordingFactory, RoomFactory, UserFactory
|
||||||
|
from core.models import (
|
||||||
|
Recording,
|
||||||
|
RecordingStatusChoices,
|
||||||
|
RoleChoices,
|
||||||
|
RoomAccessLevel,
|
||||||
|
)
|
||||||
|
from core.recording.event.notification import NotificationService
|
||||||
|
from core.services.presence import PresenceCache
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_worker_manager():
|
||||||
|
"""Mock the worker service factory and mediator."""
|
||||||
|
with (
|
||||||
|
mock.patch("core.api.viewsets.get_worker_service"),
|
||||||
|
mock.patch("core.api.viewsets.WorkerServiceMediator") as mediator_class,
|
||||||
|
):
|
||||||
|
mediator = mock.Mock()
|
||||||
|
mediator_class.return_value = mediator
|
||||||
|
yield mediator
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def enable_recording(settings):
|
||||||
|
"""Recording must be enabled for the endpoints to be reachable."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||||
|
)
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
|
def test_open_room_present_user_can_start_recording(
|
||||||
|
mock_check, access_level, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Authenticated + present in a trusted/public room -> 201, starter is member."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(access_level=access_level)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
mock_check.assert_called_once_with(room_name=str(room.id), identity=str(user.sub))
|
||||||
|
recording = Recording.objects.get()
|
||||||
|
mock_worker_manager.start.assert_called_once_with(recording)
|
||||||
|
access = recording.accesses.get()
|
||||||
|
assert access.user == user
|
||||||
|
assert access.role == RoleChoices.MEMBER
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
|
def test_presence_is_cached_until_participant_leaves(mock_check, mock_worker_manager):
|
||||||
|
"""Presence is memoized; clearing it forces a LiveKit re-check."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||||
|
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
url = f"/api/v1.0/rooms/{room.id}/stop-recording/"
|
||||||
|
|
||||||
|
assert client.post(url).status_code == 200
|
||||||
|
assert client.post(url).status_code == 200
|
||||||
|
assert mock_check.call_count == 1
|
||||||
|
|
||||||
|
# participant_left webhook
|
||||||
|
PresenceCache().clear(room.id, str(user.sub))
|
||||||
|
mock_check.return_value = False
|
||||||
|
|
||||||
|
assert client.post(url).status_code == 403
|
||||||
|
assert mock_check.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||||
|
)
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||||
|
return_value=False,
|
||||||
|
)
|
||||||
|
def test_open_room_absent_user_forbidden(mock_check, access_level, mock_worker_manager):
|
||||||
|
"""Authenticated but not connected to the meeting -> 403."""
|
||||||
|
room = RoomFactory(access_level=access_level)
|
||||||
|
user = UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {"detail": "You are not allowed to perform this action."}
|
||||||
|
assert Recording.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
|
def test_restricted_room_present_user_forbidden(mock_check, mock_worker_manager):
|
||||||
|
"""Presence is not enough on restricted rooms; LiveKit is not even asked."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
user = UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
mock_check.assert_not_called()
|
||||||
|
assert Recording.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||||
|
)
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
|
def test_open_room_present_user_can_stop_recording(
|
||||||
|
mock_check, access_level, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Any present authenticated user can stop the active recording."""
|
||||||
|
room = RoomFactory(access_level=access_level)
|
||||||
|
recording = RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||||
|
user = UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_worker_manager.stop.assert_called_once_with(recording)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||||
|
)
|
||||||
|
def test_admin_does_not_need_presence(mock_check, mock_worker_manager):
|
||||||
|
"""Admins/owners keep recording rights on any room, without a LiveKit check."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
room.accesses.create(user=user, role=RoleChoices.ADMIN)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
mock_check.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||||
|
)
|
||||||
|
def test_open_room_anonymous_forbidden(access_level):
|
||||||
|
"""Anonymous users never record, even on public rooms."""
|
||||||
|
room = RoomFactory(access_level=access_level)
|
||||||
|
response = APIClient().post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||||
|
)
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
|
def test_participant_path_disabled_by_setting(
|
||||||
|
mock_check, access_level, settings, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Self-hosters can opt out: participants then get 403, LiveKit not asked."""
|
||||||
|
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
|
||||||
|
room = RoomFactory(access_level=access_level)
|
||||||
|
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||||
|
user = UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
start = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||||
|
)
|
||||||
|
stop = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||||
|
|
||||||
|
assert start.status_code == 403
|
||||||
|
assert stop.status_code == 403
|
||||||
|
mock_check.assert_not_called()
|
||||||
|
mock_worker_manager.start.assert_not_called()
|
||||||
|
mock_worker_manager.stop.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||||
|
)
|
||||||
|
def test_participant_path_disabled_admin_still_records(
|
||||||
|
mock_check, settings, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""The setting only removes the participant path, never admin rights."""
|
||||||
|
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||||
|
room.accesses.create(user=user, role=RoleChoices.OWNER)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
mock_check.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
|
def test_public_room_lobby_is_open_but_empty(mock_check):
|
||||||
|
"""Participants pass the lobby permission on public rooms, which have no lobby."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
|
user = UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
listing = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||||
|
entry = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||||
|
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert listing.status_code == 200
|
||||||
|
assert listing.json() == {"participants": []}
|
||||||
|
assert entry.status_code == 404
|
||||||
|
assert entry.json() == {"message": "Room has no lobby system."}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("enabled", [True, False])
|
||||||
|
def test_config_exposes_authenticated_participants_setting(settings, enabled):
|
||||||
|
"""The frontend reads the setting from /config/ to gate the UI."""
|
||||||
|
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = enabled
|
||||||
|
|
||||||
|
response = APIClient().get("/api/v1.0/config/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["recording"]["authenticated_participants_enabled"] is enabled
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
|
def test_room_owners_and_admins_get_access_to_participant_recording(
|
||||||
|
mock_check, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Room owners/admins own the recording, the starter is member; room members get nothing."""
|
||||||
|
starter = UserFactory()
|
||||||
|
room_owner = UserFactory()
|
||||||
|
room_admin = UserFactory()
|
||||||
|
room_member = UserFactory()
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||||
|
room.accesses.create(user=room_owner, role=RoleChoices.OWNER)
|
||||||
|
room.accesses.create(user=room_admin, role=RoleChoices.ADMIN)
|
||||||
|
room.accesses.create(user=room_member, role=RoleChoices.MEMBER)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(starter)
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get()
|
||||||
|
assert dict(recording.accesses.values_list("user_id", "role")) == {
|
||||||
|
starter.id: RoleChoices.MEMBER,
|
||||||
|
room_owner.id: RoleChoices.OWNER,
|
||||||
|
room_admin.id: RoleChoices.OWNER,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Room admins can see and download it; plain members cannot.
|
||||||
|
client.force_login(room_admin)
|
||||||
|
assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 200
|
||||||
|
client.force_login(room_member)
|
||||||
|
assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||||
|
)
|
||||||
|
def test_room_admin_starter_is_not_duplicated(mock_check, mock_worker_manager):
|
||||||
|
"""A room admin who starts the recording gets an exclusive access."""
|
||||||
|
starter = UserFactory()
|
||||||
|
co_owner = UserFactory()
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
room.accesses.create(user=starter, role=RoleChoices.ADMIN)
|
||||||
|
room.accesses.create(user=co_owner, role=RoleChoices.OWNER)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(starter)
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get()
|
||||||
|
assert dict(recording.accesses.values_list("user_id", "role")) == {
|
||||||
|
starter.id: RoleChoices.OWNER,
|
||||||
|
co_owner.id: RoleChoices.OWNER,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
|
def test_participant_can_stop_recording_started_by_someone_else(
|
||||||
|
mock_check, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Stopping is room-wide on trusted rooms, not tied to who started it."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||||
|
recording = RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||||
|
recording.accesses.create(user=UserFactory(), role=RoleChoices.OWNER)
|
||||||
|
user = UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_worker_manager.stop.assert_called_once_with(recording)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
|
def test_summary_goes_to_starter_among_several_owners(mock_check, mock_worker_manager):
|
||||||
|
"""The starter's access is created first, so the summary goes to them."""
|
||||||
|
starter = UserFactory()
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||||
|
for _ in range(2):
|
||||||
|
room.accesses.create(user=UserFactory(), role=RoleChoices.OWNER)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(starter)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "transcript"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get()
|
||||||
|
assert recording.accesses.count() == 3
|
||||||
|
assert NotificationService._get_summary_recipient(recording) == starter
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
|
def test_participant_starter_can_retrieve_but_not_delete(
|
||||||
|
mock_check, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""A starter without a privileged room role can download but not delete."""
|
||||||
|
starter = UserFactory()
|
||||||
|
room_owner = UserFactory()
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||||
|
room.accesses.create(user=room_owner, role=RoleChoices.OWNER)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(starter)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get()
|
||||||
|
recording.status = RecordingStatusChoices.SAVED
|
||||||
|
recording.save()
|
||||||
|
|
||||||
|
assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 200
|
||||||
|
assert client.delete(f"/api/v1.0/recordings/{recording.id}/").status_code == 403
|
||||||
|
assert Recording.objects.filter(id=recording.id).exists()
|
||||||
|
|
||||||
|
client.force_login(room_owner)
|
||||||
|
assert client.delete(f"/api/v1.0/recordings/{recording.id}/").status_code == 204
|
||||||
|
assert not Recording.objects.filter(id=recording.id).exists()
|
||||||
@@ -17,7 +17,12 @@ from rest_framework import status
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import utils
|
from core import utils
|
||||||
from core.factories import RoomFactory, UserFactory
|
from core.factories import (
|
||||||
|
RoomFactory,
|
||||||
|
UserFactory,
|
||||||
|
UserResourceAccessFactory,
|
||||||
|
)
|
||||||
|
from core.tests.utils import generate_user_access_token
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -69,7 +74,10 @@ def test_toggle_hand_raise_success(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -84,7 +92,10 @@ def test_toggle_hand_lower_success(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": False}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": False},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -101,7 +112,10 @@ def test_toggle_hand_raise_sets_timestamp(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -117,7 +131,10 @@ def test_toggle_hand_identity_derived_from_token(
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
client.post(
|
client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -128,7 +145,9 @@ def test_toggle_hand_missing_raised_field(room, token):
|
|||||||
"""Test toggle hand with missing raised field returns 400."""
|
"""Test toggle hand with missing raised field returns 400."""
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
response = client.post(
|
||||||
|
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
assert "raised" in response.data
|
assert "raised" in response.data
|
||||||
@@ -142,7 +161,7 @@ def test_toggle_hand_invalid_raised_field(room, token):
|
|||||||
url,
|
url,
|
||||||
{"raised": "not-a-boolean"},
|
{"raised": "not-a-boolean"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
@@ -166,7 +185,10 @@ def test_toggle_hand_forbidden_token_for_wrong_room(user):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {wrong_token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -181,7 +203,10 @@ def test_toggle_hand_unexpected_twirp_error(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
@@ -200,7 +225,7 @@ def test_toggle_hand_raise_success_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"raised": True},
|
{"raised": True},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -220,7 +245,7 @@ def test_toggle_hand_lower_success_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"raised": False},
|
{"raised": False},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -240,7 +265,7 @@ def test_toggle_hand_identity_derived_from_token_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"raised": True},
|
{"raised": True},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -257,7 +282,10 @@ def test_rename_participant_success(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -272,7 +300,10 @@ def test_rename_participant_sets_correct_name(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
client.post(
|
client.post(
|
||||||
url, {"name": "Jane Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "Jane Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -286,7 +317,10 @@ def test_rename_participant_uses_identity_from_token(
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
client.post(
|
client.post(
|
||||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -298,7 +332,7 @@ def test_rename_participant_empty_name(room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
@@ -309,7 +343,9 @@ def test_rename_participant_missing_name(room, token):
|
|||||||
"""Test rename with missing name field returns 400."""
|
"""Test rename with missing name field returns 400."""
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
response = client.post(
|
||||||
|
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
assert "name" in response.data
|
assert "name" in response.data
|
||||||
@@ -320,7 +356,10 @@ def test_rename_participant_name_too_long(room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": "a" * 256}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "a" * 256},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
@@ -348,7 +387,7 @@ def test_rename_participant_forbidden_token_for_wrong_room(user):
|
|||||||
url,
|
url,
|
||||||
{"name": "John Doe"},
|
{"name": "John Doe"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {wrong_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -363,7 +402,10 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
@@ -385,7 +427,10 @@ def test_rename_participant_forbidden_when_display_name_edit_disabled(
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": name}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": name},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -404,7 +449,10 @@ def test_rename_participant_allowed_when_display_name_edit_enabled(
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -426,7 +474,7 @@ def test_rename_participant_anonymous_allowed_when_display_name_edit_disabled(
|
|||||||
url,
|
url,
|
||||||
{"name": "Guest User"},
|
{"name": "Guest User"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -443,7 +491,7 @@ def test_rename_participant_success_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"name": "Guest User"},
|
{"name": "Guest User"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -463,7 +511,7 @@ def test_rename_participant_uses_identity_from_token_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"name": "Guest User"},
|
{"name": "Guest User"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -480,7 +528,7 @@ def test_rename_participant_sets_correct_name_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"name": "Guest User"},
|
{"name": "Guest User"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -497,7 +545,7 @@ def test_rename_participant_forbidden_anonymous_token_for_wrong_room(anonymous_t
|
|||||||
url,
|
url,
|
||||||
{"name": "Guest User"},
|
{"name": "Guest User"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -523,7 +571,7 @@ def test_toggle_hand_expired_token(room, expired_token):
|
|||||||
url,
|
url,
|
||||||
{"raised": True},
|
{"raised": True},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -537,7 +585,7 @@ def test_rename_participant_expired_token(room, expired_token):
|
|||||||
url,
|
url,
|
||||||
{"name": "John Doe"},
|
{"name": "John Doe"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -551,7 +599,7 @@ def test_toggle_hand_malformed_token(room):
|
|||||||
url,
|
url,
|
||||||
{"raised": True},
|
{"raised": True},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
|
HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -565,7 +613,10 @@ def test_toggle_hand_room_not_found(user):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
@@ -580,7 +631,10 @@ def test_toggle_hand_participant_not_found(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
@@ -597,7 +651,7 @@ def test_rename_participant_malformed_token(room):
|
|||||||
url,
|
url,
|
||||||
{"name": "John Doe"},
|
{"name": "John Doe"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
|
HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -611,7 +665,10 @@ def test_rename_participant_room_not_found(user):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
|
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
@@ -626,10 +683,188 @@ def test_rename_participant_not_found(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
assert response.data == {"error": "Participant not found"}
|
assert response.data == {"error": "Participant not found"}
|
||||||
|
|
||||||
mock_livekit_client.aclose.assert_called_once()
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_access_token(user):
|
||||||
|
"""Generate a valid user access JWT, sent with the "X-LiveKit-Token" scheme."""
|
||||||
|
return generate_user_access_token(user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_bearer_scheme_defers_to_next_authentication(
|
||||||
|
mock_livekit_client, room, user, user_access_token
|
||||||
|
):
|
||||||
|
"""Test toggle hand defers a "Bearer" header instead of failing on it."""
|
||||||
|
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {user_access_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert response.data == {"detail": "Authentication credentials were not provided."}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_bearer_scheme_defers_to_next_authentication(
|
||||||
|
mock_livekit_client, room, user, user_access_token
|
||||||
|
):
|
||||||
|
"""Test rename defers a "Bearer" header instead of failing on it."""
|
||||||
|
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {user_access_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert response.data == {"detail": "Authentication credentials were not provided."}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_unknown_scheme_defers(mock_livekit_client, room):
|
||||||
|
"""Test toggle hand defers a scheme no backend recognizes."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert response.data == {"detail": "Authentication credentials were not provided."}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_unknown_scheme_defers(mock_livekit_client, room):
|
||||||
|
"""Test rename defers a scheme no backend recognizes."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert response.data == {"detail": "Authentication credentials were not provided."}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_session_authentication_is_not_accepted(
|
||||||
|
mock_livekit_client, room, user
|
||||||
|
):
|
||||||
|
"""Test toggle hand is not granted by a session, whatever the user's room role."""
|
||||||
|
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(url, {"raised": True}, format="json")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_session_authentication_is_not_accepted(
|
||||||
|
mock_livekit_client, room, user
|
||||||
|
):
|
||||||
|
"""Test rename is not granted by a session, whatever the user's room role."""
|
||||||
|
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(url, {"name": "John Doe"}, format="json")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_livekit_scheme_is_case_insensitive(
|
||||||
|
mock_livekit_client, room, token
|
||||||
|
):
|
||||||
|
"""Test rename claims the LiveKit scheme whatever its casing."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"x-livekit-token {token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data == {"status": "success"}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_livekit_scheme_malformed_header_is_rejected(
|
||||||
|
mock_livekit_client, room, token
|
||||||
|
):
|
||||||
|
"""Test toggle hand rejects a malformed header once the LiveKit scheme is claimed."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert response.data == {
|
||||||
|
"detail": "Authorization header must be: X-LiveKit-Token <token>"
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_livekit_scheme_malformed_header_is_rejected(
|
||||||
|
mock_livekit_client, room, token
|
||||||
|
):
|
||||||
|
"""Test rename rejects a malformed header once the LiveKit scheme is claimed."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert response.data == {
|
||||||
|
"detail": "Authorization header must be: X-LiveKit-Token <token>"
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|||||||
@@ -7,13 +7,18 @@ from unittest import mock
|
|||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone as dj_timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
from ...factories import (
|
||||||
|
RoomFactory,
|
||||||
|
UserFactory,
|
||||||
|
UserResourceAccessFactory,
|
||||||
|
)
|
||||||
from ...models import RoleChoices, RoomAccessLevel
|
from ...models import RoleChoices, RoomAccessLevel
|
||||||
|
from ..utils import generate_user_access_token
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -188,6 +193,28 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
|
|||||||
assert response.json() == {"detail": "No Room matches the given query."}
|
assert response.json() == {"detail": "No Room matches the given query."}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("allow_unregistered_rooms", [True, False])
|
||||||
|
@pytest.mark.parametrize("lookup", ["id", "slug"])
|
||||||
|
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||||
|
def test_api_rooms_retrieve_soft_deleted(
|
||||||
|
mock_token, lookup, allow_unregistered_rooms, settings
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Retrieving a soft-deleted room should return a 410, and never fall back
|
||||||
|
to an unregistered room with the same slug.
|
||||||
|
"""
|
||||||
|
settings.ALLOW_UNREGISTERED_ROOMS = allow_unregistered_rooms
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
|
room.soft_delete()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.get(f"/api/v1.0/rooms/{getattr(room, lookup)!s}/")
|
||||||
|
|
||||||
|
assert response.status_code == 410
|
||||||
|
assert response.json() == {"detail": "This room has been deleted."}
|
||||||
|
mock_token.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
LIVEKIT_CONFIGURATION={
|
LIVEKIT_CONFIGURATION={
|
||||||
@@ -514,7 +541,7 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
@pytest.mark.parametrize("role", [None, *RoleChoices])
|
@pytest.mark.parametrize("role", [None, *RoleChoices])
|
||||||
def test_api_rooms_retrieve_last_started_at_not_exposed(role, access_level):
|
def test_api_rooms_retrieve_last_started_at_not_exposed(role, access_level):
|
||||||
"""Should not expose when the room was last started, whoever the requester is."""
|
"""Should not expose when the room was last started, whoever the requester is."""
|
||||||
room = RoomFactory(access_level=access_level, last_started_at=timezone.now())
|
room = RoomFactory(access_level=access_level, last_started_at=dj_timezone.now())
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
if role is not None:
|
if role is not None:
|
||||||
@@ -525,3 +552,18 @@ def test_api_rooms_retrieve_last_started_at_not_exposed(role, access_level):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "last_started_at" not in response.json()
|
assert "last_started_at" not in response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_retrieve_authenticated_with_user_access_token():
|
||||||
|
"""A user access token should retrieve a room exactly like a session would."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "owner")])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["id"] == str(room.id)
|
||||||
|
assert response.data["pin_code"] == room.pin_code
|
||||||
|
assert "accesses" in response.data
|
||||||
|
|||||||
@@ -2,15 +2,16 @@
|
|||||||
Test rooms API endpoints in the Meet core app: start recording.
|
Test rooms API endpoints in the Meet core app: start recording.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name,unused-argument
|
# pylint: disable=redefined-outer-name,unused-argument,no-member
|
||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from livekit import api as livekit_api
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
from ...models import Recording
|
from ...models import Recording, RoomAccessLevel
|
||||||
from ...recording.worker.exceptions import RecordingStartError
|
from ...recording.worker.exceptions import RecordingStartError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -41,8 +42,13 @@ def mock_worker_manager(mock_worker_service):
|
|||||||
yield mock_mediator
|
yield mock_mediator
|
||||||
|
|
||||||
|
|
||||||
def test_start_recording_anonymous():
|
@pytest.mark.parametrize(
|
||||||
|
"recording_enabled, expected_status",
|
||||||
|
[(True, 401), (False, 404)],
|
||||||
|
)
|
||||||
|
def test_start_recording_anonymous(settings, recording_enabled, expected_status):
|
||||||
"""Anonymous users should not be allowed to start room recordings."""
|
"""Anonymous users should not be allowed to start room recordings."""
|
||||||
|
settings.RECORDING_ENABLE = recording_enabled
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
@@ -51,14 +57,24 @@ def test_start_recording_anonymous():
|
|||||||
{"mode": "screen_recording"},
|
{"mode": "screen_recording"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == expected_status
|
||||||
assert Recording.objects.count() == 0
|
assert Recording.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
def test_start_recording_non_owner_and_non_administrator(settings):
|
@pytest.mark.parametrize("authenticated_participants_enabled", [True, False])
|
||||||
"""Non-owner and Non-Administrator users should not be allowed to start room recordings."""
|
def test_start_recording_restricted_room_non_owner_and_non_administrator(
|
||||||
|
settings, authenticated_participants_enabled
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
On restricted rooms, non-owner and non-administrator users should not be
|
||||||
|
allowed to start recordings, whether or not authenticated participants
|
||||||
|
are allowed to record.
|
||||||
|
"""
|
||||||
settings.RECORDING_ENABLE = True
|
settings.RECORDING_ENABLE = True
|
||||||
room = RoomFactory()
|
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = (
|
||||||
|
authenticated_participants_enabled
|
||||||
|
)
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
@@ -470,6 +486,224 @@ def test_start_recording_options_unknown_field_rejected(settings):
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_valid(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Should accept a valid encoding configuration."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_rejected_when_custom_encoding_disabled(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Per-recording encoding is rejected when RECORDING_CUSTOM_ENCODING_ENABLED is off."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = False
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert not Recording.objects.filter(room=room).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_persists_resolved_encoding(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""The resolved encoding should be persisted in recording.options alongside
|
||||||
|
the requested resolution/profile for traceability."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options["encoding"] == {
|
||||||
|
"resolution": "720p",
|
||||||
|
"profile": "talking_heads",
|
||||||
|
"resolved": {
|
||||||
|
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||||
|
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||||
|
"audio_frequency": 48000,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"framerate": 15,
|
||||||
|
"video_bitrate": 700,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_resolution_only_uses_default_profile(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""An encoding without a profile should resolve the default profile."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
settings.RECORDING_ENCODING_DEFAULT_PROFILE = "talking_heads"
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"encoding": {"resolution": "540p"}}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
resolved = recording.options["encoding"]["resolved"]
|
||||||
|
assert resolved["width"] == 960
|
||||||
|
assert resolved["height"] == 540
|
||||||
|
assert resolved["framerate"] == 15
|
||||||
|
assert resolved["video_bitrate"] == 400
|
||||||
|
# The requested payload is persisted as sent: no profile was asked for.
|
||||||
|
assert "profile" not in recording.options["encoding"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_forwards_resolved_encoding_to_worker(
|
||||||
|
settings, mock_worker_service, mock_worker_service_factory
|
||||||
|
):
|
||||||
|
"""The resolved encoding should passed on to the worker."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
mock_worker_service.start.return_value = "egress-123"
|
||||||
|
|
||||||
|
with mock.patch("core.services.room_management.RoomManagement.update_metadata"):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {
|
||||||
|
"encoding": {"resolution": "720p", "profile": "talking_heads"}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
mock_worker_service.start.assert_called_once_with(
|
||||||
|
str(room.id),
|
||||||
|
recording.id,
|
||||||
|
encoding_options=recording.options["encoding"]["resolved"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_invalid_resolution(settings):
|
||||||
|
"""Should reject invalid encoding resolution values."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"encoding": {"resolution": "4K"}}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_unknown_key_rejected(settings):
|
||||||
|
"""Should reject unknown keys in encoding configuration."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"bitrate": 9000}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_without_encoding_unchanged(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Requests without encoding should keep existing options behavior."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"language": "fr"}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options == {"language": "fr"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["foo", 12])
|
@pytest.mark.parametrize("value", ["foo", 12])
|
||||||
def test_start_recording_options_invalid_transcribe_type(settings, value):
|
def test_start_recording_options_invalid_transcribe_type(settings, value):
|
||||||
"""Should reject non-boolean transcribe values."""
|
"""Should reject non-boolean transcribe values."""
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import pytest
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RecordingFactory, RoomFactory, UserFactory
|
from ...factories import RecordingFactory, RoomFactory, UserFactory
|
||||||
from ...models import Recording, RecordingStatusChoices
|
from ...models import Recording, RecordingStatusChoices, RoomAccessLevel
|
||||||
from ...recording.worker.exceptions import RecordingStopError
|
from ...recording.worker.exceptions import RecordingStopError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -41,15 +41,20 @@ def mock_worker_manager(mock_worker_service):
|
|||||||
yield mock_mediator
|
yield mock_mediator
|
||||||
|
|
||||||
|
|
||||||
def test_stop_recording_anonymous():
|
@pytest.mark.parametrize(
|
||||||
|
"recording_enabled, expected_status",
|
||||||
|
[(True, 401), (False, 404)],
|
||||||
|
)
|
||||||
|
def test_stop_recording_anonymous(settings, recording_enabled, expected_status):
|
||||||
"""Anonymous users should not be allowed to stop room recordings."""
|
"""Anonymous users should not be allowed to stop room recordings."""
|
||||||
|
settings.RECORDING_ENABLE = recording_enabled
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == expected_status
|
||||||
# Verify recording status hasn't changed
|
# Verify recording status hasn't changed
|
||||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||||
|
|
||||||
@@ -57,7 +62,7 @@ def test_stop_recording_anonymous():
|
|||||||
def test_stop_recording_non_owner_and_non_administrator(settings):
|
def test_stop_recording_non_owner_and_non_administrator(settings):
|
||||||
"""Non-owner and Non-Administrator users should not be allowed to stop room recordings."""
|
"""Non-owner and Non-Administrator users should not be allowed to stop room recordings."""
|
||||||
settings.RECORDING_ENABLE = True
|
settings.RECORDING_ENABLE = True
|
||||||
room = RoomFactory()
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import pytest
|
|||||||
from livekit.api import AccessToken, TwirpError, VideoGrants
|
from livekit.api import AccessToken, TwirpError, VideoGrants
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory
|
from core.factories import RoomFactory, UserFactory
|
||||||
|
from core.tests.utils import generate_user_access_token
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ def test_start_subtitle_invalid_token():
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION="Bearer invalid-token",
|
HTTP_AUTHORIZATION="X-LiveKit-Token invalid-token",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -130,7 +131,7 @@ def test_start_subtitle_disabled_by_default(mock_livekit_token, settings):
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
@@ -150,7 +151,7 @@ def test_start_subtitle_valid_token(
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -180,7 +181,7 @@ def test_start_subtitle_twirp_error(
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
@@ -200,7 +201,7 @@ def test_start_subtitle_wrong_room(settings, mock_livekit_token):
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -221,10 +222,114 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"detail": "Invalid LiveKit token: Signature verification failed"
|
"detail": "Invalid LiveKit token: Signature verification failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_access_token():
|
||||||
|
"""Generate a valid user access JWT, sent with the "Bearer" scheme."""
|
||||||
|
return generate_user_access_token(UserFactory())
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_subtitle_bearer_scheme_defers_to_next_authentication(
|
||||||
|
settings, mock_livekit_client, user_access_token
|
||||||
|
):
|
||||||
|
"""Test that a "Bearer" header is deferred instead of failing on the LiveKit backend.
|
||||||
|
|
||||||
|
The action declares LiveKitTokenAuthentication as its only backend, so a
|
||||||
|
scheme it does not own must be left to the next one. None follows, so the
|
||||||
|
request ends up unauthenticated: the body reports missing credentials
|
||||||
|
rather than an invalid LiveKit token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
settings.ROOM_SUBTITLE_ENABLED = True
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
|
{},
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {user_access_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "Authentication credentials were not provided."
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_subtitle_unknown_scheme_defers(settings, mock_livekit_client):
|
||||||
|
"""Test that a scheme no backend recognizes is deferred, not rejected."""
|
||||||
|
|
||||||
|
settings.ROOM_SUBTITLE_ENABLED = True
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
|
{},
|
||||||
|
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "Authentication credentials were not provided."
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_subtitle_scheme_is_case_insensitive(
|
||||||
|
settings, mock_livekit_client, mock_livekit_token, mock_room_id
|
||||||
|
):
|
||||||
|
"""Test that the LiveKit scheme is claimed whatever its casing."""
|
||||||
|
|
||||||
|
settings.ROOM_SUBTITLE_ENABLED = True
|
||||||
|
|
||||||
|
room = RoomFactory(id=mock_room_id)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
|
{},
|
||||||
|
HTTP_AUTHORIZATION=f"x-livekit-token {mock_livekit_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "success"}
|
||||||
|
|
||||||
|
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_subtitle_malformed_header_is_rejected(
|
||||||
|
settings, mock_livekit_client, mock_livekit_token
|
||||||
|
):
|
||||||
|
"""Test that a malformed header is rejected once the LiveKit scheme is claimed."""
|
||||||
|
|
||||||
|
settings.ROOM_SUBTITLE_ENABLED = True
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
|
{},
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token} extra-part",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "Authorization header must be: X-LiveKit-Token <token>"
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called()
|
||||||
|
|||||||
@@ -11,13 +11,15 @@ from django.utils import timezone
|
|||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from ...api.serializers import RoomSerializer
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
from ...models import RoomAccessLevel
|
from ...models import Room, RoomAccessLevel
|
||||||
from ...services.room_management import (
|
from ...services.room_management import (
|
||||||
RoomManagement,
|
RoomManagement,
|
||||||
RoomManagementException,
|
RoomManagementException,
|
||||||
RoomNotFoundException,
|
RoomNotFoundException,
|
||||||
)
|
)
|
||||||
|
from ..utils import generate_user_access_token
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -446,3 +448,61 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
|
|||||||
"configuration": {"can_publish_sources": ["camera"]},
|
"configuration": {"can_publish_sources": ["camera"]},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("privileged_role", ["administrator", "owner"])
|
||||||
|
def test_api_rooms_update_authenticated_with_user_access_token(privileged_role):
|
||||||
|
"""Role-based permissions apply unchanged with a user access token."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "member")])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
# A simple member cannot update the room
|
||||||
|
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
# An administrator or an owner can
|
||||||
|
room.accesses.filter(user=user).update(role=privileged_role)
|
||||||
|
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.name == "new name"
|
||||||
|
|
||||||
|
|
||||||
|
@patch.object(RoomManagement, "sync_room_metadata")
|
||||||
|
def test_api_rooms_update_soft_deleted_after_load(mock_sync_room_metadata):
|
||||||
|
"""A room soft deleted while an update is in flight should not be restored."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(
|
||||||
|
name="Old name",
|
||||||
|
access_level=RoomAccessLevel.RESTRICTED,
|
||||||
|
users=[(user, "owner")],
|
||||||
|
)
|
||||||
|
|
||||||
|
original_is_valid = RoomSerializer.is_valid
|
||||||
|
|
||||||
|
def soft_delete_while_validating(self, **kwargs):
|
||||||
|
is_valid = original_is_valid(self, **kwargs)
|
||||||
|
Room.all_objects.get(pk=self.instance.pk).soft_delete()
|
||||||
|
return is_valid
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
with patch.object(RoomSerializer, "is_valid", soft_delete_while_validating):
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/v1.0/rooms/{room.id!s}/",
|
||||||
|
{"name": "New name", "access_level": RoomAccessLevel.PUBLIC},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 410
|
||||||
|
assert response.json() == {"detail": "This room has been deleted."}
|
||||||
|
|
||||||
|
refreshed_room = Room.all_objects.get(pk=room.pk)
|
||||||
|
assert refreshed_room.deleted_at is not None
|
||||||
|
assert refreshed_room.name == "Old name"
|
||||||
|
assert refreshed_room.access_level == RoomAccessLevel.RESTRICTED
|
||||||
|
|
||||||
|
mock_sync_room_metadata.assert_not_called()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Test LiveKitEvents service.
|
Test LiveKitEvents service.
|
||||||
"""
|
"""
|
||||||
# pylint: disable=W0621,W0613, W0212, E0611
|
# pylint: disable=W0621,W0613, W0212, E0611, C0302
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
@@ -27,7 +27,10 @@ from core.services.livekit_events import (
|
|||||||
to_recording_event,
|
to_recording_event,
|
||||||
)
|
)
|
||||||
from core.services.lobby import LobbyService
|
from core.services.lobby import LobbyService
|
||||||
from core.services.room_management import RoomManagementException
|
from core.services.room_management import (
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
from core.services.sip_management import (
|
from core.services.sip_management import (
|
||||||
SIPException,
|
SIPException,
|
||||||
SIPManagement,
|
SIPManagement,
|
||||||
@@ -796,6 +799,61 @@ def test_handle_room_started_raises_error_for_nonexistent_room(service):
|
|||||||
service._handle_room_started(mock_data)
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||||
|
@mock.patch("core.services.room_management.RoomManagement.delete_room")
|
||||||
|
def test_handle_room_started_closes_deleted_room(
|
||||||
|
mock_delete_room, mock_ensure_dispatch_rule, service, settings
|
||||||
|
):
|
||||||
|
"""Should close a LiveKit room recreated for a soft-deleted room."""
|
||||||
|
settings.ROOM_TELEPHONY_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
room.soft_delete()
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.room.name = str(room.id)
|
||||||
|
|
||||||
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
|
mock_delete_room.assert_called_once_with(str(room.id))
|
||||||
|
mock_ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.room_management.RoomManagement.delete_room",
|
||||||
|
side_effect=RoomNotFoundException("Room does not exist"),
|
||||||
|
)
|
||||||
|
def test_handle_room_started_ignores_already_closed_deleted_room(
|
||||||
|
mock_delete_room, service
|
||||||
|
):
|
||||||
|
"""Should proceed silently when the deleted room is already closed in LiveKit."""
|
||||||
|
room = RoomFactory()
|
||||||
|
room.soft_delete()
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.room.name = str(room.id)
|
||||||
|
|
||||||
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
|
mock_delete_room.assert_called_once_with(str(room.id))
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.services.room_management.RoomManagement.delete_room",
|
||||||
|
side_effect=RoomManagementException("Could not delete room"),
|
||||||
|
)
|
||||||
|
def test_handle_room_started_raises_error_when_closing_deleted_room_fails(
|
||||||
|
mock_delete_room, service
|
||||||
|
):
|
||||||
|
"""Should raise ActionFailedError when the deleted room cannot be closed."""
|
||||||
|
room = RoomFactory()
|
||||||
|
room.soft_delete()
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.room.name = str(room.id)
|
||||||
|
|
||||||
|
expected_error = f"Failed to close deleted room {room.id}"
|
||||||
|
|
||||||
|
with pytest.raises(ActionFailedError, match=expected_error):
|
||||||
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload")
|
api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,20 +2,20 @@
|
|||||||
Test lobby service.
|
Test lobby service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=W0621,W0613, W0212, R0913, C0302
|
# pylint: disable=W0621,W0613, W0212, R0913, C0302, R0917
|
||||||
# ruff: noqa: PLR0913, PLR0917
|
# ruff: noqa: PLR0913, PLR0917
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings as django_settings
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.http import HttpResponse
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
|
|
||||||
|
from core import utils
|
||||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||||
from core.models import RoleChoices, RoomAccessLevel
|
from core.models import RoleChoices, RoomAccessLevel
|
||||||
from core.services.lobby import (
|
from core.services.lobby import (
|
||||||
@@ -151,63 +151,10 @@ def test_get_cache_key(lobby_service, participant_id):
|
|||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = lobby_service._get_cache_key(room.id, participant_id)
|
cache_key = lobby_service._get_cache_key(room.id, participant_id)
|
||||||
|
|
||||||
expected_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_{participant_id}"
|
expected_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_{participant_id}"
|
||||||
assert cache_key == expected_key
|
assert cache_key == expected_key
|
||||||
|
|
||||||
|
|
||||||
def test_get_or_create_participant_id_from_cookie(lobby_service):
|
|
||||||
"""Test extracting participant ID from cookie."""
|
|
||||||
request = mock.Mock()
|
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: "existing-id"}
|
|
||||||
|
|
||||||
participant_id = lobby_service._get_or_create_participant_id(request)
|
|
||||||
|
|
||||||
assert participant_id == "existing-id"
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(uuid, "uuid4", return_value="generated-id")
|
|
||||||
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
|
|
||||||
"""Test creating new participant ID when cookie is missing."""
|
|
||||||
request = mock.Mock()
|
|
||||||
request.COOKIES = {}
|
|
||||||
|
|
||||||
participant_id = lobby_service._get_or_create_participant_id(request)
|
|
||||||
|
|
||||||
assert participant_id == "generated-id"
|
|
||||||
mock_uuid4.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_response_existing_cookie(lobby_service, participant_id):
|
|
||||||
"""Test response preparation with existing cookie."""
|
|
||||||
response = HttpResponse()
|
|
||||||
response.cookies[settings.LOBBY_COOKIE_NAME] = "existing-cookie"
|
|
||||||
|
|
||||||
lobby_service.prepare_response(response, participant_id)
|
|
||||||
|
|
||||||
# Verify cookie wasn't set again
|
|
||||||
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
|
|
||||||
assert cookie.value == "existing-cookie"
|
|
||||||
assert cookie.value != participant_id
|
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_response_new_cookie(lobby_service, participant_id):
|
|
||||||
"""Test response preparation with new cookie."""
|
|
||||||
response = HttpResponse()
|
|
||||||
|
|
||||||
lobby_service.prepare_response(response, participant_id)
|
|
||||||
|
|
||||||
# Verify cookie was set
|
|
||||||
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
|
|
||||||
assert cookie is not None
|
|
||||||
assert cookie.value == participant_id
|
|
||||||
assert cookie["httponly"] is True
|
|
||||||
assert cookie["secure"] is True
|
|
||||||
assert cookie["samesite"] == "Lax"
|
|
||||||
|
|
||||||
# It's a session cookies (no max_age specified):
|
|
||||||
assert not cookie["max-age"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_can_bypass_lobby_public_room(lobby_service):
|
def test_can_bypass_lobby_public_room(lobby_service):
|
||||||
"""Should return True for public rooms regardless of user auth and role."""
|
"""Should return True for public rooms regardless of user auth and role."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
@@ -271,94 +218,99 @@ def test_can_bypass_lobby_private_room_with_any_role(role, lobby_service):
|
|||||||
|
|
||||||
@mock.patch("core.utils.generate_livekit_config")
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
def test_request_entry_public_room(
|
def test_request_entry_public_room(
|
||||||
mock_generate_config, lobby_service, participant_id, username
|
mock_generate_config, lobby_service, participant_id, username, settings
|
||||||
):
|
):
|
||||||
"""Test requesting entry to a public room."""
|
"""Test requesting entry to a public room."""
|
||||||
request = mock.Mock()
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
request.user = AnonymousUser()
|
|
||||||
|
user = AnonymousUser()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
|
|
||||||
mocked_participant = LobbyParticipant(
|
cache.set(
|
||||||
status=LobbyParticipantStatus.UNKNOWN,
|
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||||
username=username,
|
{
|
||||||
id=participant_id,
|
"id": participant_id,
|
||||||
color="#123456",
|
"username": username,
|
||||||
entered_at="2025-01-01T10:00:00+00:00",
|
"status": "waiting",
|
||||||
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
|
||||||
mock_generate_config.return_value = {"token": "test-token"}
|
mock_generate_config.return_value = {"token": "test-token"}
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, user, username, participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||||
assert livekit_config == {"token": "test-token"}
|
assert livekit_config == {"token": "test-token"}
|
||||||
mock_generate_config.assert_called_once_with(
|
mock_generate_config.assert_called_once_with(
|
||||||
room_id=str(room.id),
|
room_id=str(room.id),
|
||||||
user=request.user,
|
user=user,
|
||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id="test-participant-id",
|
participant_id=participant_id,
|
||||||
role=None,
|
role=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.generate_livekit_config")
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
def test_request_entry_trusted_room(
|
def test_request_entry_trusted_room(
|
||||||
mock_generate_config, lobby_service, participant_id, username
|
mock_generate_config, lobby_service, participant_id, username, settings
|
||||||
):
|
):
|
||||||
"""Test requesting entry to a trusted room when the user is authenticated."""
|
"""Test requesting entry to a trusted room when the user is authenticated."""
|
||||||
request = mock.Mock()
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
request.user = UserFactory()
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||||
|
|
||||||
mocked_participant = LobbyParticipant(
|
cache.set(
|
||||||
status=LobbyParticipantStatus.UNKNOWN,
|
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||||
username=username,
|
{
|
||||||
id=participant_id,
|
"id": participant_id,
|
||||||
color="#123456",
|
"username": username,
|
||||||
entered_at="2025-01-01T10:00:00+00:00",
|
"status": "waiting",
|
||||||
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
|
||||||
mock_generate_config.return_value = {"token": "test-token"}
|
mock_generate_config.return_value = {"token": "test-token"}
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, user, username, participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||||
assert livekit_config == {"token": "test-token"}
|
assert livekit_config == {"token": "test-token"}
|
||||||
mock_generate_config.assert_called_once_with(
|
mock_generate_config.assert_called_once_with(
|
||||||
room_id=str(room.id),
|
room_id=str(room.id),
|
||||||
user=request.user,
|
user=user,
|
||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id="test-participant-id",
|
participant_id=participant_id,
|
||||||
role=None,
|
role=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.lobby.LobbyService._notify_entry_request")
|
||||||
@mock.patch("core.services.lobby.LobbyService.enter")
|
@mock.patch("core.services.lobby.LobbyService._create_participant")
|
||||||
def test_request_entry_new_participant(
|
def test_request_entry_new_participant(
|
||||||
mock_enter, lobby_service, participant_id, username
|
mock_create, mock_notify, lobby_service, participant_id, username
|
||||||
):
|
):
|
||||||
"""Test requesting entry for a new participant."""
|
"""A new participant gets a server-minted identifier - any provided
|
||||||
request = mock.Mock()
|
one is unknown to the lobby and therefore discarded - and the room is
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
notified of the entry request."""
|
||||||
request.user = AnonymousUser()
|
|
||||||
|
user = AnonymousUser()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=None)
|
lobby_service._get_participant = mock.Mock(return_value=None)
|
||||||
|
|
||||||
participant_data = LobbyParticipant(
|
participant_data = LobbyParticipant(
|
||||||
@@ -368,14 +320,20 @@ def test_request_entry_new_participant(
|
|||||||
color="#123456",
|
color="#123456",
|
||||||
entered_at="2025-01-01T10:00:00+00:00",
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
mock_enter.return_value = participant_data
|
mock_create.return_value = participant_data
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
forged_id = str(uuid.uuid4())
|
||||||
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, user, username, participant_id=forged_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant == participant_data
|
assert participant == participant_data
|
||||||
assert livekit_config is None
|
assert livekit_config is None
|
||||||
mock_enter.assert_called_once_with(room.id, participant_id, username)
|
# The provided identifier was looked up, found unknown, and replaced
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
# by a freshly minted participant
|
||||||
|
lobby_service._get_participant.assert_called_once_with(room.id, forged_id)
|
||||||
|
mock_create.assert_called_once_with(username)
|
||||||
|
mock_notify.assert_called_once_with(str(room.id))
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.lobby.LobbyService.refresh_waiting_status")
|
@mock.patch("core.services.lobby.LobbyService.refresh_waiting_status")
|
||||||
@@ -383,9 +341,7 @@ def test_request_entry_waiting_participant(
|
|||||||
mock_refresh, lobby_service, participant_id, username
|
mock_refresh, lobby_service, participant_id, username
|
||||||
):
|
):
|
||||||
"""Test requesting entry for a waiting participant."""
|
"""Test requesting entry for a waiting participant."""
|
||||||
request = mock.Mock()
|
user = AnonymousUser()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
|
||||||
request.user = AnonymousUser()
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
@@ -396,10 +352,11 @@ def test_request_entry_waiting_participant(
|
|||||||
color="#123456",
|
color="#123456",
|
||||||
entered_at="2025-01-01T10:00:00+00:00",
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, user, username, participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant.status == LobbyParticipantStatus.WAITING
|
assert participant.status == LobbyParticipantStatus.WAITING
|
||||||
assert livekit_config is None
|
assert livekit_config is None
|
||||||
@@ -409,82 +366,122 @@ def test_request_entry_waiting_participant(
|
|||||||
|
|
||||||
@mock.patch("core.utils.generate_livekit_config")
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
def test_request_entry_accepted_participant(
|
def test_request_entry_accepted_participant(
|
||||||
mock_generate_config, lobby_service, participant_id, username
|
mock_generate_config, lobby_service, participant_id, username, settings
|
||||||
):
|
):
|
||||||
"""Test requesting entry for an accepted participant."""
|
"""Test requesting entry for an accepted participant."""
|
||||||
request = mock.Mock()
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
request.user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
mocked_participant = LobbyParticipant(
|
cache.set(
|
||||||
status=LobbyParticipantStatus.ACCEPTED,
|
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||||
username=username,
|
{
|
||||||
id=participant_id,
|
"id": participant_id,
|
||||||
color="#123456",
|
"username": username,
|
||||||
entered_at="2025-01-01T10:00:00+00:00",
|
"status": "accepted",
|
||||||
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
|
||||||
|
|
||||||
mock_generate_config.return_value = {"token": "test-token"}
|
mock_generate_config.return_value = {"token": "test-token"}
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, user, username, participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||||
assert livekit_config == {"token": "test-token"}
|
assert livekit_config == {"token": "test-token"}
|
||||||
mock_generate_config.assert_called_once_with(
|
mock_generate_config.assert_called_once_with(
|
||||||
room_id=str(room.id),
|
room_id=str(room.id),
|
||||||
user=request.user,
|
user=user,
|
||||||
username=username,
|
username=username,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
role=None,
|
role=None,
|
||||||
)
|
)
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
|
def test_request_entry_accepted_participant_username_is_bound(
|
||||||
|
mock_generate_config, lobby_service, participant_id, settings
|
||||||
|
):
|
||||||
|
"""An accepted identifier must join under the username the host accepted.
|
||||||
|
|
||||||
|
The participant identifier is a bearer value: a stolen or replayed
|
||||||
|
identifier must not be able to enter the room under a different
|
||||||
|
display name than the one the acceptance decision was made on.
|
||||||
|
"""
|
||||||
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
user = AnonymousUser()
|
||||||
|
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
|
lobby_service._save_participant(
|
||||||
|
room.id,
|
||||||
|
LobbyParticipant(
|
||||||
|
id=participant_id,
|
||||||
|
username="accepted-name",
|
||||||
|
status=LobbyParticipantStatus.ACCEPTED,
|
||||||
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_generate_config.return_value = {"token": "test-token"}
|
||||||
|
|
||||||
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, user, "spoofed-name", participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||||
|
assert livekit_config == {"token": "test-token"}
|
||||||
|
assert mock_generate_config.call_args.kwargs["username"] == "accepted-name"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.generate_livekit_config")
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
def test_request_entry_participant_with_role(
|
def test_request_entry_participant_with_role(
|
||||||
mock_generate_config, lobby_service, participant_id, username
|
mock_generate_config, lobby_service, participant_id, username, settings
|
||||||
):
|
):
|
||||||
"""Test requesting entry for a participant with a role on the room."""
|
"""Test requesting entry for a participant with a role on the room."""
|
||||||
request = mock.Mock()
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
request.user = UserFactory()
|
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
user = UserFactory()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
UserResourceAccessFactory(resource=room, user=request.user, role="administrator")
|
UserResourceAccessFactory(resource=room, user=user, role="administrator")
|
||||||
|
|
||||||
mocked_participant = LobbyParticipant(
|
cache.set(
|
||||||
status=LobbyParticipantStatus.ACCEPTED,
|
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||||
username=username,
|
{
|
||||||
id=participant_id,
|
"id": participant_id,
|
||||||
color="#123456",
|
"username": username,
|
||||||
entered_at="2025-01-01T10:00:00+00:00",
|
"status": "accepted",
|
||||||
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
|
||||||
|
|
||||||
mock_generate_config.return_value = {"token": "test-token"}
|
mock_generate_config.return_value = {"token": "test-token"}
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, user, username, participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||||
assert livekit_config == {"token": "test-token"}
|
assert livekit_config == {"token": "test-token"}
|
||||||
mock_generate_config.assert_called_once_with(
|
mock_generate_config.assert_called_once_with(
|
||||||
room_id=str(room.id),
|
room_id=str(room.id),
|
||||||
user=request.user,
|
user=user,
|
||||||
username=username,
|
username=username,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
role="administrator",
|
role="administrator",
|
||||||
)
|
)
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@@ -495,87 +492,70 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
|||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
lobby_service.refresh_waiting_status(room.id, participant_id)
|
lobby_service.refresh_waiting_status(room.id, participant_id)
|
||||||
mock_cache.touch.assert_called_once_with(
|
mock_cache.touch.assert_called_once_with(
|
||||||
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
"mocked_cache_key", django_settings.LOBBY_WAITING_TIMEOUT
|
||||||
)
|
)
|
||||||
lobby_service._index_touch.assert_called_once_with(room.id)
|
lobby_service._index_touch.assert_called_once_with(room.id)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=R0917
|
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@mock.patch("core.utils.generate_color")
|
@mock.patch("core.utils.generate_color")
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.lobby.LobbyService._index_add")
|
@mock.patch("core.services.lobby.LobbyService._index_add")
|
||||||
@freeze_time("2025-01-01 10:00:00")
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
def test_enter_success(
|
def test_create_participant_not_persisted_until_saved(
|
||||||
mock_index_add,
|
mock_index_add,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_generate_color,
|
mock_generate_color,
|
||||||
mock_cache,
|
mock_cache,
|
||||||
lobby_service,
|
lobby_service,
|
||||||
participant_id,
|
|
||||||
username,
|
username,
|
||||||
):
|
):
|
||||||
"""Test successful participant entry."""
|
"""Creation is in-memory; explicitly saving persists and indexes the participant."""
|
||||||
mock_generate_color.return_value = "#123456"
|
mock_generate_color.return_value = "#123456"
|
||||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
participant = lobby_service.enter(room.id, participant_id, username)
|
participant = lobby_service._create_participant(username)
|
||||||
|
|
||||||
mock_generate_color.assert_called_once_with(participant_id)
|
# The identifier is minted server-side
|
||||||
|
uuid.UUID(participant.id)
|
||||||
|
mock_generate_color.assert_called_once_with(participant.id)
|
||||||
assert participant.status == LobbyParticipantStatus.WAITING
|
assert participant.status == LobbyParticipantStatus.WAITING
|
||||||
assert participant.username == username
|
assert participant.username == username
|
||||||
assert participant.id == participant_id
|
|
||||||
assert participant.color == "#123456"
|
assert participant.color == "#123456"
|
||||||
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
|
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
|
||||||
|
|
||||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
mock_cache.set.assert_not_called()
|
||||||
|
mock_index_add.assert_not_called()
|
||||||
|
lobby_service._save_participant(room.id, participant)
|
||||||
|
|
||||||
|
lobby_service._get_cache_key.assert_called_once_with(room.id, participant.id)
|
||||||
|
|
||||||
mock_cache.set.assert_called_once_with(
|
mock_cache.set.assert_called_once_with(
|
||||||
"mocked_cache_key",
|
"mocked_cache_key",
|
||||||
participant.to_dict(),
|
participant.to_dict(),
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
timeout=django_settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
mock_notify.assert_called_once_with(
|
mock_index_add.assert_called_once_with(room.id, participant.id)
|
||||||
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
|
||||||
)
|
|
||||||
mock_index_add.assert_called_once_with(room.id, participant_id)
|
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=R0917
|
|
||||||
@mock.patch("core.services.lobby.cache")
|
|
||||||
@mock.patch("core.utils.generate_color")
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.lobby.LobbyService._index_add")
|
@mock.patch("core.services.lobby.LobbyService._index_add")
|
||||||
def test_enter_with_notification_error(
|
def test_enter_with_notification_error(
|
||||||
mock_index_add,
|
mock_index_add,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_generate_color,
|
|
||||||
mock_cache,
|
|
||||||
lobby_service,
|
lobby_service,
|
||||||
participant_id,
|
participant_id,
|
||||||
username,
|
username,
|
||||||
):
|
):
|
||||||
"""Test participant entry with notification error."""
|
"""A notification error must not break the entry request flow."""
|
||||||
mock_generate_color.return_value = "#123456"
|
|
||||||
mock_notify.side_effect = NotificationError("Error notifying")
|
mock_notify.side_effect = NotificationError("Error notifying")
|
||||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
lobby_service._notify_entry_request("room-id")
|
||||||
participant = lobby_service.enter(room.id, participant_id, username)
|
|
||||||
|
|
||||||
mock_generate_color.assert_called_once_with(participant_id)
|
mock_notify.assert_called_once_with(
|
||||||
assert participant.status == LobbyParticipantStatus.WAITING
|
room_name="room-id", notification_data={"type": "participantWaiting"}
|
||||||
assert participant.username == username
|
|
||||||
|
|
||||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
|
||||||
|
|
||||||
mock_cache.set.assert_called_once_with(
|
|
||||||
"mocked_cache_key",
|
|
||||||
participant.to_dict(),
|
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
|
||||||
)
|
)
|
||||||
mock_index_add.assert_called_once_with(room.id, participant_id)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@@ -627,7 +607,9 @@ def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
def test_list_waiting_participants(
|
||||||
|
mock_cache, lobby_service, participant_dict, settings
|
||||||
|
):
|
||||||
"""Test listing waiting participants with valid data."""
|
"""Test listing waiting participants with valid data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
@@ -649,8 +631,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
|||||||
def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||||
"""Test listing multiple waiting participants with valid data."""
|
"""Test listing multiple waiting participants with valid data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||||
|
|
||||||
participant1 = {
|
participant1 = {
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
@@ -694,7 +676,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service, settings):
|
||||||
"""Test listing waiting participants with corrupted data."""
|
"""Test listing waiting participants with corrupted data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
@@ -712,8 +694,8 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
|||||||
def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service):
|
def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service):
|
||||||
"""Test listing waiting participants with one valid and one corrupted entry."""
|
"""Test listing waiting participants with one valid and one corrupted entry."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||||
|
|
||||||
valid_participant = {
|
valid_participant = {
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
@@ -753,8 +735,8 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
|||||||
def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
||||||
"""Test listing only waiting participants (not accepted/denied)."""
|
"""Test listing only waiting participants (not accepted/denied)."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||||
|
|
||||||
participant1 = {
|
participant1 = {
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
@@ -797,7 +779,7 @@ def test_handle_participant_entry_allow(mock_update, lobby_service, participant_
|
|||||||
room.id,
|
room.id,
|
||||||
participant_id,
|
participant_id,
|
||||||
status=LobbyParticipantStatus.ACCEPTED,
|
status=LobbyParticipantStatus.ACCEPTED,
|
||||||
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
timeout=django_settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -811,7 +793,7 @@ def test_handle_participant_entry_deny(mock_update, lobby_service, participant_i
|
|||||||
room.id,
|
room.id,
|
||||||
participant_id,
|
participant_id,
|
||||||
status=LobbyParticipantStatus.DENIED,
|
status=LobbyParticipantStatus.DENIED,
|
||||||
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
timeout=django_settings.LOBBY_DENIED_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -958,12 +940,12 @@ def test_clear_room_empty(settings, lobby_service):
|
|||||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
||||||
|
|
||||||
|
|
||||||
def test_clear_participant_cache(lobby_service):
|
def test_clear_participant_cache(lobby_service, settings):
|
||||||
"""Test clearing a specific participant entry from cache."""
|
"""Test clearing a specific participant entry from cache."""
|
||||||
room_id = uuid.uuid4()
|
room_id = uuid.uuid4()
|
||||||
participant_id = "test-participant-id"
|
participant_id = "test-participant-id"
|
||||||
|
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||||
participant_data = {
|
participant_data = {
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
@@ -986,7 +968,7 @@ def test_clear_participant_cache_nonexistent(lobby_service):
|
|||||||
room_id = uuid.uuid4()
|
room_id = uuid.uuid4()
|
||||||
participant_id = "nonexistent-participant"
|
participant_id = "nonexistent-participant"
|
||||||
|
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||||
assert cache.get(cache_key) is None
|
assert cache.get(cache_key) is None
|
||||||
|
|
||||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||||
@@ -994,7 +976,7 @@ def test_clear_participant_cache_nonexistent(lobby_service):
|
|||||||
assert cache.get(cache_key) is None
|
assert cache.get(cache_key) is None
|
||||||
|
|
||||||
|
|
||||||
def test_index_add_members_remove_roundtrip(lobby_service):
|
def test_index_add_members_remove_roundtrip(lobby_service, settings):
|
||||||
"""The room index records, lists and forgets participant ids."""
|
"""The room index records, lists and forgets participant ids."""
|
||||||
room_id = uuid.uuid4()
|
room_id = uuid.uuid4()
|
||||||
|
|
||||||
@@ -1023,9 +1005,10 @@ def test_enter_registers_participant_in_room_index(
|
|||||||
"""Entering the lobby must index the participant id for the room."""
|
"""Entering the lobby must index the participant id for the room."""
|
||||||
room_id = uuid.uuid4()
|
room_id = uuid.uuid4()
|
||||||
|
|
||||||
lobby_service.enter(room_id, participant_id, username)
|
participant = lobby_service._create_participant(username)
|
||||||
|
lobby_service._save_participant(room_id, participant)
|
||||||
|
|
||||||
assert lobby_service._index_members(room_id) == frozenset([participant_id])
|
assert lobby_service._index_members(room_id) == frozenset([participant.id])
|
||||||
|
|
||||||
|
|
||||||
def test_list_waiting_participants_prunes_stale_index_ids(settings, lobby_service):
|
def test_list_waiting_participants_prunes_stale_index_ids(settings, lobby_service):
|
||||||
@@ -1074,3 +1057,70 @@ def test_refresh_waiting_status_rearms_room_index_ttl(lobby_service, participant
|
|||||||
|
|
||||||
assert redis_client.ttl(index_key) > 10
|
assert redis_client.ttl(index_key) > 10
|
||||||
assert lobby_service._index_members(room_id) == frozenset([participant_id])
|
assert lobby_service._index_members(room_id) == frozenset([participant_id])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cached_status",
|
||||||
|
[None, LobbyParticipantStatus.WAITING, LobbyParticipantStatus.ACCEPTED],
|
||||||
|
)
|
||||||
|
def test_bypass_clears_lobby_admission_before_room_becomes_restricted(
|
||||||
|
lobby_service, cached_status
|
||||||
|
):
|
||||||
|
"""Bypass writes no admission; later restricted entry requires approval."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
|
user = AnonymousUser()
|
||||||
|
participant_id = None
|
||||||
|
if cached_status is not None:
|
||||||
|
participant_id = str(uuid.uuid4())
|
||||||
|
participant = LobbyParticipant(
|
||||||
|
status=cached_status,
|
||||||
|
username="Guest",
|
||||||
|
id=participant_id,
|
||||||
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
|
)
|
||||||
|
lobby_service._save_participant(room.id, participant)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(
|
||||||
|
utils, "generate_livekit_config", return_value={"token": "test"}
|
||||||
|
) as generate_config,
|
||||||
|
mock.patch.object(lobby_service, "_notify_entry_request") as notify,
|
||||||
|
mock.patch.object(
|
||||||
|
lobby_service, "_save_participant", wraps=lobby_service._save_participant
|
||||||
|
) as save,
|
||||||
|
mock.patch.object(
|
||||||
|
lobby_service,
|
||||||
|
"clear_participant_cache",
|
||||||
|
wraps=lobby_service.clear_participant_cache,
|
||||||
|
) as clear,
|
||||||
|
):
|
||||||
|
admitted, config = lobby_service.request_entry(
|
||||||
|
room, user, "Guest", participant_id=participant_id
|
||||||
|
)
|
||||||
|
assert admitted.status == LobbyParticipantStatus.ACCEPTED
|
||||||
|
assert config == {"token": "test"}
|
||||||
|
assert lobby_service._get_participant(room.id, admitted.id) is None
|
||||||
|
assert admitted.id not in lobby_service._index_members(room.id)
|
||||||
|
notify.assert_not_called()
|
||||||
|
save.assert_not_called()
|
||||||
|
if cached_status is None:
|
||||||
|
clear.assert_not_called()
|
||||||
|
else:
|
||||||
|
clear.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
room.access_level = RoomAccessLevel.RESTRICTED
|
||||||
|
generate_config.reset_mock()
|
||||||
|
waiting, config = lobby_service.request_entry(
|
||||||
|
room, user, "Guest", participant_id=admitted.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert waiting.status == LobbyParticipantStatus.WAITING
|
||||||
|
assert config is None
|
||||||
|
generate_config.assert_not_called()
|
||||||
|
notify.assert_called_once_with(str(room.id))
|
||||||
|
save.assert_called_once_with(room.id, waiting)
|
||||||
|
cached = lobby_service._get_participant(room.id, waiting.id)
|
||||||
|
assert cached is not None
|
||||||
|
assert cached.status == LobbyParticipantStatus.WAITING
|
||||||
|
assert waiting.id in lobby_service._index_members(room.id)
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
"""
|
|
||||||
Test marketing services.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# pylint: disable=W0621,W0613
|
|
||||||
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
|
||||||
|
|
||||||
import brevo_python
|
|
||||||
import pytest
|
|
||||||
import urllib3
|
|
||||||
|
|
||||||
from core.services.marketing import (
|
|
||||||
BrevoMarketingService,
|
|
||||||
ContactCreationError,
|
|
||||||
ContactData,
|
|
||||||
get_marketing_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_init_missing_api_key(settings):
|
|
||||||
"""Test initialization with missing API key."""
|
|
||||||
settings.BREVO_API_KEY = None
|
|
||||||
with pytest.raises(ImproperlyConfigured, match="Brevo API key is required"):
|
|
||||||
BrevoMarketingService()
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_contact_missing_list_ids(settings):
|
|
||||||
"""Test contact creation with missing list IDs."""
|
|
||||||
|
|
||||||
settings.BREVO_API_KEY = "test-api-key"
|
|
||||||
settings.BREVO_API_CONTACT_LIST_IDS = None
|
|
||||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
|
||||||
|
|
||||||
valid_contact_data = ContactData(
|
|
||||||
email="test@example.com",
|
|
||||||
attributes={"first_name": "Test"},
|
|
||||||
list_ids=[1, 2],
|
|
||||||
update_enabled=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
brevo_service = BrevoMarketingService()
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
ImproperlyConfigured, match="Default Brevo List IDs must be configured"
|
|
||||||
):
|
|
||||||
brevo_service.create_contact(valid_contact_data)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("brevo_python.ContactsApi")
|
|
||||||
def test_create_contact_success(mock_contact_api):
|
|
||||||
"""Test successful contact creation."""
|
|
||||||
|
|
||||||
mock_api = mock_contact_api.return_value
|
|
||||||
|
|
||||||
settings.BREVO_API_KEY = "test-api-key"
|
|
||||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
|
||||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
|
||||||
|
|
||||||
valid_contact_data = ContactData(
|
|
||||||
email="test@example.com",
|
|
||||||
attributes={"first_name": "Test"},
|
|
||||||
list_ids=[1, 2],
|
|
||||||
update_enabled=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
brevo_service = BrevoMarketingService()
|
|
||||||
|
|
||||||
mock_api.create_contact.return_value = {"id": "test-id"}
|
|
||||||
response = brevo_service.create_contact(valid_contact_data)
|
|
||||||
|
|
||||||
assert response == {"id": "test-id"}
|
|
||||||
|
|
||||||
mock_api.create_contact.assert_called_once()
|
|
||||||
contact_arg = mock_api.create_contact.call_args[0][0]
|
|
||||||
assert contact_arg.email == "test@example.com"
|
|
||||||
assert contact_arg.attributes == {
|
|
||||||
**settings.BREVO_API_CONTACT_ATTRIBUTES,
|
|
||||||
**valid_contact_data.attributes,
|
|
||||||
}
|
|
||||||
assert set(contact_arg.list_ids) == {1, 2, 3, 4}
|
|
||||||
assert contact_arg.update_enabled is True
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("brevo_python.ContactsApi")
|
|
||||||
def test_create_contact_with_timeout(mock_contact_api):
|
|
||||||
"""Test contact creation with timeout."""
|
|
||||||
|
|
||||||
mock_api = mock_contact_api.return_value
|
|
||||||
|
|
||||||
settings.BREVO_API_KEY = "test-api-key"
|
|
||||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
|
||||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
|
||||||
|
|
||||||
valid_contact_data = ContactData(
|
|
||||||
email="test@example.com",
|
|
||||||
attributes={"first_name": "Test"},
|
|
||||||
list_ids=[1, 2],
|
|
||||||
update_enabled=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
brevo_service = BrevoMarketingService()
|
|
||||||
brevo_service.create_contact(valid_contact_data, timeout=30)
|
|
||||||
|
|
||||||
mock_api.create_contact.assert_called_once()
|
|
||||||
assert mock_api.create_contact.call_args[1]["_request_timeout"] == 30
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("brevo_python.ContactsApi")
|
|
||||||
def test_create_contact_api_error(mock_contact_api):
|
|
||||||
"""Test contact creation API error handling."""
|
|
||||||
|
|
||||||
mock_api = mock_contact_api.return_value
|
|
||||||
|
|
||||||
settings.BREVO_API_KEY = "test-api-key"
|
|
||||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
|
||||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
|
||||||
|
|
||||||
valid_contact_data = ContactData(
|
|
||||||
email="test@example.com",
|
|
||||||
attributes={"first_name": "Test"},
|
|
||||||
list_ids=[1, 2],
|
|
||||||
update_enabled=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
brevo_service = BrevoMarketingService()
|
|
||||||
|
|
||||||
mock_api.create_contact.side_effect = brevo_python.rest.ApiException()
|
|
||||||
|
|
||||||
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
|
|
||||||
brevo_service.create_contact(valid_contact_data)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("brevo_python.ContactsApi")
|
|
||||||
def test_create_contact_timeout_error(mock_contact_api):
|
|
||||||
"""Test contact creation timeout error handling."""
|
|
||||||
|
|
||||||
mock_api = mock_contact_api.return_value
|
|
||||||
settings.BREVO_API_KEY = "test-api-key"
|
|
||||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
|
||||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
|
||||||
|
|
||||||
valid_contact_data = ContactData(
|
|
||||||
email="test@example.com",
|
|
||||||
attributes={"first_name": "Test"},
|
|
||||||
list_ids=[1, 2],
|
|
||||||
update_enabled=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
brevo_service = BrevoMarketingService()
|
|
||||||
|
|
||||||
mock_api.create_contact.side_effect = urllib3.exceptions.ReadTimeoutError(
|
|
||||||
pool=mock.Mock(),
|
|
||||||
url="https://api.brevo.com/v3/endpoint",
|
|
||||||
message="HTTPSConnectionPool(host='api.brevo.com', port=443): Read timed out.",
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
|
|
||||||
brevo_service.create_contact(valid_contact_data)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def clear_marketing_cache():
|
|
||||||
"""Clear marketing service cache between tests."""
|
|
||||||
get_marketing_service.cache_clear()
|
|
||||||
yield
|
|
||||||
get_marketing_service.cache_clear()
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_marketing_service_caching(clear_marketing_cache):
|
|
||||||
"""Test marketing service caching behavior."""
|
|
||||||
settings.BREVO_API_KEY = "test-api-key"
|
|
||||||
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
|
|
||||||
|
|
||||||
service1 = get_marketing_service()
|
|
||||||
service2 = get_marketing_service()
|
|
||||||
|
|
||||||
assert service1 is service2
|
|
||||||
assert isinstance(service1, BrevoMarketingService)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_marketing_service_invalid_class(clear_marketing_cache):
|
|
||||||
"""Test handling of invalid service class."""
|
|
||||||
settings.MARKETING_SERVICE_CLASS = "invalid.service.path"
|
|
||||||
|
|
||||||
with pytest.raises(ImportError):
|
|
||||||
get_marketing_service()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.marketing.import_string")
|
|
||||||
def test_service_instantiation_called_once(mock_import_string, clear_marketing_cache):
|
|
||||||
"""Test service class is instantiated only once."""
|
|
||||||
|
|
||||||
settings.BREVO_API_KEY = "test-api-key"
|
|
||||||
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
|
|
||||||
get_marketing_service.cache_clear()
|
|
||||||
|
|
||||||
mock_service_cls = mock.Mock()
|
|
||||||
mock_service_instance = mock.Mock()
|
|
||||||
mock_service_cls.return_value = mock_service_instance
|
|
||||||
mock_import_string.return_value = mock_service_cls
|
|
||||||
|
|
||||||
service1 = get_marketing_service()
|
|
||||||
service2 = get_marketing_service()
|
|
||||||
|
|
||||||
mock_import_string.assert_called_once_with(settings.MARKETING_SERVICE_CLASS)
|
|
||||||
mock_service_cls.assert_called_once()
|
|
||||||
assert service1 is service2
|
|
||||||
assert service1 is mock_service_instance
|
|
||||||
@@ -2,16 +2,19 @@
|
|||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from livekit.api import TwirpError
|
from livekit.api import TwirpError
|
||||||
|
|
||||||
from core.factories import RoomFactory
|
from core.factories import RoomFactory
|
||||||
from core.models import RoomAccessLevel
|
from core.models import Room, RoomAccessLevel
|
||||||
from core.services.room_management import (
|
from core.services.room_management import (
|
||||||
RoomManagement,
|
RoomManagement,
|
||||||
RoomManagementException,
|
RoomManagementException,
|
||||||
RoomNotFoundException,
|
RoomNotFoundException,
|
||||||
)
|
)
|
||||||
|
from core.services.sip_management import SIPException, SIPManagement
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.room_management.utils.create_livekit_client")
|
@mock.patch("core.services.room_management.utils.create_livekit_client")
|
||||||
@@ -62,6 +65,221 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client):
|
|||||||
mock_api.aclose.assert_awaited_once()
|
mock_api.aclose.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||||
|
@mock.patch.object(RoomManagement, "delete_room")
|
||||||
|
def test_soft_delete_commits_before_closing_livekit_room(mock_delete_room, _):
|
||||||
|
"""The deletion is committed before the LiveKit room is closed, so the
|
||||||
|
room_started webhook of a participant reconnecting right away sees it."""
|
||||||
|
room = RoomFactory()
|
||||||
|
|
||||||
|
def assert_deletion_committed(room_name):
|
||||||
|
assert connection.in_atomic_block is False
|
||||||
|
assert Room.all_objects.get(id=room_name).is_deleted
|
||||||
|
|
||||||
|
mock_delete_room.side_effect = assert_deletion_committed
|
||||||
|
|
||||||
|
RoomManagement.soft_delete(room)
|
||||||
|
|
||||||
|
mock_delete_room.assert_called_once_with(str(room.id))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error",
|
||||||
|
[
|
||||||
|
RoomManagementException("Could not delete room"),
|
||||||
|
ConnectionError("LiveKit is unreachable"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@mock.patch.object(SIPManagement, "delete_dispatch_rule", return_value=False)
|
||||||
|
@mock.patch.object(RoomManagement, "delete_room")
|
||||||
|
def test_soft_delete_failure_rolls_back_and_can_be_retried(mock_delete_room, _, error):
|
||||||
|
"""A failed soft delete leaves the room untouched, in database and in memory,
|
||||||
|
so it can be retried."""
|
||||||
|
room = RoomFactory()
|
||||||
|
mock_delete_room.side_effect = error
|
||||||
|
|
||||||
|
with pytest.raises(type(error)):
|
||||||
|
RoomManagement.soft_delete(room)
|
||||||
|
|
||||||
|
assert room.deleted_at is None
|
||||||
|
assert Room.objects.filter(id=room.id).exists()
|
||||||
|
|
||||||
|
mock_delete_room.side_effect = None
|
||||||
|
RoomManagement.soft_delete(room)
|
||||||
|
|
||||||
|
assert mock_delete_room.call_count == 2
|
||||||
|
assert room.deleted_at is not None
|
||||||
|
assert Room.all_objects.get(id=room.id).deleted_at is not None
|
||||||
|
assert Room.objects.filter(id=room.id).exists() is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize("sip_setting", ["ROOM_TELEPHONY_ENABLED", "ROOMKIT_ENABLED"])
|
||||||
|
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||||
|
@mock.patch.object(
|
||||||
|
RoomManagement,
|
||||||
|
"delete_room",
|
||||||
|
side_effect=RoomNotFoundException("Room does not exist"),
|
||||||
|
)
|
||||||
|
def test_soft_delete_room_not_live_deletes_dispatch_rule(
|
||||||
|
mock_delete_room, mock_delete_dispatch_rule, sip_setting, settings
|
||||||
|
):
|
||||||
|
"""A roomkit join creates the dispatch rule before any LiveKit room exists."""
|
||||||
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
|
settings.ROOMKIT_ENABLED = False
|
||||||
|
setattr(settings, sip_setting, True)
|
||||||
|
room = RoomFactory()
|
||||||
|
|
||||||
|
RoomManagement.soft_delete(room)
|
||||||
|
|
||||||
|
mock_delete_room.assert_called_once_with(str(room.id))
|
||||||
|
mock_delete_dispatch_rule.assert_called_once_with(room.id)
|
||||||
|
assert Room.all_objects.get(id=room.id).is_deleted
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_soft_delete_live_room_deletes_dispatch_rule_before_closing_it(settings):
|
||||||
|
"""The dispatch rule is deleted before the LiveKit room is closed."""
|
||||||
|
settings.ROOM_TELEPHONY_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
calls = mock.Mock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(RoomManagement, "delete_room", calls.delete_room),
|
||||||
|
mock.patch.object(
|
||||||
|
SIPManagement, "delete_dispatch_rule", calls.delete_dispatch_rule
|
||||||
|
),
|
||||||
|
):
|
||||||
|
RoomManagement.soft_delete(room)
|
||||||
|
|
||||||
|
assert calls.mock_calls == [
|
||||||
|
mock.call.delete_dispatch_rule(room.id),
|
||||||
|
mock.call.delete_room(str(room.id)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||||
|
@mock.patch.object(RoomManagement, "delete_room")
|
||||||
|
def test_soft_delete_sip_disabled_skips_dispatch_rule(
|
||||||
|
mock_delete_room, mock_delete_dispatch_rule, settings
|
||||||
|
):
|
||||||
|
"""Without telephony nor roomkit, no dispatch rule is looked up."""
|
||||||
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
|
settings.ROOMKIT_ENABLED = False
|
||||||
|
room = RoomFactory()
|
||||||
|
|
||||||
|
RoomManagement.soft_delete(room)
|
||||||
|
|
||||||
|
mock_delete_room.assert_called_once_with(str(room.id))
|
||||||
|
mock_delete_dispatch_rule.assert_not_called()
|
||||||
|
assert Room.all_objects.get(id=room.id).is_deleted
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||||
|
@mock.patch.object(
|
||||||
|
SIPManagement,
|
||||||
|
"delete_dispatch_rule",
|
||||||
|
side_effect=SIPException("Could not delete dispatch rules"),
|
||||||
|
)
|
||||||
|
@mock.patch.object(RoomManagement, "delete_room")
|
||||||
|
def test_soft_delete_dispatch_rule_failure_rolls_back(
|
||||||
|
mock_delete_room, mock_delete_dispatch_rule, mock_ensure_dispatch_rule, settings
|
||||||
|
):
|
||||||
|
"""A dispatch rule that can't be deleted fails the soft delete like a LiveKit
|
||||||
|
room that can't be closed: it is rolled back so it can be retried, and the
|
||||||
|
LiveKit room is left open."""
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
|
||||||
|
with pytest.raises(RoomManagementException):
|
||||||
|
RoomManagement.soft_delete(room)
|
||||||
|
|
||||||
|
mock_delete_room.assert_not_called()
|
||||||
|
mock_delete_dispatch_rule.assert_called_once_with(room.id)
|
||||||
|
mock_ensure_dispatch_rule.assert_not_called()
|
||||||
|
assert room.deleted_at is None
|
||||||
|
assert Room.objects.filter(id=room.id).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||||
|
@mock.patch.object(SIPManagement, "delete_dispatch_rule", return_value=True)
|
||||||
|
@mock.patch.object(
|
||||||
|
RoomManagement,
|
||||||
|
"delete_room",
|
||||||
|
side_effect=RoomManagementException("Could not delete room"),
|
||||||
|
)
|
||||||
|
def test_soft_delete_room_close_failure_restores_dispatch_rule(
|
||||||
|
_, __, mock_ensure_dispatch_rule, settings
|
||||||
|
):
|
||||||
|
"""A LiveKit room that can't be closed rolls the deletion back, including
|
||||||
|
its dispatch rule, so phone callers can still join the room with its PIN."""
|
||||||
|
settings.ROOM_TELEPHONY_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
|
||||||
|
with pytest.raises(RoomManagementException, match="Could not delete room"):
|
||||||
|
RoomManagement.soft_delete(room)
|
||||||
|
|
||||||
|
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
||||||
|
assert room.deleted_at is None
|
||||||
|
assert Room.objects.filter(id=room.id).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||||
|
@mock.patch.object(SIPManagement, "delete_dispatch_rule", return_value=False)
|
||||||
|
@mock.patch.object(
|
||||||
|
RoomManagement,
|
||||||
|
"delete_room",
|
||||||
|
side_effect=RoomManagementException("Could not delete room"),
|
||||||
|
)
|
||||||
|
def test_soft_delete_room_close_failure_without_rule_skips_restore(
|
||||||
|
_, __, mock_ensure_dispatch_rule, settings
|
||||||
|
):
|
||||||
|
"""A room that had no dispatch rule does not get one on rollback, as nothing
|
||||||
|
would ever delete it."""
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
|
||||||
|
with pytest.raises(RoomManagementException):
|
||||||
|
RoomManagement.soft_delete(room)
|
||||||
|
|
||||||
|
mock_ensure_dispatch_rule.assert_not_called()
|
||||||
|
assert room.deleted_at is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@mock.patch.object(
|
||||||
|
SIPManagement,
|
||||||
|
"ensure_dispatch_rule",
|
||||||
|
side_effect=SIPException("Could not create dispatch rule"),
|
||||||
|
)
|
||||||
|
@mock.patch.object(SIPManagement, "delete_dispatch_rule", return_value=True)
|
||||||
|
@mock.patch.object(
|
||||||
|
RoomManagement,
|
||||||
|
"delete_room",
|
||||||
|
side_effect=RoomManagementException("Could not delete room"),
|
||||||
|
)
|
||||||
|
def test_soft_delete_restore_failure_raises_original_error(
|
||||||
|
_, __, ___, settings, caplog
|
||||||
|
):
|
||||||
|
"""A dispatch rule that can't be restored is logged without hiding the error
|
||||||
|
that caused the rollback."""
|
||||||
|
settings.ROOM_TELEPHONY_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
|
||||||
|
with pytest.raises(RoomManagementException, match="Could not delete room"):
|
||||||
|
RoomManagement.soft_delete(room)
|
||||||
|
|
||||||
|
assert f"Could not restore dispatch rule for room {room.id}" in caplog.text
|
||||||
|
assert room.deleted_at is None
|
||||||
|
assert Room.objects.filter(id=room.id).exists()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(RoomManagement, "update_metadata")
|
@mock.patch.object(RoomManagement, "update_metadata")
|
||||||
def test_sync_room_metadata_pushes_configuration_and_access_level(mock_update_metadata):
|
def test_sync_room_metadata_pushes_configuration_and_access_level(mock_update_metadata):
|
||||||
"""The room's configuration and access level are forwarded to LiveKit."""
|
"""The room's configuration and access level are forwarded to LiveKit."""
|
||||||
|
|||||||
@@ -257,6 +257,31 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
|
|||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
||||||
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
|
def test_delete_dispatch_rule_already_deleted(mock_client_factory, mock_list_rules):
|
||||||
|
"""A rule deleted between listing and deletion (e.g. by both a room deletion and
|
||||||
|
its room_finished webhook) should not fail, nor stop the other deletions."""
|
||||||
|
sip_management = SIPManagement()
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
|
mock_list_rules.return_value = ["rule-1", "rule-2"]
|
||||||
|
mock_api = create_mock_livekit_client()
|
||||||
|
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
TwirpError(msg="sip dispatch rule not found", code="not_found", status=404),
|
||||||
|
None,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
|
result = sip_management.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
|
||||||
|
mock_api.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the TransitCodeService.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.factories import UserFactory
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_code_returns_unique_opaque_codes():
|
||||||
|
"""Each created code should be a distinct high-entropy string."""
|
||||||
|
user = UserFactory()
|
||||||
|
service = TransitCodeService()
|
||||||
|
|
||||||
|
codes = {service.create_code(user, "my-app") for _ in range(5)}
|
||||||
|
|
||||||
|
assert len(codes) == 5
|
||||||
|
for code in codes:
|
||||||
|
assert len(code) >= 43
|
||||||
|
|
||||||
|
|
||||||
|
def test_consume_code_returns_stored_data_once():
|
||||||
|
"""Consuming a code should return its data exactly once."""
|
||||||
|
user = UserFactory()
|
||||||
|
service = TransitCodeService()
|
||||||
|
|
||||||
|
code = service.create_code(user, client_id="my-app")
|
||||||
|
|
||||||
|
assert service.consume_code(code) == {
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"client_id": "my-app",
|
||||||
|
}
|
||||||
|
# Single use: a second consumption fails
|
||||||
|
assert service.consume_code(code) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_consume_code_unknown_or_empty():
|
||||||
|
"""Unknown or empty codes should not be consumable."""
|
||||||
|
service = TransitCodeService()
|
||||||
|
|
||||||
|
assert service.consume_code("unknown-code") is None
|
||||||
|
assert service.consume_code("") is None
|
||||||
|
assert service.consume_code(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.services.transit_code.cache.delete", return_value=False)
|
||||||
|
def test_consume_code_returns_none_when_delete_loses_the_race(mock_delete):
|
||||||
|
"""If the code was already deleted by a concurrent request, consumption fails."""
|
||||||
|
user = UserFactory()
|
||||||
|
service = TransitCodeService()
|
||||||
|
code = service.create_code(user, client_id="my-app")
|
||||||
|
assert service.consume_code(code) is None
|
||||||
|
mock_delete.assert_called_once()
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Tests for the generic send_email task."""
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument,redefined-outer-name
|
||||||
|
|
||||||
|
import smtplib
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.mail import get_connection
|
||||||
|
from django.utils.translation import gettext_noop
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.tasks.mail import send_email
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def email_settings(settings):
|
||||||
|
"""Branding settings shared by every email."""
|
||||||
|
settings.EMAIL_BRAND_NAME = "ACME"
|
||||||
|
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
||||||
|
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||||
|
settings.EMAIL_DOMAIN = "acme.com"
|
||||||
|
settings.EMAIL_FROM = "notifications@acme.com"
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.tasks.mail.send_mail")
|
||||||
|
@mock.patch("core.tasks.mail.render_to_string", side_effect=["<p>html</p>", "text"])
|
||||||
|
def test_send_email_renders_template_and_sends(
|
||||||
|
mock_render, mock_send_mail, email_settings
|
||||||
|
):
|
||||||
|
"""Both template variants are rendered with base + caller context, then sent."""
|
||||||
|
send_email(
|
||||||
|
template="some_template",
|
||||||
|
subject="Any subject",
|
||||||
|
recipients=["a@test.com", "b@test.com"],
|
||||||
|
context={"foo": "bar", "brandname": "Overridden"},
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_context = {
|
||||||
|
"brandname": "Overridden",
|
||||||
|
"support_email": "support@acme.com",
|
||||||
|
"logo_img": "https://acme.com/logo",
|
||||||
|
"domain": "acme.com",
|
||||||
|
"foo": "bar",
|
||||||
|
}
|
||||||
|
assert mock_render.call_args_list == [
|
||||||
|
mock.call("mail/html/some_template.html", expected_context),
|
||||||
|
mock.call("mail/text/some_template.txt", expected_context),
|
||||||
|
]
|
||||||
|
mock_send_mail.assert_called_once_with(
|
||||||
|
"Any subject",
|
||||||
|
"text",
|
||||||
|
"notifications@acme.com",
|
||||||
|
["a@test.com", "b@test.com"],
|
||||||
|
html_message="<p>html</p>",
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.tasks.mail.send_mail")
|
||||||
|
def test_send_email_translates_subject_in_requested_language(
|
||||||
|
mock_send_mail, email_settings
|
||||||
|
):
|
||||||
|
"""The subject msgid is translated in the worker, in the recipient language."""
|
||||||
|
send_email(
|
||||||
|
template="screen_recording",
|
||||||
|
subject=gettext_noop("Your recording is ready"),
|
||||||
|
recipients=["franc@test.com"],
|
||||||
|
context={"room_name": "Room", "link": "https://acme.com/r/1"},
|
||||||
|
language="fr-fr",
|
||||||
|
)
|
||||||
|
|
||||||
|
subject, body, *_ = mock_send_mail.call_args[0]
|
||||||
|
assert subject == "Votre enregistrement est prêt"
|
||||||
|
assert "Votre enregistrement est prêt !" in body
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.tasks.mail.send_mail", side_effect=smtplib.SMTPException("SMTP Error")
|
||||||
|
)
|
||||||
|
@mock.patch("core.tasks.mail.render_to_string", return_value="content")
|
||||||
|
def test_send_email_raises_on_smtp_error(mock_render, mock_send_mail, email_settings):
|
||||||
|
"""SMTP errors propagate so Celery can retry the task."""
|
||||||
|
with pytest.raises(smtplib.SMTPException):
|
||||||
|
send_email(
|
||||||
|
template="some_template",
|
||||||
|
subject="Any subject",
|
||||||
|
recipients=["a@test.com"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_smtp_backend_uses_email_timeout(settings):
|
||||||
|
"""A stalled SMTP server cannot hold the worker forever."""
|
||||||
|
settings.EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||||
|
settings.EMAIL_TIMEOUT = 12
|
||||||
|
|
||||||
|
assert get_connection().timeout == 12
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
Test resource accesses API endpoints in the Meet core app.
|
Test resource accesses API endpoints in the Meet core app.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -956,3 +958,99 @@ def test_api_room_user_access_delete_owners_last_owner():
|
|||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert ResourceAccess.objects.count() == 1
|
assert ResourceAccess.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
# Soft-deleted rooms
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_room_user_accesses_create_soft_deleted_room():
|
||||||
|
"""
|
||||||
|
Owners of a soft-deleted room should not be allowed to add accesses to it.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "owner")])
|
||||||
|
room.soft_delete()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/resource-accesses/",
|
||||||
|
{
|
||||||
|
"user": str(UserFactory().id),
|
||||||
|
"resource": str(room.id),
|
||||||
|
"role": "member",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 410
|
||||||
|
assert response.json() == {"detail": "This room has been deleted."}
|
||||||
|
assert ResourceAccess.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_room_user_accesses_create_soft_deleted_room_not_administrator():
|
||||||
|
"""
|
||||||
|
Users without privileges on a soft-deleted room should get a 403,
|
||||||
|
not revealing that the room has been deleted.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "member")])
|
||||||
|
room.soft_delete()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/resource-accesses/",
|
||||||
|
{
|
||||||
|
"user": str(UserFactory().id),
|
||||||
|
"resource": str(room.id),
|
||||||
|
"role": "member",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert ResourceAccess.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_room_user_accesses_update_soft_deleted_room():
|
||||||
|
"""
|
||||||
|
Owners of a soft-deleted room should not be allowed to update its accesses.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "owner")])
|
||||||
|
access = UserResourceAccessFactory(resource=room, role="member")
|
||||||
|
room.soft_delete()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/v1.0/resource-accesses/{access.id!s}/",
|
||||||
|
{"role": "administrator"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 410
|
||||||
|
access.refresh_from_db()
|
||||||
|
assert access.role == "member"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_room_user_access_delete_soft_deleted_room():
|
||||||
|
"""
|
||||||
|
Owners of a soft-deleted room should not be allowed to remove its accesses.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "owner")])
|
||||||
|
access = UserResourceAccessFactory(resource=room, role="member")
|
||||||
|
room.soft_delete()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/resource-accesses/{access.id!s}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 410
|
||||||
|
assert ResourceAccess.objects.filter(id=access.id).exists() is True
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""
|
||||||
|
Tests for user access JWT authentication on the core API.
|
||||||
|
|
||||||
|
The token authenticates the user on the whole API, exactly like a session
|
||||||
|
cookie would (similar to lib-jitsi-meet's token authentication): the
|
||||||
|
existing role-based permissions apply unchanged. Room endpoint coverage
|
||||||
|
with a user access token lives in the room test files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
||||||
|
from core.models import ApplicationScope, RoleChoices
|
||||||
|
from core.tests.utils import generate_user_access_token
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_users_me():
|
||||||
|
"""A user access token should authenticate the user on /users/me/."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["email"] == user.email
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_expired():
|
||||||
|
"""An expired user access token should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
token = generate_user_access_token(
|
||||||
|
user,
|
||||||
|
iat=now - timedelta(hours=3),
|
||||||
|
exp=now - timedelta(hours=1),
|
||||||
|
)
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "token expired" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_wrong_token_type():
|
||||||
|
"""A verified token with the wrong 'token_type' claim should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
token = generate_user_access_token(user, token_type="addons")
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "invalid token type" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_invalid_signature():
|
||||||
|
"""A token signed with the wrong key should defer and end unauthenticated."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
token = jwt.encode(
|
||||||
|
{
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=600),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_token",
|
||||||
|
"client_id": "test-app",
|
||||||
|
},
|
||||||
|
"wrong-secret-key-padded-for-minimum-len!",
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
# UserAccessJWTAuthentication defers, session auth finds no session
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_missing_client_id_claim():
|
||||||
|
"""A token without the issuance-audit claim should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
token = generate_user_access_token(user, client_id=None)
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "invalid token claims" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_inactive_user():
|
||||||
|
"""A user access token for an inactive user should be rejected."""
|
||||||
|
user = UserFactory(is_active=False)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_feature_disabled(settings):
|
||||||
|
"""When the feature is disabled, user access tokens should be ignored."""
|
||||||
|
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_does_not_break_session_authentication():
|
||||||
|
"""A session-authenticated user should keep full access to the API."""
|
||||||
|
user = UserFactory()
|
||||||
|
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.get("/api/v1.0/rooms/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_application_jwt_not_accepted_on_core_api():
|
||||||
|
"""An application-delegation JWT must not authenticate on the core API."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
token = jwt.encode(
|
||||||
|
{
|
||||||
|
"iss": django_settings.APPLICATION_JWT_ISSUER,
|
||||||
|
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=600),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"client_id": "some-client",
|
||||||
|
"delegated": True,
|
||||||
|
"scope": "rooms:retrieve",
|
||||||
|
},
|
||||||
|
django_settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
|
algorithm=django_settings.APPLICATION_JWT_ALG,
|
||||||
|
)
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
# The user token backend must defer (wrong signature) and the request
|
||||||
|
# must end up unauthenticated.
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_application_scope_revoked():
|
||||||
|
"""Revoking the application's grant invalidates its outstanding tokens."""
|
||||||
|
user = UserFactory()
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
token = generate_user_access_token(user, application=application)
|
||||||
|
|
||||||
|
application.scopes = []
|
||||||
|
application.save()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "application access revoked" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_application_deactivated():
|
||||||
|
"""Deactivating the application invalidates its outstanding tokens."""
|
||||||
|
user = UserFactory()
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
token = generate_user_access_token(user, application=application)
|
||||||
|
|
||||||
|
application.is_active = False
|
||||||
|
application.save()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "application access revoked" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_unknown_application():
|
||||||
|
"""A token whose client_id matches no application is refused."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
token = generate_user_access_token(user, client_id="not-an-application")
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "application access revoked" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_does_not_override_existing_session():
|
||||||
|
"""A Bearer token must not override the identity of a live session."""
|
||||||
|
session_user = UserFactory()
|
||||||
|
token_user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(session_user)
|
||||||
|
client.credentials(
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(token_user)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["email"] == session_user.email
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""
|
||||||
|
Test users API endpoints in the Meet core app: exchange transit code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0621
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.factories import ApplicationFactory, UserFactory
|
||||||
|
from core.models import ApplicationScope
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def decode_user_access_token(token, settings):
|
||||||
|
"""Decode a user access token with the token secret."""
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithms=[settings.USER_ACCESS_TOKEN_ALG],
|
||||||
|
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_unknown_code(settings):
|
||||||
|
"""Generate a well-formed code that was never stored."""
|
||||||
|
return secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
"""Return an anonymous API client with a random source IP.
|
||||||
|
|
||||||
|
A fresh IP per test isolates the anonymous throttle history, both
|
||||||
|
between the tests of this module and between test runs.
|
||||||
|
"""
|
||||||
|
# `secrets` rather than `random`: the global random module is seeded
|
||||||
|
# deterministically by the factories, its sequence repeats across runs.
|
||||||
|
remote_addr = (
|
||||||
|
f"10.{secrets.randbelow(256)}.{secrets.randbelow(256)}"
|
||||||
|
f".{secrets.randbelow(254) + 1}"
|
||||||
|
)
|
||||||
|
return APIClient(REMOTE_ADDR=remote_addr)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_missing_code(client):
|
||||||
|
"""The exchange endpoint should validate its input."""
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "code" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_get_method(client):
|
||||||
|
"""The exchange endpoint should not accept GET."""
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/exchange-access-token/")
|
||||||
|
assert response.status_code == 405
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_malformed_code(client):
|
||||||
|
"""A code whose length cannot match a generated one should be a 400."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/users/exchange-access-token/",
|
||||||
|
{"code": "not-a-valid-code"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "invalid transit code format" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_unknown_code(client, settings):
|
||||||
|
"""A well-formed but unknown code should be denied."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/users/exchange-access-token/",
|
||||||
|
{"code": generate_unknown_code(settings)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "invalid, expired or already used" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_success(client, settings):
|
||||||
|
"""A valid transit code should be exchangeable for an access token."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["token_type"] == settings.USER_ACCESS_TOKEN_TYPE
|
||||||
|
assert response.data["expires_in"] == settings.USER_ACCESS_TOKEN_TTL
|
||||||
|
assert response.data["scope"] == "user:access"
|
||||||
|
|
||||||
|
payload = decode_user_access_token(response.data["access_token"], settings)
|
||||||
|
assert payload["user_id"] == str(user.id)
|
||||||
|
assert payload["client_id"] == application.client_id
|
||||||
|
assert payload["exp"] - payload["iat"] == settings.USER_ACCESS_TOKEN_TTL
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_single_use(client):
|
||||||
|
"""A transit code should be exchangeable exactly once."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Replaying the same code must be denied
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "invalid, expired or already used" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_inactive_user(client):
|
||||||
|
"""A code minted for a now-inactive user should be denied."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||||
|
|
||||||
|
user.is_active = False
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "no longer access" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_feature_disabled(client, settings):
|
||||||
|
"""The exchange endpoint should return 404 when the feature is disabled."""
|
||||||
|
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_throttled(client, settings):
|
||||||
|
"""Anonymous exchange attempts should be rate limited."""
|
||||||
|
throttle_rates = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]
|
||||||
|
initial_rate = throttle_rates["exchange_access_token"]
|
||||||
|
# The rates dict is mutated in place: restore it explicitly, the
|
||||||
|
# `settings` fixture only rolls back attribute assignments.
|
||||||
|
throttle_rates["exchange_access_token"] = "2/minute"
|
||||||
|
|
||||||
|
try:
|
||||||
|
for _ in range(2):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/users/exchange-access-token/",
|
||||||
|
{"code": generate_unknown_code(settings)},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/users/exchange-access-token/",
|
||||||
|
{"code": generate_unknown_code(settings)},
|
||||||
|
)
|
||||||
|
assert response.status_code == 429
|
||||||
|
finally:
|
||||||
|
throttle_rates["exchange_access_token"] = initial_rate
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_refused_when_already_authenticated(client):
|
||||||
|
"""A session-authenticated browser must not exchange a transit code."""
|
||||||
|
user = UserFactory()
|
||||||
|
session_user = UserFactory()
|
||||||
|
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||||
|
|
||||||
|
client.force_login(session_user)
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "already authenticated" in str(response.data).lower()
|
||||||
|
|
||||||
|
# The code was not consumed: it stays valid for its intended,
|
||||||
|
# cookieless embedded context.
|
||||||
|
client.logout()
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_application_scope_revoked(client):
|
||||||
|
"""A code is refused once the application's grant is revoked."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||||
|
|
||||||
|
application.scopes = []
|
||||||
|
application.save()
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "no longer create user sessions" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_application_deactivated(client):
|
||||||
|
"""A code is refused once the application is disabled."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||||
|
|
||||||
|
application.is_active = False
|
||||||
|
application.save()
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "no longer create user sessions" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_unknown_application(client):
|
||||||
|
"""A code whose client_id matches no application is refused."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
code = TransitCodeService().create_code(user, client_id="not-an-application")
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "no longer create user sessions" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_end_to_end(client):
|
||||||
|
"""A token obtained from the exchange must authenticate on the core API.
|
||||||
|
|
||||||
|
Regression test: token issuance and token validation must stay in
|
||||||
|
sync on the claims they set and require (e.g. 'token_type').
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
api_client = APIClient()
|
||||||
|
api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {response.data['access_token']}")
|
||||||
|
me = api_client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert me.status_code == 200
|
||||||
|
assert me.data["email"] == user.email
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
"""Application hashing and migration of existing credentials."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.contrib.auth.hashers import check_password, identify_hasher, make_password
|
||||||
|
from django.db import connection
|
||||||
|
from django.test.utils import CaptureQueriesContext
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import hashers
|
||||||
|
from core.factories import ApplicationFactory, UserFactory
|
||||||
|
from core.models import Application
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("secret", ["short", "a" * 128, b"byte-secret"])
|
||||||
|
def test_application_hash(secret):
|
||||||
|
"""Application hashes verify correctly but are not accepted for user passwords."""
|
||||||
|
encoded = hashers.hash_client_secret(secret)
|
||||||
|
raw = secret.encode() if isinstance(secret, str) else secret
|
||||||
|
algorithm, version, digest = encoded.split("$")
|
||||||
|
assert algorithm == "sha256"
|
||||||
|
assert version == "v0"
|
||||||
|
assert digest == hashlib.sha256(raw).hexdigest()
|
||||||
|
assert hashers.hash_client_secret(secret) == encoded
|
||||||
|
assert hashers.verify_client_secret(secret, encoded)
|
||||||
|
assert not hashers.verify_client_secret("wrong", encoded)
|
||||||
|
assert not hashers.verify_client_secret(None, encoded)
|
||||||
|
assert not hashers.verify_client_secret(secret, "sha256$invalid")
|
||||||
|
assert not hashers.verify_client_secret(secret, "sha256$v1$" + digest)
|
||||||
|
assert not check_password(secret, encoded)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
identify_hasher(encoded)
|
||||||
|
assert not make_password(raw.decode()).startswith("sha256$")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("algorithm", ["pbkdf2_sha256", "md5"])
|
||||||
|
def test_token_migrates_legacy_secret_once(algorithm):
|
||||||
|
"""The same client secret works before and after migration, with no later writes."""
|
||||||
|
secret = get_random_string(128)
|
||||||
|
user = UserFactory()
|
||||||
|
legacy = make_password(secret, hasher=algorithm)
|
||||||
|
app = ApplicationFactory(client_secret=legacy)
|
||||||
|
app.refresh_from_db()
|
||||||
|
|
||||||
|
assert app.client_secret == legacy
|
||||||
|
assert app.client_secret_sha256 is None
|
||||||
|
payload = {
|
||||||
|
"client_id": app.client_id,
|
||||||
|
"client_secret": secret,
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": user.email,
|
||||||
|
}
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(
|
||||||
|
"/external-api/v1.0/application/token/", payload, format="json"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
app.refresh_from_db()
|
||||||
|
migrated = app.client_secret_sha256
|
||||||
|
assert check_password(secret, app.client_secret)
|
||||||
|
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(migrated)["digest"]
|
||||||
|
assert hashers.verify_client_secret(secret, migrated)
|
||||||
|
with CaptureQueriesContext(connection) as queries:
|
||||||
|
response = client.post(
|
||||||
|
"/external-api/v1.0/application/token/", payload, format="json"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert not any(q["sql"].lstrip().startswith("UPDATE") for q in queries)
|
||||||
|
app.refresh_from_db()
|
||||||
|
assert app.client_secret_sha256 == migrated
|
||||||
|
assert app.client_secret == legacy
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_secret_does_not_migrate():
|
||||||
|
"""Failed authentication leaves a production PBKDF2 hash untouched."""
|
||||||
|
user = UserFactory()
|
||||||
|
legacy = make_password(get_random_string(128), hasher="pbkdf2_sha256")
|
||||||
|
app = ApplicationFactory(client_secret=legacy)
|
||||||
|
response = APIClient().post(
|
||||||
|
"/external-api/v1.0/application/token/",
|
||||||
|
{
|
||||||
|
"client_id": app.client_id,
|
||||||
|
"client_secret": "wrong",
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": user.email,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
app.refresh_from_db()
|
||||||
|
assert app.client_secret == legacy
|
||||||
|
assert app.client_secret_sha256 is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_preserves_concurrent_rotation():
|
||||||
|
"""Migration must not restore a secret rotated after verification."""
|
||||||
|
secret = get_random_string(128)
|
||||||
|
app = ApplicationFactory(
|
||||||
|
client_secret=make_password(secret, hasher="pbkdf2_sha256")
|
||||||
|
)
|
||||||
|
replacement = make_password(get_random_string(128), hasher="pbkdf2_sha256")
|
||||||
|
|
||||||
|
def verify_then_rotate(raw, encoded):
|
||||||
|
verified = check_password(raw, encoded)
|
||||||
|
Application.objects.filter(pk=app.pk).update(client_secret=replacement)
|
||||||
|
return verified
|
||||||
|
|
||||||
|
with mock.patch.object(hashers, "check_password", side_effect=verify_then_rotate):
|
||||||
|
assert app.check_client_secret(secret) is False
|
||||||
|
|
||||||
|
app.refresh_from_db()
|
||||||
|
assert app.client_secret == replacement
|
||||||
|
assert app.client_secret_sha256 is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_preserves_concurrent_migration():
|
||||||
|
"""Authentication succeeds when another request migrates the same secret."""
|
||||||
|
secret = get_random_string(128)
|
||||||
|
app = ApplicationFactory(
|
||||||
|
client_secret=make_password(secret, hasher="pbkdf2_sha256")
|
||||||
|
)
|
||||||
|
migrated = hashers.hash_client_secret(secret)
|
||||||
|
|
||||||
|
def verify_then_migrate(raw, encoded):
|
||||||
|
verified = check_password(raw, encoded)
|
||||||
|
Application.objects.filter(pk=app.pk).update(client_secret_sha256=migrated)
|
||||||
|
return verified
|
||||||
|
|
||||||
|
with mock.patch.object(hashers, "check_password", side_effect=verify_then_migrate):
|
||||||
|
assert app.check_client_secret(secret) is True
|
||||||
|
|
||||||
|
app.refresh_from_db()
|
||||||
|
assert app.client_secret_sha256 == migrated
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_preserves_concurrent_deletion():
|
||||||
|
"""Authentication fails when the application is deleted after verification."""
|
||||||
|
secret = get_random_string(128)
|
||||||
|
app = ApplicationFactory(
|
||||||
|
client_secret=make_password(secret, hasher="pbkdf2_sha256")
|
||||||
|
)
|
||||||
|
|
||||||
|
def verify_then_delete(raw, encoded):
|
||||||
|
verified = check_password(raw, encoded)
|
||||||
|
Application.objects.filter(pk=app.pk).delete()
|
||||||
|
return verified
|
||||||
|
|
||||||
|
with mock.patch.object(hashers, "check_password", side_effect=verify_then_delete):
|
||||||
|
assert app.check_client_secret(secret) is False
|
||||||
|
|
||||||
|
assert not Application.objects.filter(pk=app.pk).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"secret",
|
||||||
|
[
|
||||||
|
"sha256$my-secret",
|
||||||
|
"sha256$" + "a" * 63,
|
||||||
|
"sha256$" + "a" * 64,
|
||||||
|
"sha256$" + "g" * 64,
|
||||||
|
"sha256$" + "a" * 64 + "\n",
|
||||||
|
"sha256$$" + "a" * 64,
|
||||||
|
"sha256$short$" + "a" * 64,
|
||||||
|
"sha256$" + "b" * 22 + "$" + "g" * 64,
|
||||||
|
"sha256$" + "b" * 22 + "$" + "a" * 64,
|
||||||
|
"sha256$v0$" + "g" * 64,
|
||||||
|
"sha256$v0$" + "a" * 63,
|
||||||
|
"sha256$v1$" + "a" * 64,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_prefixed_plaintext_is_hashed(secret):
|
||||||
|
"""A prefix alone must not cause a raw secret to bypass hashing."""
|
||||||
|
assert not hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(secret)
|
||||||
|
app = ApplicationFactory(client_secret=secret)
|
||||||
|
app.refresh_from_db()
|
||||||
|
encoded = app.client_secret_sha256
|
||||||
|
assert encoded != secret
|
||||||
|
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(encoded)["digest"]
|
||||||
|
assert app.check_client_secret(secret)
|
||||||
|
app.name = "Updated application"
|
||||||
|
app.save()
|
||||||
|
app.refresh_from_db()
|
||||||
|
assert app.client_secret_sha256 == encoded
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsalted_secret_is_rejected():
|
||||||
|
"""Only salted SHA-256 hashes are accepted."""
|
||||||
|
secret = get_random_string(128)
|
||||||
|
encoded = f"sha256${hashlib.sha256(secret.encode()).hexdigest()}"
|
||||||
|
|
||||||
|
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(encoded) is None
|
||||||
|
assert not hashers.verify_client_secret(secret, encoded)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_application_supports_legacy_verification(settings):
|
||||||
|
"""A rollback can authenticate applications created by the new release."""
|
||||||
|
settings.PASSWORD_HASHERS = [
|
||||||
|
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
|
||||||
|
]
|
||||||
|
secret = get_random_string(128)
|
||||||
|
app = ApplicationFactory(client_secret=secret)
|
||||||
|
app.refresh_from_db()
|
||||||
|
assert app.client_secret.startswith("pbkdf2_sha256$")
|
||||||
|
assert check_password(secret, app.client_secret)
|
||||||
|
assert hashers.verify_client_secret(secret, app.client_secret_sha256)
|
||||||
|
with mock.patch.object(hashers, "check_password", side_effect=AssertionError):
|
||||||
|
assert app.check_client_secret(secret)
|
||||||
|
assert not app.check_client_secret("wrong")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrelated_save_preserves_both_hashes():
|
||||||
|
"""Saving an application's metadata does not change either credential hash."""
|
||||||
|
app = ApplicationFactory()
|
||||||
|
original = (app.client_secret, app.client_secret_sha256)
|
||||||
|
app.name = "Renamed"
|
||||||
|
app.save()
|
||||||
|
app.refresh_from_db()
|
||||||
|
assert (app.client_secret, app.client_secret_sha256) == original
|
||||||
|
|
||||||
|
|
||||||
|
def test_creation_with_legacy_hash_defers_fast_hash_until_login():
|
||||||
|
"""An imported Django hash is preserved, never treated as the raw secret."""
|
||||||
|
secret = get_random_string(128)
|
||||||
|
legacy = make_password(secret, hasher="pbkdf2_sha256")
|
||||||
|
app = ApplicationFactory(client_secret=legacy)
|
||||||
|
app.refresh_from_db()
|
||||||
|
assert app.client_secret == legacy
|
||||||
|
assert app.client_secret_sha256 is None
|
||||||
|
assert not app.check_client_secret(legacy)
|
||||||
|
assert app.check_client_secret(secret)
|
||||||
|
app.refresh_from_db()
|
||||||
|
assert app.client_secret == legacy
|
||||||
|
assert hashers.verify_client_secret(secret, app.client_secret_sha256)
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_only_save_does_not_rotate_secret():
|
||||||
|
"""A secret excluded from update_fields must not change either stored hash."""
|
||||||
|
app = ApplicationFactory()
|
||||||
|
original = (app.client_secret, app.client_secret_sha256)
|
||||||
|
app.client_secret = get_random_string(128)
|
||||||
|
app.name = "Renamed"
|
||||||
|
app.save(update_fields=["name"])
|
||||||
|
app.refresh_from_db()
|
||||||
|
assert (app.client_secret, app.client_secret_sha256) == original
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_update_fields_does_not_rotate_secret():
|
||||||
|
"""Django's explicit no-op save must not update either credential field."""
|
||||||
|
app = ApplicationFactory()
|
||||||
|
original = (app.client_secret, app.client_secret_sha256)
|
||||||
|
app.client_secret = get_random_string(128)
|
||||||
|
with CaptureQueriesContext(connection) as queries:
|
||||||
|
app.save(update_fields=[])
|
||||||
|
assert not any(q["sql"].lstrip().startswith("UPDATE") for q in queries)
|
||||||
|
app.refresh_from_db()
|
||||||
|
assert (app.client_secret, app.client_secret_sha256) == original
|
||||||
|
|
||||||
|
|
||||||
|
def test_creation_with_salted_hash_skips_fast_hash():
|
||||||
|
"""An existing salted hash must not be hashed again as plaintext."""
|
||||||
|
encoded = hashers.hash_client_secret(get_random_string(128))
|
||||||
|
app = ApplicationFactory(client_secret=encoded)
|
||||||
|
app.refresh_from_db()
|
||||||
|
assert app.client_secret == encoded
|
||||||
|
assert app.client_secret_sha256 is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("legacy_only", [False, True])
|
||||||
|
def test_rotate_client_secret_updates_both_hashes(legacy_only, settings):
|
||||||
|
"""Rotation revokes the old secret for both current and rollback releases."""
|
||||||
|
settings.PASSWORD_HASHERS = [
|
||||||
|
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
|
||||||
|
]
|
||||||
|
secret = get_random_string(128)
|
||||||
|
app = ApplicationFactory(
|
||||||
|
client_secret=make_password(secret) if legacy_only else secret
|
||||||
|
)
|
||||||
|
|
||||||
|
replacement = app.rotate_client_secret()
|
||||||
|
|
||||||
|
assert replacement != secret
|
||||||
|
assert len(replacement) == settings.APPLICATION_CLIENT_SECRET_LENGTH
|
||||||
|
assert app.check_client_secret(replacement)
|
||||||
|
assert not app.check_client_secret(secret)
|
||||||
|
app.refresh_from_db()
|
||||||
|
assert app.client_secret.startswith("pbkdf2_sha256$")
|
||||||
|
assert check_password(replacement, app.client_secret)
|
||||||
|
assert not check_password(secret, app.client_secret)
|
||||||
|
assert hashers.verify_client_secret(replacement, app.client_secret_sha256)
|
||||||
|
assert not app.check_client_secret(secret)
|
||||||
|
assert app.client_secret != replacement
|
||||||
|
assert app.client_secret_sha256 != replacement
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotate_client_secret_preserves_metadata():
|
||||||
|
"""Rotation persists only the credential fields, not other pending changes."""
|
||||||
|
app = ApplicationFactory()
|
||||||
|
original_name = app.name
|
||||||
|
original_client_id = app.client_id
|
||||||
|
app.name = "Unsaved metadata"
|
||||||
|
|
||||||
|
app.rotate_client_secret()
|
||||||
|
|
||||||
|
app.refresh_from_db()
|
||||||
|
assert app.name == original_name
|
||||||
|
assert app.client_id == original_client_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotate_client_secret_repeatedly_revokes_previous_secrets():
|
||||||
|
"""Only the latest generated secret remains valid after successive rotations."""
|
||||||
|
original = get_random_string(128)
|
||||||
|
app = ApplicationFactory(client_secret=original)
|
||||||
|
first = app.rotate_client_secret()
|
||||||
|
second = app.rotate_client_secret()
|
||||||
|
|
||||||
|
app.refresh_from_db()
|
||||||
|
assert len({original, first, second}) == 3
|
||||||
|
assert app.check_client_secret(second)
|
||||||
|
assert check_password(second, app.client_secret)
|
||||||
|
for revoked in (original, first):
|
||||||
|
assert not app.check_client_secret(revoked)
|
||||||
|
assert not check_password(revoked, app.client_secret)
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_endpoint_rejects_rotated_secret():
|
||||||
|
"""New token requests reject the revoked secret and accept its replacement."""
|
||||||
|
secret = get_random_string(128)
|
||||||
|
app = ApplicationFactory(client_secret=secret)
|
||||||
|
user = UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
payload = {
|
||||||
|
"client_id": app.client_id,
|
||||||
|
"client_secret": secret,
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": user.email,
|
||||||
|
}
|
||||||
|
endpoint = "/external-api/v1.0/application/token/"
|
||||||
|
assert client.post(endpoint, payload, format="json").status_code == 200
|
||||||
|
|
||||||
|
replacement = app.rotate_client_secret()
|
||||||
|
|
||||||
|
assert client.post(endpoint, payload, format="json").status_code == 401
|
||||||
|
payload["client_secret"] = replacement
|
||||||
|
assert client.post(endpoint, payload, format="json").status_code == 200
|
||||||
@@ -17,6 +17,7 @@ from lasuite.oidc_resource_server.authentication import ResourceServerAuthentica
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core.analytics import AnalyticsEvent
|
from core.analytics import AnalyticsEvent
|
||||||
|
from core.external_api.serializers import RoomSerializer
|
||||||
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
||||||
from core.models import (
|
from core.models import (
|
||||||
Application,
|
Application,
|
||||||
@@ -26,7 +27,12 @@ from core.models import (
|
|||||||
RoomAccessLevel,
|
RoomAccessLevel,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from core.services.room_management import RoomManagement
|
from core.services.room_management import (
|
||||||
|
RoomManagement,
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
|
from core.services.sip_management import SIPManagement
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -563,6 +569,53 @@ def test_api_rooms_retrieve_not_found():
|
|||||||
assert "no room matches the given query." in str(response.data).lower()
|
assert "no room matches the given query." in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_retrieve_invalid_id():
|
||||||
|
"""Retrieving a room with a malformed id should return a 404."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.get("/external-api/v1.0/rooms/not-a-uuid/")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_retrieve_soft_deleted():
|
||||||
|
"""Retrieving a soft-deleted room should return a 410."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||||
|
room.soft_delete()
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||||
|
|
||||||
|
assert response.status_code == 410
|
||||||
|
assert response.json() == {"detail": "This room has been deleted."}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_retrieve_soft_deleted_not_member():
|
||||||
|
"""Retrieving a soft-deleted room without any role on it should return a 403,
|
||||||
|
not revealing that the room has been deleted."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory()
|
||||||
|
room.soft_delete()
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_requires_authentication():
|
def test_api_rooms_create_requires_authentication():
|
||||||
"""Creating rooms without authentication should return 401."""
|
"""Creating rooms without authentication should return 401."""
|
||||||
|
|
||||||
@@ -1392,6 +1445,253 @@ def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture):
|
|||||||
mock_update_metadata.assert_called_once()
|
mock_update_metadata.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||||
|
@mock.patch.object(RoomManagement, "sync_room_metadata")
|
||||||
|
def test_api_rooms_update_soft_deleted_after_load(
|
||||||
|
mock_sync_room_metadata, mock_capture
|
||||||
|
):
|
||||||
|
"""A room soft deleted while an update is in flight should not be restored."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(
|
||||||
|
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED
|
||||||
|
)
|
||||||
|
|
||||||
|
original_is_valid = RoomSerializer.is_valid
|
||||||
|
|
||||||
|
def soft_delete_while_validating(self, **kwargs):
|
||||||
|
is_valid = original_is_valid(self, **kwargs)
|
||||||
|
Room.all_objects.get(pk=self.instance.pk).soft_delete()
|
||||||
|
return is_valid
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
with mock.patch.object(RoomSerializer, "is_valid", soft_delete_while_validating):
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 410
|
||||||
|
|
||||||
|
refreshed_room = Room.all_objects.get(pk=room.pk)
|
||||||
|
assert refreshed_room.deleted_at is not None
|
||||||
|
assert refreshed_room.access_level == RoomAccessLevel.TRUSTED
|
||||||
|
|
||||||
|
mock_sync_room_metadata.assert_not_called()
|
||||||
|
mock_capture.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "delete_room")
|
||||||
|
def test_api_rooms_delete_requires_authentication(mock_delete_room):
|
||||||
|
"""Deleting a room without authentication should return 401."""
|
||||||
|
|
||||||
|
room = RoomFactory(users=[(UserFactory(), RoleChoices.OWNER)])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert Room.objects.filter(id=room.id).exists() is True
|
||||||
|
mock_delete_room.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "delete_room")
|
||||||
|
def test_api_rooms_delete_requires_scope(mock_delete_room):
|
||||||
|
"""Deleting a room requires the ROOMS_DELETE scope."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||||
|
|
||||||
|
# Token without ROOMS_DELETE scope
|
||||||
|
token = generate_test_token(
|
||||||
|
user, [ApplicationScope.ROOMS_RETRIEVE, ApplicationScope.ROOMS_UPDATE]
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert (
|
||||||
|
"insufficient permissions. required scope: rooms:delete"
|
||||||
|
in str(response.data).lower()
|
||||||
|
)
|
||||||
|
assert Room.objects.filter(id=room.id).exists() is True
|
||||||
|
mock_delete_room.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.ADMIN, RoleChoices.MEMBER, None])
|
||||||
|
@mock.patch.object(RoomManagement, "delete_room")
|
||||||
|
def test_api_rooms_delete_without_ownership(mock_delete_room, role):
|
||||||
|
"""Only owners should be able to delete a room, administrators included."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
users = [(user, role)] if role else []
|
||||||
|
room = RoomFactory(users=users)
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert Room.objects.filter(id=room.id).exists() is True
|
||||||
|
mock_delete_room.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "delete_room")
|
||||||
|
def test_api_rooms_delete_unknown_room(mock_delete_room):
|
||||||
|
"""Deleting a room that does not exist should return 404."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.delete(f"/external-api/v1.0/rooms/{uuid.uuid4()}/")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
mock_delete_room.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||||
|
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||||
|
@mock.patch.object(RoomManagement, "delete_room")
|
||||||
|
def test_api_rooms_delete_success(mock_delete_room, _, mock_capture):
|
||||||
|
"""Owners should be able to delete a room: it is soft deleted, its LiveKit
|
||||||
|
room is closed and a ROOM_DELETED analytics event is emitted."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(
|
||||||
|
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED
|
||||||
|
)
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||||
|
application = Application.objects.get()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
mock_delete_room.assert_called_once_with(str(room.id))
|
||||||
|
assert Room.objects.filter(id=room.id).exists() is False
|
||||||
|
assert Room.all_objects.get(id=room.id).deleted_at is not None
|
||||||
|
|
||||||
|
mock_capture.assert_called_once()
|
||||||
|
captured_user, event, properties = mock_capture.call_args[0]
|
||||||
|
|
||||||
|
assert captured_user == user
|
||||||
|
assert event == AnalyticsEvent.ROOM_DELETED
|
||||||
|
assert properties == {
|
||||||
|
"room_id": str(room.pk),
|
||||||
|
"access_level": RoomAccessLevel.TRUSTED,
|
||||||
|
"client_id": str(application.client_id),
|
||||||
|
"external_api": True,
|
||||||
|
"auth_method": "ApplicationJWTAuthentication",
|
||||||
|
"$set": {"email": user.email},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||||
|
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||||
|
@mock.patch.object(
|
||||||
|
RoomManagement,
|
||||||
|
"delete_room",
|
||||||
|
side_effect=RoomManagementException("Could not delete room"),
|
||||||
|
)
|
||||||
|
def test_api_rooms_delete_livekit_failure(mock_delete_room, _, mock_capture):
|
||||||
|
"""When the LiveKit room can't be closed, the deletion should be rolled back
|
||||||
|
and no analytics event emitted."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert response.json() == {"detail": "Could not delete the room, please try again."}
|
||||||
|
mock_delete_room.assert_called_once_with(str(room.id))
|
||||||
|
assert Room.objects.get(id=room.id).deleted_at is None
|
||||||
|
mock_capture.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||||
|
@mock.patch.object(
|
||||||
|
RoomManagement,
|
||||||
|
"delete_room",
|
||||||
|
side_effect=RoomNotFoundException("Room does not exist"),
|
||||||
|
)
|
||||||
|
def test_api_rooms_delete_room_not_live(
|
||||||
|
mock_delete_room, mock_delete_dispatch_rule, settings
|
||||||
|
):
|
||||||
|
"""Deleting a room that is not live in LiveKit should still soft delete it,
|
||||||
|
and delete its SIP dispatch rule, as no room_finished webhook will."""
|
||||||
|
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
mock_delete_room.assert_called_once_with(str(room.id))
|
||||||
|
mock_delete_dispatch_rule.assert_called_once_with(room.id)
|
||||||
|
assert Room.objects.filter(id=room.id).exists() is False
|
||||||
|
assert Room.all_objects.get(id=room.id).deleted_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "delete_room")
|
||||||
|
def test_api_rooms_delete_soft_deleted(mock_delete_room):
|
||||||
|
"""Deleting a room that is already soft deleted should return a 410."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||||
|
room.soft_delete()
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||||
|
|
||||||
|
assert response.status_code == 410
|
||||||
|
mock_delete_room.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "delete_room")
|
||||||
|
def test_api_rooms_delete_soft_deleted_not_owner(mock_delete_room):
|
||||||
|
"""Deleting a soft-deleted room as a non-owner should return a 403,
|
||||||
|
not revealing that the room has been deleted."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
|
||||||
|
room.soft_delete()
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
mock_delete_room.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_response_no_url(settings):
|
def test_api_rooms_response_no_url(settings):
|
||||||
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
||||||
settings.APPLICATION_BASE_URL = None
|
settings.APPLICATION_BASE_URL = None
|
||||||
|
|||||||
@@ -7,17 +7,20 @@ Tests for external API /token endpoint
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from django.contrib.auth.hashers import check_password
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import hashers
|
||||||
from core.factories import (
|
from core.factories import (
|
||||||
ApplicationDomainFactory,
|
ApplicationDomainFactory,
|
||||||
ApplicationFactory,
|
ApplicationFactory,
|
||||||
UserFactory,
|
UserFactory,
|
||||||
)
|
)
|
||||||
from core.models import ApplicationScope, User
|
from core.models import Application, ApplicationScope, User
|
||||||
from core.services import provisional_user_service
|
from core.services import provisional_user_service
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -28,15 +31,13 @@ def test_api_applications_generate_token_application_disabled(settings):
|
|||||||
settings.APPLICATION_ENABLED = False
|
settings.APPLICATION_ENABLED = False
|
||||||
|
|
||||||
user = UserFactory(email="user@example.com")
|
user = UserFactory(email="user@example.com")
|
||||||
|
plain_secret = "test-secret-123"
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
|
client_secret=plain_secret,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
scopes=[ApplicationScope.ROOMS_LIST],
|
scopes=[ApplicationScope.ROOMS_LIST],
|
||||||
)
|
)
|
||||||
|
|
||||||
plain_secret = "test-secret-123"
|
|
||||||
application.client_secret = plain_secret
|
|
||||||
application.save()
|
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/external-api/v1.0/application/token/",
|
"/external-api/v1.0/application/token/",
|
||||||
@@ -55,16 +56,13 @@ def test_api_applications_generate_token_application_disabled(settings):
|
|||||||
def test_api_applications_generate_token_success(settings):
|
def test_api_applications_generate_token_success(settings):
|
||||||
"""Valid credentials should return a JWT token."""
|
"""Valid credentials should return a JWT token."""
|
||||||
UserFactory(email="User.Family@example.com")
|
UserFactory(email="User.Family@example.com")
|
||||||
|
plain_secret = "test-secret-123"
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
|
client_secret=plain_secret,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store plain secret before it's hashed
|
|
||||||
plain_secret = "test-secret-123"
|
|
||||||
application.client_secret = plain_secret
|
|
||||||
application.save()
|
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/external-api/v1.0/application/token/",
|
"/external-api/v1.0/application/token/",
|
||||||
@@ -89,21 +87,73 @@ def test_api_applications_generate_token_success(settings):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"restricted", [True, False], ids=["restricted", "unrestricted"]
|
||||||
|
)
|
||||||
|
def test_api_applications_generate_token_success_query_count(
|
||||||
|
restricted, django_assert_num_queries
|
||||||
|
):
|
||||||
|
"""An existing user needs one query each for application, domains, and user."""
|
||||||
|
user = UserFactory(email="user@example.com")
|
||||||
|
plain_secret = "test-secret-123"
|
||||||
|
application = ApplicationFactory(client_secret=plain_secret)
|
||||||
|
if restricted:
|
||||||
|
ApplicationDomainFactory(application=application, domain="example.com")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
with django_assert_num_queries(3):
|
||||||
|
response = client.post(
|
||||||
|
"/external-api/v1.0/application/token/",
|
||||||
|
{
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"client_secret": plain_secret,
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": user.email,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "access_token" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_applications_generate_token_invalid_credentials_query_count(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
|
"""An invalid secret must be rejected before querying domains or users."""
|
||||||
|
application = ApplicationFactory(client_secret="test-secret-123")
|
||||||
|
ApplicationDomainFactory(application=application, domain="example.com")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
with django_assert_num_queries(1):
|
||||||
|
response = client.post(
|
||||||
|
"/external-api/v1.0/application/token/",
|
||||||
|
{
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"client_secret": "wrong-secret",
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": "user@example.com",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "Invalid credentials" in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
def test_api_applications_generate_token_form_urlencoded(settings):
|
def test_api_applications_generate_token_form_urlencoded(settings):
|
||||||
"""The token endpoint should accept "application/x-www-form-urlencoded"
|
"""The token endpoint should accept "application/x-www-form-urlencoded"
|
||||||
requests, as mandated by RFC 6749 (sections 3.2 and 4.4.2) for OAuth 2.0
|
requests, as mandated by RFC 6749 (sections 3.2 and 4.4.2) for OAuth 2.0
|
||||||
token endpoints, so that standard OAuth 2.0 client libraries work
|
token endpoints, so that standard OAuth 2.0 client libraries work
|
||||||
out of the box."""
|
out of the box."""
|
||||||
UserFactory(email="user@example.com")
|
UserFactory(email="user@example.com")
|
||||||
|
plain_secret = "test-secret-123"
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
|
client_secret=plain_secret,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||||
)
|
)
|
||||||
|
|
||||||
plain_secret = "test-secret-123"
|
|
||||||
application.client_secret = plain_secret
|
|
||||||
application.save()
|
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/external-api/v1.0/application/token/",
|
"/external-api/v1.0/application/token/",
|
||||||
@@ -170,11 +220,8 @@ def test_api_applications_generate_token_form_urlencoded_missing_fields():
|
|||||||
def test_api_applications_generate_token_form_urlencoded_invalid_grant_type():
|
def test_api_applications_generate_token_form_urlencoded_invalid_grant_type():
|
||||||
"""An unsupported grant_type sent as form-urlencoded should return 400."""
|
"""An unsupported grant_type sent as form-urlencoded should return 400."""
|
||||||
user = UserFactory(email="user@example.com")
|
user = UserFactory(email="user@example.com")
|
||||||
application = ApplicationFactory(is_active=True)
|
|
||||||
|
|
||||||
plain_secret = "test-secret-123"
|
plain_secret = "test-secret-123"
|
||||||
application.client_secret = plain_secret
|
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
|
||||||
application.save()
|
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -198,15 +245,13 @@ def test_api_applications_generate_token_form_urlencoded_special_characters():
|
|||||||
"""Percent-encoded reserved characters ("&", "=", "+", "%") in the
|
"""Percent-encoded reserved characters ("&", "=", "+", "%") in the
|
||||||
client_secret should survive form-urlencoded decoding."""
|
client_secret should survive form-urlencoded decoding."""
|
||||||
UserFactory(email="user@example.com")
|
UserFactory(email="user@example.com")
|
||||||
|
plain_secret = "s3cr3t&with=special+chars%42"
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
|
client_secret=plain_secret,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
scopes=[ApplicationScope.ROOMS_LIST],
|
scopes=[ApplicationScope.ROOMS_LIST],
|
||||||
)
|
)
|
||||||
|
|
||||||
plain_secret = "s3cr3t&with=special+chars%42"
|
|
||||||
application.client_secret = plain_secret
|
|
||||||
application.save()
|
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/external-api/v1.0/application/token/",
|
"/external-api/v1.0/application/token/",
|
||||||
@@ -279,14 +324,54 @@ def test_api_applications_generate_token_invalid_client_secret():
|
|||||||
assert "Invalid credentials" in str(response.data)
|
assert "Invalid credentials" in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_unknown_client_id_with_valid_secret():
|
||||||
|
"""A valid secret cannot authenticate an unknown client ID."""
|
||||||
|
secret = "application-a-secret"
|
||||||
|
ApplicationFactory(client_secret=secret)
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
response = APIClient().post(
|
||||||
|
"/external-api/v1.0/application/token/",
|
||||||
|
{
|
||||||
|
"client_id": "unknown-client-id",
|
||||||
|
"client_secret": secret,
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": user.email,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "Invalid credentials" in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_rejects_secret_owned_by_another_application():
|
||||||
|
"""Application A's secret cannot authenticate application B."""
|
||||||
|
secret_a = "application-a-secret"
|
||||||
|
ApplicationFactory(client_secret=secret_a)
|
||||||
|
application_b = ApplicationFactory(client_secret="application-b-secret")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
response = APIClient().post(
|
||||||
|
"/external-api/v1.0/application/token/",
|
||||||
|
{
|
||||||
|
"client_id": application_b.client_id,
|
||||||
|
"client_secret": secret_a,
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": user.email,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "Invalid credentials" in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
def test_api_applications_generate_token_inactive_application():
|
def test_api_applications_generate_token_inactive_application():
|
||||||
"""Inactive application should return 401."""
|
"""Inactive application should return 401."""
|
||||||
user = UserFactory(email="user@example.com")
|
user = UserFactory(email="user@example.com")
|
||||||
application = ApplicationFactory(is_active=False)
|
|
||||||
|
|
||||||
plain_secret = "test-secret-123"
|
plain_secret = "test-secret-123"
|
||||||
application.client_secret = plain_secret
|
application = ApplicationFactory(client_secret=plain_secret, is_active=False)
|
||||||
application.save()
|
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -328,11 +413,8 @@ def test_api_applications_generate_token_inactive_application_wrong_secret():
|
|||||||
|
|
||||||
def test_api_applications_generate_token_invalid_email_format():
|
def test_api_applications_generate_token_invalid_email_format():
|
||||||
"""Invalid email format should return 400."""
|
"""Invalid email format should return 400."""
|
||||||
application = ApplicationFactory(is_active=True)
|
|
||||||
|
|
||||||
plain_secret = "test-secret-123"
|
plain_secret = "test-secret-123"
|
||||||
application.client_secret = plain_secret
|
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
|
||||||
application.save()
|
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -353,12 +435,9 @@ def test_api_applications_generate_token_invalid_email_format():
|
|||||||
def test_api_applications_generate_token_domain_not_authorized():
|
def test_api_applications_generate_token_domain_not_authorized():
|
||||||
"""Application without domain authorization should return 403."""
|
"""Application without domain authorization should return 403."""
|
||||||
user = UserFactory(email="user@denied.com")
|
user = UserFactory(email="user@denied.com")
|
||||||
application = ApplicationFactory(is_active=True)
|
|
||||||
ApplicationDomainFactory(application=application, domain="allowed.com")
|
|
||||||
|
|
||||||
plain_secret = "test-secret-123"
|
plain_secret = "test-secret-123"
|
||||||
application.client_secret = plain_secret
|
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
|
||||||
application.save()
|
ApplicationDomainFactory(application=application, domain="allowed.com")
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -379,16 +458,14 @@ def test_api_applications_generate_token_domain_not_authorized():
|
|||||||
def test_api_applications_generate_token_domain_authorized():
|
def test_api_applications_generate_token_domain_authorized():
|
||||||
"""Application with domain authorization should succeed."""
|
"""Application with domain authorization should succeed."""
|
||||||
user = UserFactory(email="user@allowed.com")
|
user = UserFactory(email="user@allowed.com")
|
||||||
|
plain_secret = "test-secret-123"
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
|
client_secret=plain_secret,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
scopes=[ApplicationScope.ROOMS_LIST],
|
scopes=[ApplicationScope.ROOMS_LIST],
|
||||||
)
|
)
|
||||||
ApplicationDomainFactory(application=application, domain="allowed.com")
|
ApplicationDomainFactory(application=application, domain="allowed.com")
|
||||||
|
|
||||||
plain_secret = "test-secret-123"
|
|
||||||
application.client_secret = plain_secret
|
|
||||||
application.save()
|
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/external-api/v1.0/application/token/",
|
"/external-api/v1.0/application/token/",
|
||||||
@@ -407,11 +484,8 @@ def test_api_applications_generate_token_domain_authorized():
|
|||||||
|
|
||||||
def test_api_applications_generate_token_user_not_found():
|
def test_api_applications_generate_token_user_not_found():
|
||||||
"""Non-existent user should return 404."""
|
"""Non-existent user should return 404."""
|
||||||
application = ApplicationFactory(is_active=True)
|
|
||||||
|
|
||||||
plain_secret = "test-secret-123"
|
plain_secret = "test-secret-123"
|
||||||
application.client_secret = plain_secret
|
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
|
||||||
application.save()
|
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -434,15 +508,13 @@ def test_api_applications_token_payload_structure(settings):
|
|||||||
"""Generated token should have correct payload structure."""
|
"""Generated token should have correct payload structure."""
|
||||||
user = UserFactory(email="user@example.com")
|
user = UserFactory(email="user@example.com")
|
||||||
|
|
||||||
|
plain_secret = "test-secret-123"
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
|
client_secret=plain_secret,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||||
)
|
)
|
||||||
|
|
||||||
plain_secret = "test-secret-123"
|
|
||||||
application.client_secret = plain_secret
|
|
||||||
application.save()
|
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/external-api/v1.0/application/token/",
|
"/external-api/v1.0/application/token/",
|
||||||
@@ -487,15 +559,13 @@ def test_api_applications_token_new_user(settings):
|
|||||||
|
|
||||||
assert len(User.objects.all()) == 0
|
assert len(User.objects.all()) == 0
|
||||||
|
|
||||||
|
plain_secret = "test-secret-123"
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
|
client_secret=plain_secret,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||||
)
|
)
|
||||||
|
|
||||||
plain_secret = "test-secret-123"
|
|
||||||
application.client_secret = plain_secret
|
|
||||||
application.save()
|
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/external-api/v1.0/application/token/",
|
"/external-api/v1.0/application/token/",
|
||||||
@@ -545,15 +615,13 @@ def test_api_applications_token_existing_user(settings):
|
|||||||
|
|
||||||
assert len(User.objects.all()) == 1
|
assert len(User.objects.all()) == 1
|
||||||
|
|
||||||
|
plain_secret = "test-secret-123"
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
|
client_secret=plain_secret,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||||
)
|
)
|
||||||
|
|
||||||
plain_secret = "test-secret-123"
|
|
||||||
application.client_secret = plain_secret
|
|
||||||
application.save()
|
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/external-api/v1.0/application/token/",
|
"/external-api/v1.0/application/token/",
|
||||||
@@ -598,12 +666,10 @@ def test_api_applications_token_new_user_race_condition(mock_get_by_email, setti
|
|||||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||||
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
|
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
|
||||||
|
|
||||||
application = ApplicationFactory(
|
|
||||||
is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
|
|
||||||
)
|
|
||||||
plain_secret = "test-secret-123"
|
plain_secret = "test-secret-123"
|
||||||
application.client_secret = plain_secret
|
application = ApplicationFactory(
|
||||||
application.save()
|
client_secret=plain_secret, is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
|
||||||
|
)
|
||||||
|
|
||||||
email = "john.doe@example.com"
|
email = "john.doe@example.com"
|
||||||
|
|
||||||
@@ -653,12 +719,10 @@ def test_api_applications_token_new_user_race_condition_unrecoverable(
|
|||||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||||
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
|
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
|
||||||
|
|
||||||
application = ApplicationFactory(
|
|
||||||
is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
|
|
||||||
)
|
|
||||||
plain_secret = "test-secret-123"
|
plain_secret = "test-secret-123"
|
||||||
application.client_secret = plain_secret
|
application = ApplicationFactory(
|
||||||
application.save()
|
client_secret=plain_secret, is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
|
||||||
|
)
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -674,3 +738,183 @@ def test_api_applications_token_new_user_race_condition_unrecoverable(
|
|||||||
|
|
||||||
assert response.status_code == 409
|
assert response.status_code == 409
|
||||||
assert mock_get_or_create.call_count == 1
|
assert mock_get_or_create.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_populates_fast_hash_and_stops_using_legacy_hash():
|
||||||
|
"""First login migrates; subsequent logins use only the fast hash."""
|
||||||
|
secret = "application-secret"
|
||||||
|
|
||||||
|
application = ApplicationFactory(client_secret=secret)
|
||||||
|
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
|
||||||
|
application.refresh_from_db()
|
||||||
|
|
||||||
|
original_hash = application.client_secret
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
payload = {
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"client_secret": secret,
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": user.email,
|
||||||
|
}
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
hashers, "check_password", wraps=hashers.check_password
|
||||||
|
) as legacy_verifier:
|
||||||
|
response = client.post(
|
||||||
|
"/external-api/v1.0/application/token/", payload, format="json"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
legacy_verifier.assert_called_once_with(secret, original_hash)
|
||||||
|
|
||||||
|
application.refresh_from_db()
|
||||||
|
|
||||||
|
migrated_hash = application.client_secret_sha256
|
||||||
|
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(migrated_hash)
|
||||||
|
assert hashers.verify_client_secret(secret, migrated_hash)
|
||||||
|
assert application.client_secret == original_hash
|
||||||
|
|
||||||
|
# Fail immediately if a subsequent login tries the legacy verifier.
|
||||||
|
with mock.patch.object(
|
||||||
|
hashers,
|
||||||
|
"check_password",
|
||||||
|
side_effect=AssertionError("Legacy hash must no longer be used"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
"/external-api/v1.0/application/token/", payload, format="json"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
application.refresh_from_db()
|
||||||
|
assert application.client_secret_sha256 == migrated_hash
|
||||||
|
assert application.client_secret == original_hash
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_failed_login_leaves_legacy_credentials_untouched():
|
||||||
|
"""An incorrect secret neither migrates nor changes the legacy hash."""
|
||||||
|
|
||||||
|
application = ApplicationFactory(client_secret="application-secret")
|
||||||
|
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
|
||||||
|
application.refresh_from_db()
|
||||||
|
|
||||||
|
original_hash = application.client_secret
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
response = APIClient().post(
|
||||||
|
"/external-api/v1.0/application/token/",
|
||||||
|
{
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"client_secret": "wrong-secret",
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": user.email,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
application.refresh_from_db()
|
||||||
|
assert application.client_secret == original_hash
|
||||||
|
assert application.client_secret_sha256 is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_concurrent_successful_logins_preserve_first_migration():
|
||||||
|
"""Both logins succeed; the later migration preserves the first hash."""
|
||||||
|
secret = "application-secret"
|
||||||
|
application = ApplicationFactory(client_secret=secret)
|
||||||
|
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
|
||||||
|
application.refresh_from_db()
|
||||||
|
|
||||||
|
original_hash = application.client_secret
|
||||||
|
user = UserFactory()
|
||||||
|
payload = {
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"client_secret": secret,
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": user.email,
|
||||||
|
}
|
||||||
|
legacy_verifier = hashers.check_password
|
||||||
|
winning_hashes = []
|
||||||
|
|
||||||
|
def verify_then_complete_other_login(raw_secret, encoded):
|
||||||
|
verified = legacy_verifier(raw_secret, encoded)
|
||||||
|
|
||||||
|
# Complete another login before this request writes its migration.
|
||||||
|
# Restore the real verifier to avoid recursively invoking this callback.
|
||||||
|
with mock.patch.object(hashers, "check_password", new=legacy_verifier):
|
||||||
|
other_response = APIClient().post(
|
||||||
|
"/external-api/v1.0/application/token/", payload, format="json"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert other_response.status_code == 200
|
||||||
|
application.refresh_from_db()
|
||||||
|
winning_hashes.append(application.client_secret_sha256)
|
||||||
|
return verified
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
hashers, "check_password", side_effect=verify_then_complete_other_login
|
||||||
|
) as verifier:
|
||||||
|
response = APIClient().post(
|
||||||
|
"/external-api/v1.0/application/token/", payload, format="json"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
verifier.assert_called_once_with(secret, original_hash)
|
||||||
|
application.refresh_from_db()
|
||||||
|
assert application.client_secret_sha256 == winning_hashes[0]
|
||||||
|
assert hashers.verify_client_secret(secret, application.client_secret_sha256)
|
||||||
|
assert application.client_secret == original_hash
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_authenticates_after_rollback():
|
||||||
|
"""Legacy authentication still works after the fast hash is discarded."""
|
||||||
|
secret = "application-secret"
|
||||||
|
application = ApplicationFactory(client_secret=secret)
|
||||||
|
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
|
||||||
|
application.refresh_from_db()
|
||||||
|
|
||||||
|
original_hash = application.client_secret
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
payload = {
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"client_secret": secret,
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": user.email,
|
||||||
|
}
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
# Authenticate with the new implementation and migrate the hash.
|
||||||
|
response = client.post(
|
||||||
|
"/external-api/v1.0/application/token/", payload, format="json"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
application.refresh_from_db()
|
||||||
|
|
||||||
|
assert hashers.verify_client_secret(secret, application.client_secret_sha256)
|
||||||
|
assert application.client_secret == original_hash
|
||||||
|
|
||||||
|
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
|
||||||
|
|
||||||
|
def legacy_check(instance, raw_secret):
|
||||||
|
return check_password(raw_secret, instance.client_secret)
|
||||||
|
|
||||||
|
# Simulate the old release's verification using only the legacy field.
|
||||||
|
with mock.patch.object(
|
||||||
|
Application,
|
||||||
|
"check_client_secret",
|
||||||
|
autospec=True,
|
||||||
|
side_effect=legacy_check,
|
||||||
|
) as verifier:
|
||||||
|
response = client.post(
|
||||||
|
"/external-api/v1.0/application/token/", payload, format="json"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
verifier.assert_called_once()
|
||||||
|
application.refresh_from_db()
|
||||||
|
assert application.client_secret == original_hash
|
||||||
|
assert application.client_secret_sha256 is None
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""
|
||||||
|
Tests for external API /users endpoints (transit codes)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0621
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.factories import ApplicationFactory, UserFactory
|
||||||
|
from core.models import ApplicationScope
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def generate_addons_test_token(user, scopes):
|
||||||
|
"""Generate a valid JWT token signed with the addons secret for testing."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.ADDONS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.ADDONS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.ADDONS_TOKEN_TTL),
|
||||||
|
"scope": " ".join(scopes),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.ADDONS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.ADDONS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_test_token(user, scopes, application=None):
|
||||||
|
"""Generate a valid application JWT token for testing."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
scope_string = " ".join(scopes)
|
||||||
|
|
||||||
|
if application is None:
|
||||||
|
application = ApplicationFactory(scopes=scopes)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.APPLICATION_JWT_ISSUER,
|
||||||
|
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now
|
||||||
|
+ timedelta(seconds=django_settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||||
|
"client_id": str(application.client_id),
|
||||||
|
"scope": scope_string,
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"delegated": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
|
algorithm=django_settings.APPLICATION_JWT_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_requires_authentication():
|
||||||
|
"""Minting a transit code without authentication should return 401."""
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_missing_scope():
|
||||||
|
"""A token without the 'users:session' scope should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "users:session" in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_success(settings):
|
||||||
|
"""A delegated user with the scope should be able to mint a transit code."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["expires_in"] == settings.TRANSIT_CODE_TTL
|
||||||
|
|
||||||
|
code = response.data["transit_code"]
|
||||||
|
# Opaque, high-entropy random string
|
||||||
|
assert len(code) == (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
|
||||||
|
|
||||||
|
# The code is stored server-side and references the delegated user
|
||||||
|
code_data = TransitCodeService().consume_code(code)
|
||||||
|
assert code_data == {
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"client_id": mock.ANY,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_scope_claim_exceeding_db_grant():
|
||||||
|
"""A 'users:session' claim beyond the grant recorded in database is refused."""
|
||||||
|
user = UserFactory()
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.ROOMS_RETRIEVE])
|
||||||
|
|
||||||
|
token = generate_test_token(
|
||||||
|
user, [ApplicationScope.USERS_SESSION], application=application
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "not granted" in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_get_forbidden():
|
||||||
|
"""Minting a transit code with a GET should not be allowed."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.get("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 405
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_resource_server_not_supported():
|
||||||
|
"""A resource server token must not be able to mint a transit code."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
ResourceServerAuthentication,
|
||||||
|
"authenticate",
|
||||||
|
return_value=(user, {"scope": "users:session", "client_id": "rs-client"}),
|
||||||
|
) as mock_rs_authenticate:
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
mock_rs_authenticate.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_feature_disabled(settings):
|
||||||
|
"""Minting a transit code should return 404 when the feature is disabled."""
|
||||||
|
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_inactive_user():
|
||||||
|
"""An inactive user should not be able to mint a transit code."""
|
||||||
|
user = UserFactory(is_active=False)
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_rejects_addons_token():
|
||||||
|
"""An addons token must not be able to mint a transit code.
|
||||||
|
|
||||||
|
The token carries the 'users:session' scope and is signed with the addons
|
||||||
|
secret, so only the missing backend stands between it and a transit code.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
token = generate_addons_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
ResourceServerAuthentication, "authenticate", return_value=None
|
||||||
|
):
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("auth", [None, {}, {"client_id": ""}, {"client_id": None}])
|
||||||
|
def test_api_users_transit_code_missing_client_id(auth):
|
||||||
|
"""No transit code should be minted without a client_id in the token."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION="Bearer token")
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"core.external_api.authentication.ApplicationJWTAuthentication.authenticate",
|
||||||
|
return_value=(user, auth),
|
||||||
|
),
|
||||||
|
mock.patch(
|
||||||
|
"core.external_api.permissions.HasRequiredUserScope.has_permission",
|
||||||
|
return_value=True,
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
TransitCodeService, "create_code", return_value="code"
|
||||||
|
) as mock_create_code,
|
||||||
|
):
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json() == {"detail": "Invalid application token."}
|
||||||
|
mock_create_code.assert_not_called()
|
||||||
@@ -6,12 +6,12 @@ Unit tests for the Application and ApplicationDomain models
|
|||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.contrib.auth.hashers import check_password
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.factories import ApplicationDomainFactory, ApplicationFactory
|
from core.factories import ApplicationDomainFactory, ApplicationFactory
|
||||||
|
from core.hashers import verify_client_secret
|
||||||
from core.models import Application, ApplicationDomain, ApplicationScope
|
from core.models import Application, ApplicationDomain, ApplicationScope
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -98,8 +98,8 @@ def test_models_application_client_secret_hashed_on_save():
|
|||||||
|
|
||||||
# Secret should be hashed, not plain
|
# Secret should be hashed, not plain
|
||||||
assert application.client_secret != plain_secret
|
assert application.client_secret != plain_secret
|
||||||
# Should verify with check_password
|
# Should verify with the application credential policy
|
||||||
assert check_password(plain_secret, application.client_secret) is True
|
assert verify_client_secret(plain_secret, application.client_secret) is True
|
||||||
|
|
||||||
|
|
||||||
def test_models_application_client_secret_preserves_existing_hash():
|
def test_models_application_client_secret_preserves_existing_hash():
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ def test_models_recording_get_abilities_privileges_active(role):
|
|||||||
|
|
||||||
|
|
||||||
def test_models_recording_get_abilities_member_active():
|
def test_models_recording_get_abilities_member_active():
|
||||||
"""Test abilities for a user who is unprivileged."""
|
"""A member can retrieve the recording but not stop or delete it."""
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
access = UserRecordingAccessFactory(role="member", user=user)
|
access = UserRecordingAccessFactory(role="member", user=user)
|
||||||
@@ -101,7 +101,7 @@ def test_models_recording_get_abilities_member_active():
|
|||||||
assert abilities == {
|
assert abilities == {
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"partial_update": False,
|
"partial_update": False,
|
||||||
"retrieve": False,
|
"retrieve": True,
|
||||||
"stop": False,
|
"stop": False,
|
||||||
"update": False,
|
"update": False,
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@ def test_models_recording_get_abilities_privileges_final(role):
|
|||||||
|
|
||||||
|
|
||||||
def test_models_recording_get_abilities_member_final():
|
def test_models_recording_get_abilities_member_final():
|
||||||
"""Test abilities for a user who is unprivileged."""
|
"""A member can retrieve the recording but not stop or delete it."""
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
access = UserRecordingAccessFactory(role="member", user=user)
|
access = UserRecordingAccessFactory(role="member", user=user)
|
||||||
@@ -139,7 +139,7 @@ def test_models_recording_get_abilities_member_final():
|
|||||||
assert abilities == {
|
assert abilities == {
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"partial_update": False,
|
"partial_update": False,
|
||||||
"retrieve": False,
|
"retrieve": True,
|
||||||
"stop": False,
|
"stop": False,
|
||||||
"update": False,
|
"update": False,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,21 @@ def test_models_rooms_is_public_property():
|
|||||||
assert private_room.is_public is False
|
assert private_room.is_public is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_rooms_soft_delete_concurrent():
|
||||||
|
"""A deletion racing another one on a stale instance matches no row and fails,
|
||||||
|
so the winner's timestamp is never overwritten."""
|
||||||
|
room = RoomFactory()
|
||||||
|
stale_room = Room.all_objects.get(pk=room.pk)
|
||||||
|
|
||||||
|
room.soft_delete()
|
||||||
|
|
||||||
|
assert stale_room.deleted_at is None
|
||||||
|
with pytest.raises(RuntimeError, match="This room is already deleted."):
|
||||||
|
stale_room.soft_delete()
|
||||||
|
|
||||||
|
assert Room.all_objects.get(pk=room.pk).deleted_at == room.deleted_at
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(Room, "generate_unique_pin_code")
|
@mock.patch.object(Room, "generate_unique_pin_code")
|
||||||
def test_telephony_and_roomkit_disabled_skips_pin_generation(
|
def test_telephony_and_roomkit_disabled_skips_pin_generation(
|
||||||
mock_generate_unique_pin_code, settings
|
mock_generate_unique_pin_code, settings
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Shared helpers for tests in the Meet core application"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from core.factories import ApplicationFactory
|
||||||
|
from core.models import ApplicationScope
|
||||||
|
|
||||||
|
|
||||||
|
def generate_user_access_token(user, application=None, **overrides):
|
||||||
|
"""Generate a valid user access JWT signed with the token secret.
|
||||||
|
|
||||||
|
Claims can be overridden through keyword arguments; passing None for a
|
||||||
|
claim removes it from the payload.
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
if application is None:
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": settings.USER_ACCESS_TOKEN_TYPE_CLAIM,
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
payload = {key: value for key, value in payload.items() if value is not None}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user