mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-14 20:53:26 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 932f400a2e | |||
| a3f22e0a4f | |||
| 0a879a96fd | |||
| ed7fa7312a | |||
| 2f948fd53a | |||
| e701a89036 | |||
| 7fbcbc89ed | |||
| 89f8480e0b | |||
| d9bf6efa2a | |||
| 4cb7412194 | |||
| e8df597055 | |||
| 05dfcd11ca | |||
| b018832fcf | |||
| a269160f6f | |||
| c3afa84d9b | |||
| 8c6752a29f | |||
| 4c57432a03 | |||
| 3b7bfd999c | |||
| 7f386b2e2f | |||
| c55d8235fd | |||
| c7b23abd68 | |||
| 5a641a4366 | |||
| f043ad6f98 | |||
| 1141c1cecd | |||
| 33792b050a | |||
| f4569c64e5 | |||
| 6a00d3d087 | |||
| 2e975e2643 | |||
| 4c63aa827f | |||
| 67e9bf2fef | |||
| 7124167947 | |||
| 759388c72f | |||
| a663b4dc76 | |||
| 73aa162dc8 | |||
| a3851842e9 | |||
| 77964c6a74 | |||
| 72b863e794 | |||
| 0e3c978af2 |
@@ -0,0 +1,29 @@
|
|||||||
|
# /!\
|
||||||
|
# Security Note: This action is not hardened against prompt injection attacks and should only be used
|
||||||
|
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
|
||||||
|
# to ensure workflows only run after a maintainer has reviewed the PR.
|
||||||
|
name: Security Review
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write # Needed for leaving PR comments
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
|
||||||
|
with:
|
||||||
|
comment-pr: true
|
||||||
|
exclude-directories: docs,gitlint,LICENSES,bin
|
||||||
|
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
|
||||||
+2
-7
@@ -14,14 +14,11 @@ and this project adheres to
|
|||||||
- ✨(frontend) add configurable documentation menu item
|
- ✨(frontend) add configurable documentation menu item
|
||||||
- ✨(frontend) allow promoting authenticated participants
|
- ✨(frontend) allow promoting authenticated participants
|
||||||
- ✨(frontend) introduce an "unauthenticated" participant badge
|
- ✨(frontend) introduce an "unauthenticated" participant badge
|
||||||
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
|
||||||
- ✨(frontend) let users set default configuration for generated links
|
|
||||||
- ✨(frontend) expose media state to external gateways
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
||||||
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.6
|
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.4
|
||||||
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
||||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
||||||
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
||||||
@@ -29,9 +26,8 @@ and this project adheres to
|
|||||||
- 📝(legal) update terms of service
|
- 📝(legal) update terms of service
|
||||||
- 💄(frontend) render Avatar initials in uppercase
|
- 💄(frontend) render Avatar initials in uppercase
|
||||||
- 💄(frontend) improve participant name rendering in the list
|
- 💄(frontend) improve participant name rendering in the list
|
||||||
- 🚚(backend) rename TelephonyService to SIPManagement
|
|
||||||
|
|
||||||
### Fixed
|
## Fixed
|
||||||
|
|
||||||
- 🐛(transcription) fix silent bug in speaker assignment
|
- 🐛(transcription) fix silent bug in speaker assignment
|
||||||
- 🐛(summary) extend tasks auto retry logic
|
- 🐛(summary) extend tasks auto retry logic
|
||||||
@@ -40,7 +36,6 @@ and this project adheres to
|
|||||||
- 🐛(backend) allow any string as sub in the API serializer
|
- 🐛(backend) allow any string as sub in the API serializer
|
||||||
- 🐛(frontend) fall back to user.full_name on request-entry
|
- 🐛(frontend) fall back to user.full_name on request-entry
|
||||||
- 🚸(frontend) show two initials in the Avatar when possible
|
- 🚸(frontend) show two initials in the Avatar when possible
|
||||||
- 🩹(all) clear the SonarCloud reliability finding and the lint debt
|
|
||||||
|
|
||||||
## [1.24.0] - 2026-07-21
|
## [1.24.0] - 2026-07-21
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ COMPOSE_EXEC = $(COMPOSE) exec
|
|||||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||||
COMPOSE_RUN_LINT = $(COMPOSE_RUN) --no-deps app-dev
|
|
||||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||||
|
|
||||||
@@ -52,14 +51,6 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
|
|||||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||||
MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
|
MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
|
||||||
|
|
||||||
# -- Linters
|
|
||||||
LINT_RUFF_FORMAT = ruff format .
|
|
||||||
LINT_RUFF_CHECK = ruff check . --fix
|
|
||||||
LINT_PYLINT = pylint meet demo core
|
|
||||||
LINT_BACK = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
|
|
||||||
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \
|
|
||||||
&& echo 'lint:pylint started…' && $(LINT_PYLINT)
|
|
||||||
|
|
||||||
# -- Frontend
|
# -- Frontend
|
||||||
PATH_FRONT = ./src/frontend
|
PATH_FRONT = ./src/frontend
|
||||||
|
|
||||||
@@ -133,7 +124,6 @@ logs: ## display app-dev logs (follow mode)
|
|||||||
run-backend: ## start only the backend application and all needed services
|
run-backend: ## start only the backend application and all needed services
|
||||||
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
|
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
|
||||||
@$(COMPOSE) up --force-recreate -d nginx
|
@$(COMPOSE) up --force-recreate -d nginx
|
||||||
@$(COMPOSE) up -d livekit
|
|
||||||
@echo "Wait for postgresql to be up..."
|
@echo "Wait for postgresql to be up..."
|
||||||
@$(WAIT_DB)
|
@$(WAIT_DB)
|
||||||
.PHONY: run-backend
|
.PHONY: run-backend
|
||||||
@@ -198,23 +188,27 @@ demo: ## flush db then create a demo for load testing purpose
|
|||||||
@$(MANAGE) create_demo
|
@$(MANAGE) create_demo
|
||||||
.PHONY: demo
|
.PHONY: demo
|
||||||
|
|
||||||
|
# Nota bene: Black should come after isort just in case they don't agree...
|
||||||
lint: ## lint back-end python sources
|
lint: ## lint back-end python sources
|
||||||
@$(COMPOSE_RUN_LINT) sh -c "$(LINT_BACK)"
|
lint: \
|
||||||
|
lint-ruff-format \
|
||||||
|
lint-ruff-check \
|
||||||
|
lint-pylint
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|
||||||
lint-ruff-format: ## format back-end python sources with ruff
|
lint-ruff-format: ## format back-end python sources with ruff
|
||||||
@echo 'lint:ruff-format started…'
|
@echo 'lint:ruff-format started…'
|
||||||
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_FORMAT)
|
@$(COMPOSE_RUN_APP) ruff format .
|
||||||
.PHONY: lint-ruff-format
|
.PHONY: lint-ruff-format
|
||||||
|
|
||||||
lint-ruff-check: ## lint back-end python sources with ruff
|
lint-ruff-check: ## lint back-end python sources with ruff
|
||||||
@echo 'lint:ruff-check started…'
|
@echo 'lint:ruff-check started…'
|
||||||
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_CHECK)
|
@$(COMPOSE_RUN_APP) ruff check . --fix
|
||||||
.PHONY: lint-ruff-check
|
.PHONY: lint-ruff-check
|
||||||
|
|
||||||
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
||||||
@echo 'lint:pylint started…'
|
@echo 'lint:pylint started…'
|
||||||
@$(COMPOSE_RUN_LINT) $(LINT_PYLINT)
|
@$(COMPOSE_RUN_APP) pylint meet demo core
|
||||||
.PHONY: lint-pylint
|
.PHONY: lint-pylint
|
||||||
|
|
||||||
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"`
|
||||||
@@ -395,6 +389,12 @@ build-k8s-cluster: \
|
|||||||
./bin/start-kind.sh
|
./bin/start-kind.sh
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
|
build-k8s-cluster-orbstack: ## setup the kubernetes environment on OrbStack's built-in cluster (macOS)
|
||||||
|
build-k8s-cluster-orbstack: \
|
||||||
|
env.d/development/kube-secret
|
||||||
|
./bin/start-orbstack.sh
|
||||||
|
.PHONY: build-k8s-cluster-orbstack
|
||||||
|
|
||||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||||
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
|
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||||
|
|
||||||
|
# OrbStack's built-in cluster (macOS) is a supported alternative to kind.
|
||||||
|
# Recent Tilt versions (>= 0.33) detect it as a local dev cluster; this is
|
||||||
|
# a no-op for kind and a safety net for older Tilt versions.
|
||||||
|
allow_k8s_contexts('orbstack')
|
||||||
|
|
||||||
namespace_create('meet')
|
namespace_create('meet')
|
||||||
|
|
||||||
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
|
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
|
||||||
|
|||||||
Executable
+182
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Bootstrap the local dev environment on OrbStack's built-in Kubernetes
|
||||||
|
# cluster (macOS) instead of kind.
|
||||||
|
#
|
||||||
|
# This replicates what bin/start-kind.sh (numerique-gouv/tools
|
||||||
|
# kind/create_cluster.sh) provides, minus what OrbStack makes unnecessary:
|
||||||
|
# - no kind cluster: OrbStack ships a lightweight single-node cluster
|
||||||
|
# - no local registry (kind-registry): OrbStack's cluster shares the
|
||||||
|
# Docker image store, so images built by Tilt are directly visible
|
||||||
|
# to pods. Tilt detects the "orbstack" context as a local cluster
|
||||||
|
# and skips pushing images entirely.
|
||||||
|
#
|
||||||
|
# Requirements: OrbStack (with Kubernetes enabled), kubectl, mkcert, curl.
|
||||||
|
set -o errexit
|
||||||
|
|
||||||
|
APPLICATION=${1:-meet}
|
||||||
|
CONTEXT="orbstack"
|
||||||
|
|
||||||
|
echo "0. Check OrbStack Kubernetes is available"
|
||||||
|
if ! command -v mkcert >/dev/null 2>&1; then
|
||||||
|
echo "❌ mkcert is not installed. Install it first: brew install mkcert"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! kubectl config get-contexts -o name | grep -qx "${CONTEXT}"; then
|
||||||
|
echo "Context '${CONTEXT}' not found. Trying to start OrbStack Kubernetes..."
|
||||||
|
if command -v orb >/dev/null 2>&1; then
|
||||||
|
orb start k8s
|
||||||
|
else
|
||||||
|
echo "❌ Enable Kubernetes in OrbStack (Settings > Kubernetes) and retry."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
kubectl config use-context "${CONTEXT}"
|
||||||
|
|
||||||
|
echo "0b. Check ports 80/443 are free on localhost"
|
||||||
|
# OrbStack forwards LoadBalancer service ports to 127.0.0.1. If the kind
|
||||||
|
# cluster is still running, its docker proxy already holds 80/443.
|
||||||
|
# Skip the check if ingress-nginx is already installed here: in that case
|
||||||
|
# the listener on 80/443 is our own LoadBalancer.
|
||||||
|
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller >/dev/null 2>&1; then
|
||||||
|
for port in 80 443; do
|
||||||
|
if lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||||
|
echo "❌ Port ${port} is already in use on the host."
|
||||||
|
echo " If the kind cluster is running, delete it first:"
|
||||||
|
echo " kind delete cluster --name suite"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "1. Create ca"
|
||||||
|
CURRENT_DIR=$(pwd)
|
||||||
|
mkcert -install
|
||||||
|
cd /tmp
|
||||||
|
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
|
||||||
|
cd "${CURRENT_DIR}"
|
||||||
|
|
||||||
|
echo "2. Install ingress-nginx (cloud provider: LoadBalancer service)"
|
||||||
|
# OrbStack exposes LoadBalancer services on 127.0.0.1, so the cloud
|
||||||
|
# manifest replaces kind's hostPort-based deploy. Every sub-step below is
|
||||||
|
# guarded individually so the script is safe to re-run after a partial
|
||||||
|
# failure (unlike the upstream kind script, which guards the whole block
|
||||||
|
# on namespace existence).
|
||||||
|
|
||||||
|
# Make sure no stale registry configmap tells Tilt to push to localhost:5001
|
||||||
|
# (there is no registry on OrbStack).
|
||||||
|
kubectl -n kube-public delete configmap local-registry-hosting --ignore-not-found
|
||||||
|
|
||||||
|
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller >/dev/null 2>&1; then
|
||||||
|
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/cloud/deploy.yaml
|
||||||
|
fi
|
||||||
|
if ! kubectl -n ingress-nginx get deployment nginx-errors >/dev/null 2>&1; then
|
||||||
|
kubectl apply -n ingress-nginx -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/refs/heads/main/docs/examples/customization/custom-errors/custom-default-backend.yaml
|
||||||
|
fi
|
||||||
|
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem || echo ok
|
||||||
|
|
||||||
|
# The meet charts render Ingresses without ingressClassName. The kind
|
||||||
|
# provider manifest handles this via --watch-ingress-without-class=true;
|
||||||
|
# the cloud manifest does not, so add it here (otherwise: 404 everywhere).
|
||||||
|
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller -o jsonpath='{.spec.template.spec.containers[0].args}' | grep -q 'watch-ingress-without-class'; then
|
||||||
|
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--watch-ingress-without-class=true"},{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"},{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-backend-service=ingress-nginx/nginx-errors"}
|
||||||
|
]'
|
||||||
|
fi
|
||||||
|
if ! kubectl -n ingress-nginx get deployment nginx-errors -o jsonpath='{.spec.template.spec.containers[0].image}' | grep -q 'error-pages'; then
|
||||||
|
kubectl -n ingress-nginx patch deployment nginx-errors --type=json -p='[
|
||||||
|
{"op": "replace", "path": "/spec/template/spec/containers/0/image", "value": "ghcr.io/tarampampam/error-pages:3.3.0"},
|
||||||
|
{"op": "add", "path": "/spec/template/spec/containers/0/env", "value": [{"name": "TEMPLATE_NAME", "value": "ghost"}, {"name": "SHOW_DETAILS", "value": "false"}, {"name": "SEND_SAME_HTTP_CODE", "value": "true"}]}
|
||||||
|
]'
|
||||||
|
fi
|
||||||
|
cat <<EOF | kubectl apply -n ingress-nginx -f -
|
||||||
|
apiVersion: v1
|
||||||
|
data:
|
||||||
|
allow-snippet-annotations: "true"
|
||||||
|
annotations-risk-level: Critical
|
||||||
|
custom-http-errors: 500,501,502,503,504
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: ingress-nginx-controller
|
||||||
|
namespace: ingress-nginx
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "2b. Wait for the ingress controller to be ready"
|
||||||
|
kubectl -n ingress-nginx rollout status deployment/ingress-nginx-controller --timeout=180s
|
||||||
|
|
||||||
|
echo "3. Patch CoreDNS so in-cluster pods resolve *.127.0.0.1.nip.io to the ingress"
|
||||||
|
# nip.io resolves to 127.0.0.1, which inside a pod is the pod itself.
|
||||||
|
# Rewrite these names to the ingress-nginx service, like the kind setup does.
|
||||||
|
# Unlike kind, we amend OrbStack's existing Corefile instead of replacing it.
|
||||||
|
if ! kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' | grep -q '127\.0\.0\.1\.nip\.io'; then
|
||||||
|
kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' \
|
||||||
|
| awk '/forward \./ && !done { print " rewrite stop {"; print " name regex (.*).127.0.0.1.nip.io ingress-nginx-controller.ingress-nginx.svc.cluster.local answer auto"; print " }"; done=1 } { print }' \
|
||||||
|
>/tmp/Corefile.orbstack
|
||||||
|
kubectl -n kube-system create configmap coredns --from-file=Corefile=/tmp/Corefile.orbstack --dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
kubectl -n kube-system rollout restart deployments/coredns
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! kubectl get ns "${APPLICATION}" >/dev/null 2>&1; then
|
||||||
|
echo "4. Setup namespace"
|
||||||
|
kubectl create ns "${APPLICATION}"
|
||||||
|
fi
|
||||||
|
kubectl config set-context --current --namespace="${APPLICATION}"
|
||||||
|
kubectl -n "${APPLICATION}" create secret generic mkcert --from-file=rootCA.pem="$(mkcert -CAROOT)/rootCA.pem" || echo ok
|
||||||
|
|
||||||
|
if ! kubectl get configmap certifi -n "${APPLICATION}" >/dev/null 2>&1; then
|
||||||
|
echo "5. Inject our custom CA in a configmap for certifi"
|
||||||
|
curl https://raw.githubusercontent.com/certifi/python-certifi/refs/heads/master/certifi/cacert.pem -o /tmp/cacert.pem
|
||||||
|
cat "$(mkcert -CAROOT)/rootCA.pem" >>/tmp/cacert.pem
|
||||||
|
kubectl -n "${APPLICATION}" create configmap certifi --from-file=cacert.pem=/tmp/cacert.pem
|
||||||
|
kubectl -n "${APPLICATION}" create secret generic certifi --from-file=/tmp/cacert.pem || echo ok
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "5b. Smoke test: the ingress chain answers on https://127.0.0.1"
|
||||||
|
# Before Tilt deploys the app this returns the styled 404 from the default
|
||||||
|
# backend — that still proves LB -> controller works. 000 means the
|
||||||
|
# LoadBalancer is not bound to localhost.
|
||||||
|
HTTP_CODE=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 10 https://127.0.0.1/ || true)
|
||||||
|
if [ "${HTTP_CODE}" = "000" ]; then
|
||||||
|
echo "⚠️ Nothing answered on https://127.0.0.1 — check the LoadBalancer:"
|
||||||
|
echo " kubectl -n ingress-nginx get svc ingress-nginx-controller"
|
||||||
|
else
|
||||||
|
echo "✅ Ingress reachable (HTTP ${HTTP_CODE})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "6. Check pod readiness across all namespaces..."
|
||||||
|
|
||||||
|
sleep_interval=10
|
||||||
|
|
||||||
|
echo "Initial wait time: $((sleep_interval * 2)) seconds…"
|
||||||
|
sleep $((sleep_interval * 2))
|
||||||
|
|
||||||
|
check_pods_ready() {
|
||||||
|
local max_attempts=60 # Maximum number of attempts (10 minutes with 10s intervals)
|
||||||
|
local attempt=1
|
||||||
|
|
||||||
|
while [ $attempt -le $max_attempts ]; do
|
||||||
|
echo "Attempt $attempt/$max_attempts - Checking pod status..."
|
||||||
|
|
||||||
|
not_ready_count=$( kubectl get po -A --no-headers | grep -v -E "Running|Completed"| wc -l | tr -d ' ')
|
||||||
|
|
||||||
|
if [ "$not_ready_count" -eq 0 ]; then
|
||||||
|
echo "✅ All pods are ready!"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo "⏳ $not_ready_count pod(s) still not ready. Waiting $sleep_interval seconds…"
|
||||||
|
sleep $sleep_interval
|
||||||
|
((attempt++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "❌ Timeout: Some pods are still not ready after 10 minutes"
|
||||||
|
echo "Final pod status:"
|
||||||
|
kubectl get po -A
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if check_pods_ready; then
|
||||||
|
echo "🎉 Cluster is fully ready!"
|
||||||
|
else
|
||||||
|
echo "⚠️ Some pods may need manual intervention"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+3
-2
@@ -85,6 +85,7 @@ services:
|
|||||||
- postgresql
|
- postgresql
|
||||||
- mailcatcher
|
- mailcatcher
|
||||||
- redis
|
- redis
|
||||||
|
- livekit
|
||||||
- createbuckets
|
- createbuckets
|
||||||
- createwebhook
|
- createwebhook
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
@@ -96,7 +97,7 @@ services:
|
|||||||
celery-dev:
|
celery-dev:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
image: meet:backend-development
|
image: meet:backend-development
|
||||||
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "DEBUG", "--pool=solo"]
|
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "DEBUG"]
|
||||||
environment:
|
environment:
|
||||||
- DJANGO_CONFIGURATION=Development
|
- DJANGO_CONFIGURATION=Development
|
||||||
env_file:
|
env_file:
|
||||||
@@ -131,7 +132,7 @@ services:
|
|||||||
celery:
|
celery:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
image: meet:backend-production
|
image: meet:backend-production
|
||||||
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO", "--pool=solo"]
|
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO"]
|
||||||
environment:
|
environment:
|
||||||
- DJANGO_CONFIGURATION=Demo
|
- DJANGO_CONFIGURATION=Demo
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -143,3 +143,24 @@ $ make start-tilt-keycloak
|
|||||||
```
|
```
|
||||||
|
|
||||||
Monitor Tilt’s progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://meet.127.0.0.1.nip.io/](https://meet.127.0.0.1.nip.io/).
|
Monitor Tilt’s progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://meet.127.0.0.1.nip.io/](https://meet.127.0.0.1.nip.io/).
|
||||||
|
|
||||||
|
### Alternative: OrbStack's built-in Kubernetes (macOS)
|
||||||
|
|
||||||
|
If you use [OrbStack](https://orbstack.dev/) on macOS, you can run the stack on its built-in Kubernetes cluster instead of kind. It uses noticeably less RAM (no nested kubeadm node container) and no local registry is needed: OrbStack's cluster shares the Docker image store, so Tilt uses images directly without pushing.
|
||||||
|
|
||||||
|
Enable Kubernetes in OrbStack (Settings > Kubernetes), then:
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
$ make build-k8s-cluster-orbstack
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs ingress-nginx (exposed by OrbStack on `127.0.0.1:80/443`), the mkcert TLS certificates, and the CoreDNS rewrite for `*.127.0.0.1.nip.io`, then you start Tilt as usual:
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
$ make start-tilt-keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Ports 80/443 must be free: delete the kind cluster first if you used it (`kind delete cluster --name suite`).
|
||||||
|
- If you "Reset Kubernetes" in OrbStack, re-run `make build-k8s-cluster-orbstack`.
|
||||||
|
- kind remains the reference setup (matches CI and lets you pin the Kubernetes version).
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ ALLOW_UNREGISTERED_ROOMS=False
|
|||||||
|
|
||||||
# Recording
|
# Recording
|
||||||
RECORDING_ENABLE=True
|
RECORDING_ENABLE=True
|
||||||
RECORDING_STORAGE_EVENT_ENABLE=False
|
RECORDING_STORAGE_EVENT_ENABLE=True
|
||||||
RECORDING_STORAGE_EVENT_TOKEN=password
|
RECORDING_STORAGE_EVENT_TOKEN=password
|
||||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
||||||
SUMMARY_SERVICE_API_TOKEN=password
|
SUMMARY_SERVICE_API_TOKEN=password
|
||||||
@@ -85,10 +85,6 @@ RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
|||||||
# Telephony
|
# Telephony
|
||||||
ROOM_TELEPHONY_ENABLED=True
|
ROOM_TELEPHONY_ENABLED=True
|
||||||
|
|
||||||
# RoomKit
|
|
||||||
# ROOMKIT_ENABLED = True
|
|
||||||
# ROOMKIT_SERVER_TO_SERVER_API_TOKEN = ThisIsAnExampleKeyForDevPurposeOnly
|
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
METADATA_COLLECTOR_ENABLED=True
|
METADATA_COLLECTOR_ENABLED=True
|
||||||
|
|
||||||
|
|||||||
Generated
+5
-5
@@ -10,7 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.49.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "^26.3.4",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
@@ -9364,9 +9364,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.6",
|
"version": "26.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
|
||||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
"integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -9383,7 +9383,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5 || ^6 || ^7"
|
"typescript": "^5 || ^6"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"typescript": {
|
"typescript": {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.49.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "26.3.6",
|
"i18next": "26.3.4",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,3 @@ class AnalyticsEvent(StrEnum):
|
|||||||
|
|
||||||
# Rooms
|
# Rooms
|
||||||
ROOM_CREATED = "room_created"
|
ROOM_CREATED = "room_created"
|
||||||
|
|
||||||
# Roomkit (meeting-room SIP devices)
|
|
||||||
ROOMKIT_JOINED = "roomkit_joined"
|
|
||||||
|
|||||||
@@ -61,9 +61,6 @@ def get_frontend_configuration(request):
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
"telephony": build_telephony_config(),
|
"telephony": build_telephony_config(),
|
||||||
"resource": {
|
|
||||||
"default_access_level": settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
|
||||||
},
|
|
||||||
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class FeatureFlag:
|
|||||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||||
"addons": "ADDONS_ENABLED",
|
"addons": "ADDONS_ENABLED",
|
||||||
"application": "APPLICATION_ENABLED",
|
"application": "APPLICATION_ENABLED",
|
||||||
"roomkit": "ROOMKIT_ENABLED",
|
"user_access_token": "USER_ACCESS_TOKEN_ENABLED",
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -31,28 +31,9 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = [
|
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
|
||||||
"id",
|
|
||||||
"email",
|
|
||||||
"full_name",
|
|
||||||
"short_name",
|
|
||||||
"timezone",
|
|
||||||
"language",
|
|
||||||
"default_room_access_level",
|
|
||||||
"default_room_configuration",
|
|
||||||
]
|
|
||||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||||
|
|
||||||
def validate_default_room_configuration(self, value):
|
|
||||||
"""Validate the default room configuration against the RoomConfiguration schema."""
|
|
||||||
if value is None or value == {}:
|
|
||||||
return value
|
|
||||||
try:
|
|
||||||
RoomConfiguration.model_validate(value)
|
|
||||||
except PydanticValidationError as e:
|
|
||||||
raise serializers.ValidationError(e.errors()) from e
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class UserLightSerializer(serializers.ModelSerializer):
|
class UserLightSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize users with limited fields."""
|
"""Serialize users with limited fields."""
|
||||||
@@ -292,6 +273,11 @@ class RequestEntrySerializer(BaseValidationOnlySerializer):
|
|||||||
"""Validate request entry data."""
|
"""Validate request entry data."""
|
||||||
|
|
||||||
username = serializers.CharField(required=True)
|
username = serializers.CharField(required=True)
|
||||||
|
participant_id = serializers.UUIDField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
def validate_participant_id(self, value):
|
||||||
|
"""The id is a bearer credential: never trusted, only looked up."""
|
||||||
|
return str(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
||||||
@@ -599,3 +585,25 @@ 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."""
|
||||||
|
|
||||||
|
# todo if I can pass the max length directly to the char field
|
||||||
|
code = serializers.CharField(max_length=255, trim_whitespace=True)
|
||||||
|
|
||||||
|
def validate_code(self, value):
|
||||||
|
"""Reject codes whose length cannot match a generated one.
|
||||||
|
|
||||||
|
`secrets.token_urlsafe(nbytes)` produces (4 * nbytes + 2) // 3
|
||||||
|
url-safe characters. Checking the length against the configured
|
||||||
|
TRANSIT_CODE_NBYTES makes malformed codes fail fast with a 400,
|
||||||
|
before any cache lookup.
|
||||||
|
"""
|
||||||
|
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."""
|
||||||
@@ -42,13 +42,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
|
||||||
@@ -58,10 +59,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,
|
||||||
@@ -75,13 +80,12 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
scope = "creation_callback"
|
scope = "creation_callback"
|
||||||
|
|
||||||
|
|
||||||
class RoomKitJoinRateThrottle(MonitoredUserRateThrottle):
|
class ExchangeAccessTokenAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||||
"""Throttle the LiveKit SIP module requesting roomkit joins.
|
"""Throttle anonymous transit code exchange attempts.
|
||||||
|
|
||||||
The roomkit endpoints are authenticated as a machine user, so all requests
|
Abuse mitigation only, not a security boundary: DRF throttling is
|
||||||
share a single throttle bucket. This is not a security measure against
|
best-effort. The security of the exchange rests on the codes'
|
||||||
brute-force attacks but a guard against accidental hammering from a buggy
|
entropy and single use.
|
||||||
SIP module.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
scope = "roomkit_join"
|
scope = "exchange_access_token"
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ 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,
|
||||||
@@ -93,6 +94,7 @@ from core.services.room_roles import (
|
|||||||
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.file import process_file_deletion
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||||
@@ -229,6 +231,76 @@ 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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.
|
||||||
|
try:
|
||||||
|
user = models.User.objects.get(id=code_data["user_id"], is_active=True)
|
||||||
|
except models.User.DoesNotExist as excpt:
|
||||||
|
raise drf_exceptions.PermissionDenied(
|
||||||
|
"This account can no longer access the application."
|
||||||
|
) from excpt
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# todo - discuss wether it's the relevant scope
|
||||||
|
data = token_service.generate_jwt(
|
||||||
|
user,
|
||||||
|
"user:access",
|
||||||
|
{
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": code_data.get("client_id", "unknown"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log for auditing
|
||||||
|
logger.info(
|
||||||
|
"User access token issued from transit code: user_id=%s, client_id=%s",
|
||||||
|
user.id,
|
||||||
|
code_data.get("client_id", "unknown"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return drf_response.Response(data)
|
||||||
|
|
||||||
|
|
||||||
class RoomViewSet(
|
class RoomViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
@@ -308,27 +380,8 @@ class RoomViewSet(
|
|||||||
return drf_response.Response(serializer.data)
|
return drf_response.Response(serializer.data)
|
||||||
|
|
||||||
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."""
|
||||||
|
room = serializer.save()
|
||||||
Apply the user's default room preferences (access level and configuration)
|
|
||||||
unless the request explicitly provides its own values.
|
|
||||||
"""
|
|
||||||
user = self.request.user
|
|
||||||
save_kwargs = {}
|
|
||||||
|
|
||||||
if (
|
|
||||||
"access_level" not in serializer.validated_data
|
|
||||||
and user.default_room_access_level not in (None, "")
|
|
||||||
):
|
|
||||||
save_kwargs["access_level"] = user.default_room_access_level
|
|
||||||
|
|
||||||
user_default_configuration = user.default_room_configuration
|
|
||||||
if not serializer.validated_data.get(
|
|
||||||
"configuration"
|
|
||||||
) and user_default_configuration not in (None, {}):
|
|
||||||
save_kwargs["configuration"] = user.default_room_configuration
|
|
||||||
|
|
||||||
room = serializer.save(**save_kwargs)
|
|
||||||
models.ResourceAccess.objects.create(
|
models.ResourceAccess.objects.create(
|
||||||
resource=room,
|
resource=room,
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
@@ -518,10 +571,7 @@ class RoomViewSet(
|
|||||||
request=request,
|
request=request,
|
||||||
**serializer.validated_data,
|
**serializer.validated_data,
|
||||||
)
|
)
|
||||||
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
return drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
||||||
lobby_service.prepare_response(response, participant.id)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
detail=True,
|
detail=True,
|
||||||
|
|||||||
@@ -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,71 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
USER_ACCESS_TOKEN_TYPE_CLAIM = "user_access" # noqa: S105
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
if payload.get("token_type") != USER_ACCESS_TOKEN_TYPE_CLAIM:
|
||||||
|
logger.warning("Wrong 'token_type' in user access token payload")
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||||
|
|
||||||
|
# Every token we issue carries the client_id of the application the
|
||||||
|
# transit code was minted for: its absence means the token does not
|
||||||
|
# come from the exchange endpoint.
|
||||||
|
if not payload.get("client_id"):
|
||||||
|
logger.warning("Missing 'client_id' in user access token payload")
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||||
@@ -86,6 +86,14 @@ class HasRequiredRoomScope(BaseScopePermission):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HasRequiredUserScope(BaseScopePermission):
|
||||||
|
"""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."""
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from rest_framework import (
|
|||||||
from core import analytics, api, models
|
from core import analytics, api, models
|
||||||
from core.api.feature_flag import FeatureFlag
|
from core.api.feature_flag import FeatureFlag
|
||||||
from core.services.jwt_token import JwtTokenService
|
from core.services.jwt_token import JwtTokenService
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
|
|
||||||
from ..services.provisional_user_service import (
|
from ..services.provisional_user_service import (
|
||||||
ProvisionalUserCreationDisabledError,
|
ProvisionalUserCreationDisabledError,
|
||||||
@@ -218,3 +219,62 @@ class RoomViewSet(
|
|||||||
"$set": {"email": self.request.user.email},
|
"$set": {"email": self.request.user.email},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
ResourceServerAuthentication,
|
||||||
|
]
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
auth_method = type(request.successful_authenticator).__name__
|
||||||
|
client_id = (request.auth or {}).get("client_id", "unknown")
|
||||||
|
|
||||||
|
code = TransitCodeService().create_code(request.user, client_id=client_id)
|
||||||
|
|
||||||
|
# Log for auditing
|
||||||
|
logger.info(
|
||||||
|
"Transit code issued: user_id=%s, client_id=%s, auth_method=%s",
|
||||||
|
request.user.id,
|
||||||
|
client_id,
|
||||||
|
auth_method,
|
||||||
|
)
|
||||||
|
|
||||||
|
return drf_response.Response(
|
||||||
|
{
|
||||||
|
"transit_code": code,
|
||||||
|
"expires_in": settings.TRANSIT_CODE_TTL,
|
||||||
|
},
|
||||||
|
status=drf_status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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', '0021_recording_external_process_id_alter_recording_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 5.2.14 on 2026-08-03 13:40
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('core', '0021_recording_external_process_id_alter_recording_status'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='default_room_access_level',
|
|
||||||
field=models.CharField(blank=True, choices=[('public', 'Public Access'), ('trusted', 'Trusted Access'), ('restricted', 'Restricted Access')], help_text='Access level applied by default to new rooms created by this user. When empty, the instance default is used.', max_length=50, null=True, verbose_name='default room access level'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='default_room_configuration',
|
|
||||||
field=models.JSONField(blank=True, default=dict, help_text='Configurations applied by default to new rooms created by this user.', verbose_name='default room configuration'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -189,25 +189,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
default=settings.TIME_ZONE,
|
default=settings.TIME_ZONE,
|
||||||
help_text=_("The timezone in which the user wants to see times."),
|
help_text=_("The timezone in which the user wants to see times."),
|
||||||
)
|
)
|
||||||
default_room_access_level = models.CharField(
|
|
||||||
max_length=50,
|
|
||||||
choices=RoomAccessLevel.choices,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name=_("default room access level"),
|
|
||||||
help_text=_(
|
|
||||||
"Access level applied by default to new rooms created by this user. "
|
|
||||||
"When empty, the instance default is used."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
default_room_configuration = models.JSONField(
|
|
||||||
blank=True,
|
|
||||||
default=dict,
|
|
||||||
verbose_name=_("default room configuration"),
|
|
||||||
help_text=_(
|
|
||||||
"Configurations applied by default to new rooms created by this user."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
is_device = models.BooleanField(
|
is_device = models.BooleanField(
|
||||||
_("device"),
|
_("device"),
|
||||||
default=False,
|
default=False,
|
||||||
@@ -448,14 +429,7 @@ class Room(Resource):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Generate a unique n-digit pin code for new rooms."""
|
"""Generate a unique n-digit pin code for new rooms."""
|
||||||
|
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
|
||||||
# Roomkit devices also join by PIN, so a PIN is needed as soon as
|
|
||||||
# either integration is enabled.
|
|
||||||
if (
|
|
||||||
(settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED)
|
|
||||||
and not self.pk
|
|
||||||
and not self.pin_code
|
|
||||||
):
|
|
||||||
self.pin_code = self.generate_unique_pin_code(
|
self.pin_code = self.generate_unique_pin_code(
|
||||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||||
)
|
)
|
||||||
@@ -795,6 +769,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):
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Meet core roomkit API endpoints for meeting-room (SIP) device integration."""
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"""Authentication for the roomkit API of the Meet core app."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from rest_framework.authentication import BaseAuthentication
|
|
||||||
from rest_framework.exceptions import AuthenticationFailed
|
|
||||||
|
|
||||||
from core.recording.event.authentication import MachineUser
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ServerToServerAuthentication(BaseAuthentication):
|
|
||||||
"""Custom authentication class for roomkit server-to-server requests.
|
|
||||||
|
|
||||||
Validates the Authorization header against the roomkit server-to-server
|
|
||||||
token. A valid PIN code is intentionally not enough to authenticate: the
|
|
||||||
endpoints are restricted to the LiveKit SIP module's credentials.
|
|
||||||
"""
|
|
||||||
|
|
||||||
AUTH_HEADER = "Authorization"
|
|
||||||
TOKEN_TYPE = "Bearer" # noqa S105
|
|
||||||
|
|
||||||
def authenticate(self, request):
|
|
||||||
"""Validate the Bearer token from the Authorization header.
|
|
||||||
|
|
||||||
Returns a (MachineUser, token) pair on success, and raises
|
|
||||||
AuthenticationFailed if the header is missing, malformed, or contains
|
|
||||||
an invalid token.
|
|
||||||
"""
|
|
||||||
required_token = settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN
|
|
||||||
if not required_token:
|
|
||||||
raise AuthenticationFailed("Server-to-server token is not configured.")
|
|
||||||
|
|
||||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
|
||||||
if not auth_header:
|
|
||||||
logger.warning(
|
|
||||||
"Roomkit authentication failed: missing Authorization header (ip: %s)",
|
|
||||||
request.META.get("REMOTE_ADDR"),
|
|
||||||
)
|
|
||||||
raise AuthenticationFailed("Authorization header is missing.")
|
|
||||||
|
|
||||||
# Validate token format and existence
|
|
||||||
auth_parts = auth_header.split(" ")
|
|
||||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
|
||||||
raise AuthenticationFailed("Invalid authorization header.")
|
|
||||||
|
|
||||||
token = auth_parts[1]
|
|
||||||
|
|
||||||
# Use constant-time comparison to prevent timing attacks
|
|
||||||
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
|
||||||
logger.warning(
|
|
||||||
"Roomkit authentication failed: invalid token (ip: %s)",
|
|
||||||
request.META.get("REMOTE_ADDR"),
|
|
||||||
)
|
|
||||||
raise AuthenticationFailed("Invalid server-to-server token.")
|
|
||||||
|
|
||||||
return MachineUser(username="roomkit"), token
|
|
||||||
|
|
||||||
def authenticate_header(self, request):
|
|
||||||
"""Return the WWW-Authenticate header value."""
|
|
||||||
return f"{self.TOKEN_TYPE} realm='Roomkit server to server'"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Serializers for the roomkit API of the Meet core app."""
|
|
||||||
|
|
||||||
# pylint: disable=abstract-method
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from core.api.serializers import BaseValidationOnlySerializer
|
|
||||||
|
|
||||||
|
|
||||||
class RoomKitJoinSerializer(BaseValidationOnlySerializer):
|
|
||||||
"""Validate roomkit join requests from the LiveKit SIP module."""
|
|
||||||
|
|
||||||
pin_code = serializers.CharField(required=True)
|
|
||||||
|
|
||||||
def validate_pin_code(self, value):
|
|
||||||
"""Ensure the PIN code matches the configured length."""
|
|
||||||
if len(value) != settings.ROOM_TELEPHONY_PIN_LENGTH:
|
|
||||||
raise serializers.ValidationError("PIN code length is invalid.")
|
|
||||||
return value
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"""Roomkit API endpoints for meeting-room (SIP) device integration."""
|
|
||||||
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from rest_framework import decorators, viewsets
|
|
||||||
from rest_framework import (
|
|
||||||
exceptions as drf_exceptions,
|
|
||||||
)
|
|
||||||
from rest_framework import (
|
|
||||||
response as drf_response,
|
|
||||||
)
|
|
||||||
from rest_framework import (
|
|
||||||
status as drf_status,
|
|
||||||
)
|
|
||||||
|
|
||||||
from core import analytics, models
|
|
||||||
from core.api import permissions, throttling
|
|
||||||
from core.api.feature_flag import FeatureFlag
|
|
||||||
from core.services.sip_management import SIPException, SIPManagement
|
|
||||||
|
|
||||||
from . import authentication, serializers
|
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class RoomKitViewSet(viewsets.ViewSet):
|
|
||||||
"""Server-to-server API endpoints for the roomkit integration.
|
|
||||||
|
|
||||||
Groups all interactions between roomkit (SIP) devices and the backend,
|
|
||||||
brokered by the LiveKit SIP module. All endpoints are authenticated
|
|
||||||
with the roomkit server-to-server tokens.
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [authentication.ServerToServerAuthentication]
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
@decorators.action(
|
|
||||||
detail=False,
|
|
||||||
methods=["post"],
|
|
||||||
url_path="join",
|
|
||||||
throttle_classes=[throttling.RoomKitJoinRateThrottle],
|
|
||||||
)
|
|
||||||
@FeatureFlag.require("roomkit")
|
|
||||||
def join(self, request):
|
|
||||||
"""Prepare a room for a meeting-room (SIP) device joining by PIN code.
|
|
||||||
|
|
||||||
Called by the LiveKit SIP module when a meeting-room device dials in
|
|
||||||
with a PIN code before any WebRTC participant has joined. Resolves the
|
|
||||||
room by PIN and creates its SIP dispatch rule, so the device can enter
|
|
||||||
without waiting for a WebRTC user.
|
|
||||||
|
|
||||||
The webhook-based creation path is kept: both converge on the same rule
|
|
||||||
through the shared SIPManagement.
|
|
||||||
"""
|
|
||||||
|
|
||||||
serializer = serializers.RoomKitJoinSerializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
room = models.Room.objects.get(
|
|
||||||
pin_code=serializer.validated_data["pin_code"]
|
|
||||||
)
|
|
||||||
except models.Room.DoesNotExist as e:
|
|
||||||
raise drf_exceptions.NotFound("No room found for this PIN code.") from e
|
|
||||||
|
|
||||||
try:
|
|
||||||
created = SIPManagement().ensure_dispatch_rule(room)
|
|
||||||
except SIPException as e:
|
|
||||||
raise drf_exceptions.APIException("Could not create dispatch rule.") from e
|
|
||||||
|
|
||||||
analytics.capture(
|
|
||||||
request.user,
|
|
||||||
analytics.AnalyticsEvent.ROOMKIT_JOINED,
|
|
||||||
{
|
|
||||||
"room_id": str(room.pk),
|
|
||||||
"dispatch_rule_created": created,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Roomkit join requested: room_id=%s, dispatch_rule_created=%s",
|
|
||||||
room.id,
|
|
||||||
created,
|
|
||||||
)
|
|
||||||
|
|
||||||
return drf_response.Response(
|
|
||||||
{"status": "success"},
|
|
||||||
status=drf_status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
@@ -28,7 +28,7 @@ from .room_management import (
|
|||||||
RoomManagementException,
|
RoomManagementException,
|
||||||
RoomNotFoundException,
|
RoomNotFoundException,
|
||||||
)
|
)
|
||||||
from .sip_management import SIPException, SIPManagement
|
from .telephony import TelephonyException, TelephonyService
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ class LiveKitEventsService:
|
|||||||
)
|
)
|
||||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||||
self.lobby_service = LobbyService()
|
self.lobby_service = LobbyService()
|
||||||
self.sip_management = SIPManagement()
|
self.telephony_service = TelephonyService()
|
||||||
self.recording_events = RecordingEventsService()
|
self.recording_events = RecordingEventsService()
|
||||||
|
|
||||||
self._filter_regex = None
|
self._filter_regex = None
|
||||||
@@ -245,12 +245,12 @@ class LiveKitEventsService:
|
|||||||
except models.Room.DoesNotExist as err:
|
except models.Room.DoesNotExist as err:
|
||||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||||
|
|
||||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
if settings.ROOM_TELEPHONY_ENABLED:
|
||||||
try:
|
try:
|
||||||
self.sip_management.ensure_dispatch_rule(room)
|
self.telephony_service.create_dispatch_rule(room)
|
||||||
except SIPException as e:
|
except TelephonyException as e:
|
||||||
raise ActionFailedError(
|
raise ActionFailedError(
|
||||||
f"Failed to create sip dispatch rule for room {room_id}"
|
f"Failed to create telephony dispatch rule for room {room_id}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
def _handle_room_finished(self, data):
|
def _handle_room_finished(self, data):
|
||||||
@@ -265,12 +265,12 @@ class LiveKitEventsService:
|
|||||||
)
|
)
|
||||||
raise ActionFailedError("Failed to process room finished event") from e
|
raise ActionFailedError("Failed to process room finished event") from e
|
||||||
|
|
||||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
if settings.ROOM_TELEPHONY_ENABLED:
|
||||||
try:
|
try:
|
||||||
self.sip_management.delete_dispatch_rule(room_id)
|
self.telephony_service.delete_dispatch_rule(room_id)
|
||||||
except SIPException as e:
|
except TelephonyException as e:
|
||||||
raise ActionFailedError(
|
raise ActionFailedError(
|
||||||
f"Failed to delete sip dispatch rule for room {room_id}"
|
f"Failed to delete telephony dispatch rule for room {room_id}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -86,23 +86,6 @@ class LobbyService:
|
|||||||
"""Generate cache key for participant(s) data."""
|
"""Generate cache key for participant(s) data."""
|
||||||
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||||
|
|
||||||
@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.
|
||||||
@@ -135,6 +118,7 @@ class LobbyService:
|
|||||||
room: models.Room,
|
room: models.Room,
|
||||||
request,
|
request,
|
||||||
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.
|
||||||
|
|
||||||
@@ -149,22 +133,20 @@ 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(room.id, username)
|
||||||
|
|
||||||
room_id = str(room.id)
|
room_id = str(room.id)
|
||||||
user_role = room.get_role(request.user)
|
user_role = room.get_role(request.user)
|
||||||
|
|
||||||
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
||||||
if participant is None:
|
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||||
participant = LobbyParticipant(
|
self._save_participant(room.id, participant)
|
||||||
status=LobbyParticipantStatus.ACCEPTED,
|
|
||||||
username=username,
|
|
||||||
id=participant_id,
|
|
||||||
color=utils.generate_color(participant_id),
|
|
||||||
)
|
|
||||||
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,
|
||||||
@@ -172,18 +154,18 @@ class LobbyService:
|
|||||||
username=username,
|
username=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._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
|
||||||
@@ -193,7 +175,7 @@ class LobbyService:
|
|||||||
username=username,
|
username=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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -210,27 +192,36 @@ class LobbyService:
|
|||||||
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
||||||
)
|
)
|
||||||
|
|
||||||
def enter(
|
def _create_participant(self, room_id: UUID, username: str) -> LobbyParticipant:
|
||||||
self, room_id: UUID, participant_id: str, username: str
|
"""Create and persist a new waiting participant.
|
||||||
) -> LobbyParticipant:
|
|
||||||
"""Add participant to waiting lobby.
|
|
||||||
|
|
||||||
Create a new participant entry in waiting status and notify room
|
Participant identifiers are minted here, server-side, exclusively.
|
||||||
participants of the new entry request.
|
|
||||||
"""
|
"""
|
||||||
|
participant_id = str(uuid.uuid4())
|
||||||
color = utils.generate_color(participant_id)
|
|
||||||
|
|
||||||
participant = LobbyParticipant(
|
participant = LobbyParticipant(
|
||||||
status=LobbyParticipantStatus.WAITING,
|
status=LobbyParticipantStatus.WAITING,
|
||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color=color,
|
color=utils.generate_color(participant_id),
|
||||||
|
)
|
||||||
|
self._save_participant(room_id, participant)
|
||||||
|
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _notify_entry_request(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,
|
||||||
},
|
},
|
||||||
@@ -239,15 +230,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,
|
|
||||||
)
|
|
||||||
|
|
||||||
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]:
|
||||||
|
|||||||
+10
-38
@@ -1,9 +1,9 @@
|
|||||||
"""SIP management service for managing SIP dispatch rules for room access."""
|
"""Telephony service for managing SIP dispatch rules for room access."""
|
||||||
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit.api import TwirpError, TwirpErrorCode
|
from livekit.api import TwirpError
|
||||||
from livekit.protocol.sip import (
|
from livekit.protocol.sip import (
|
||||||
CreateSIPDispatchRuleRequest,
|
CreateSIPDispatchRuleRequest,
|
||||||
DeleteSIPDispatchRuleRequest,
|
DeleteSIPDispatchRuleRequest,
|
||||||
@@ -17,16 +17,12 @@ from core import utils
|
|||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SIPException(Exception):
|
class TelephonyException(Exception):
|
||||||
"""Exception raised when SIP operations fail."""
|
"""Exception raised when telephony operations fail."""
|
||||||
|
|
||||||
|
|
||||||
class DispatchRuleConflictError(SIPException):
|
class TelephonyService:
|
||||||
"""Raised when a dispatch rule already exists for the same routing criteria."""
|
"""Service for managing participant access through the telephony system (SIP)."""
|
||||||
|
|
||||||
|
|
||||||
class SIPManagement:
|
|
||||||
"""Service for managing SIP access through the telephony or roomkit system (SIP)."""
|
|
||||||
|
|
||||||
def _rule_name(self, room_id):
|
def _rule_name(self, room_id):
|
||||||
"""Generate the rule name for a room based on its ID."""
|
"""Generate the rule name for a room based on its ID."""
|
||||||
@@ -36,7 +32,7 @@ class SIPManagement:
|
|||||||
async def create_dispatch_rule(self, room):
|
async def create_dispatch_rule(self, room):
|
||||||
"""Create a SIP inbound dispatch rule for direct room routing.
|
"""Create a SIP inbound dispatch rule for direct room routing.
|
||||||
|
|
||||||
Configures livekit-sip to route incoming SIP calls directly to the specified room
|
Configures telephony to route incoming SIP calls directly to the specified room
|
||||||
using the room's ID and PIN code for authentication.
|
using the room's ID and PIN code for authentication.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -55,12 +51,10 @@ class SIPManagement:
|
|||||||
try:
|
try:
|
||||||
await lkapi.sip.create_sip_dispatch_rule(create=request)
|
await lkapi.sip.create_sip_dispatch_rule(create=request)
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
if e.code == TwirpErrorCode.ALREADY_EXISTS:
|
|
||||||
raise DispatchRuleConflictError("Dispatch rule already exists") from e
|
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Unexpected error creating dispatch rule for room %s", room.id
|
"Unexpected error creating dispatch rule for room %s", room.id
|
||||||
)
|
)
|
||||||
raise SIPException("Could not create dispatch rule") from e
|
raise TelephonyException("Could not create dispatch rule") from e
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
@@ -85,7 +79,7 @@ class SIPManagement:
|
|||||||
)
|
)
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
logger.exception("Failed to list dispatch rules for room %s", room_id)
|
logger.exception("Failed to list dispatch rules for room %s", room_id)
|
||||||
raise SIPException("Could not list dispatch rules") from e
|
raise TelephonyException("Could not list dispatch rules") from e
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
@@ -100,28 +94,6 @@ class SIPManagement:
|
|||||||
if existing_rule.name == rule_name
|
if existing_rule.name == rule_name
|
||||||
]
|
]
|
||||||
|
|
||||||
@async_to_sync
|
|
||||||
async def has_dispatch_rule(self, room_id):
|
|
||||||
"""Check whether at least one dispatch rule exists for a specific room."""
|
|
||||||
return bool(await self._list_dispatch_rules_ids(room_id))
|
|
||||||
|
|
||||||
def ensure_dispatch_rule(self, room):
|
|
||||||
"""Create the SIP dispatch rule for a room if it does not already exist.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if a rule was created, False if it already existed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.has_dispatch_rule(room.pk):
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.create_dispatch_rule(room)
|
|
||||||
except DispatchRuleConflictError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
@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."""
|
||||||
@@ -146,7 +118,7 @@ class SIPManagement:
|
|||||||
|
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
logger.exception("Failed to delete dispatch rules for room %s", room_id)
|
logger.exception("Failed to delete dispatch rules for room %s", room_id)
|
||||||
raise SIPException("Could not delete dispatch rules") from e
|
raise TelephonyException("Could not delete dispatch rules") from e
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
@@ -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="unknown"):
|
||||||
|
"""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
|
||||||
@@ -1,11 +1,4 @@
|
|||||||
"""
|
|
||||||
Celery task decorator that degrades to a synchronous call when Celery is off.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# The Celery app is imported lazily so that importing this module does not pull
|
|
||||||
# in Celery when CELERY_ENABLED is false.
|
|
||||||
# ruff: noqa: PLC0415
|
# ruff: noqa: PLC0415
|
||||||
# pylint: disable=import-outside-toplevel
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for the roomkit API of the Meet core app."""
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
"""
|
|
||||||
Test the roomkit join server-to-server API endpoint.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name,unused-argument
|
|
||||||
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ...factories import RoomFactory
|
|
||||||
from ...services.sip_management import SIPException
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_anonymous(mock_sip_management, settings, client):
|
|
||||||
"""Requests without an Authorization header should be rejected."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post("/api/v1.0/roomkit/join/", {"pin_code": room.pin_code})
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert response.json() == {"detail": "Authorization header is missing."}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_malformed_authorization_header(mock_sip_management, settings, client):
|
|
||||||
"""Requests with a malformed Authorization header should be rejected."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert response.json() == {"detail": "Invalid authorization header."}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_wrong_bearer(mock_sip_management, settings, client):
|
|
||||||
"""Requests with an incorrect bearer token should be rejected."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert response.json() == {"detail": "Invalid server-to-server token."}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_token_not_configured(mock_sip_management, settings, client):
|
|
||||||
"""Requests should be rejected when no server-to-server token is configured."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = None
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_roomkit_disabled(mock_sip_management, settings, client):
|
|
||||||
"""The endpoint should not be exposed when the roomkit integration is disabled."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = False
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_missing_pin(mock_sip_management, settings, client):
|
|
||||||
"""Requests without a PIN code should be rejected."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json() == {"pin_code": ["This field is required."]}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_blank_pin(mock_sip_management, settings, client):
|
|
||||||
"""Requests with a blank PIN code should be rejected."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": ""},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json() == {"pin_code": ["This field may not be blank."]}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_wrong_pin_length(mock_sip_management, settings, client):
|
|
||||||
"""Requests with a PIN code of unexpected length should be rejected."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 10
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": "123"},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json() == {"pin_code": ["PIN code length is invalid."]}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_unknown_pin(mock_sip_management, settings, client):
|
|
||||||
"""Requests with a PIN matching no room should return 404 and create no rule."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": "0987654321"},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert response.json() == {"detail": "No room found for this PIN code."}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_success(mock_sip_management, settings, client):
|
|
||||||
"""Requests with a valid PIN should create the dispatch rule."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.return_value = True
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"status": "success"}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_called_once_with(room)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_dispatch_rule_already_exists(mock_sip_management, settings, client):
|
|
||||||
"""Requests should succeed when the dispatch rule already exists (idempotency)."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.return_value = False
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"status": "success"}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_called_once_with(room)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.analytics.capture")
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_tracks_analytics_event(
|
|
||||||
mock_sip_management, mock_capture, settings, client
|
|
||||||
):
|
|
||||||
"""Successful joins should be tracked with an analytics event."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.return_value = True
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
mock_capture.assert_called_once()
|
|
||||||
_user, event, properties = mock_capture.call_args[0]
|
|
||||||
assert str(event) == "roomkit_joined"
|
|
||||||
assert properties == {
|
|
||||||
"room_id": str(room.pk),
|
|
||||||
"dispatch_rule_created": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.analytics.capture")
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_sip_failure(mock_sip_management, mock_capture, settings, client):
|
|
||||||
"""Requests should fail with a server error when the sip management service fails."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.side_effect = SIPException(
|
|
||||||
"Could not create dispatch rule"
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
raise_request_exception=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 500
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_called_once_with(room)
|
|
||||||
mock_capture.assert_not_called()
|
|
||||||
@@ -2,15 +2,19 @@
|
|||||||
Test rooms API endpoints in the Meet core app: create.
|
Test rooms API endpoints in the Meet core app: create.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name,unused-argument
|
# pylint: disable=redefined-outer-name,unused-argument
|
||||||
from django.conf import settings
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
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 Room, RoomAccessLevel
|
from ...models import Room
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -112,203 +116,36 @@ 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_user_default_access_level():
|
def generate_user_access_token(user):
|
||||||
"""
|
"""Generate a valid user access JWT signed with the token secret."""
|
||||||
The user's default room access level should be applied to the new room
|
now = datetime.now(timezone.utc)
|
||||||
when the request does not provide one.
|
|
||||||
"""
|
payload = {
|
||||||
user = UserFactory(default_room_access_level=RoomAccessLevel.RESTRICTED)
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": "test-app",
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 = APIClient()
|
||||||
client.force_login(user)
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
response = client.post("/api/v1.0/rooms/", {"name": "my room"})
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
room = Room.objects.get()
|
room = Room.objects.get()
|
||||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
assert room.accesses.filter(role="owner", user=user).exists()
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_explicit_access_level_overrides_default():
|
|
||||||
"""
|
|
||||||
An access level explicitly provided in the request should take precedence
|
|
||||||
over the user's default room access level.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_access_level=RoomAccessLevel.RESTRICTED)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
"access_level": RoomAccessLevel.TRUSTED,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.access_level == RoomAccessLevel.TRUSTED
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_no_user_default_access_level():
|
|
||||||
"""
|
|
||||||
When the user has no default room access level, the instance default
|
|
||||||
should be applied to the new room.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_access_level=None)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_user_default_configuration():
|
|
||||||
"""
|
|
||||||
The user's default room configuration should be applied to the new room
|
|
||||||
when the request does not provide one.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_configuration={"everyone_can_mute": False})
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.configuration == {"everyone_can_mute": False}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_explicit_configuration_overrides_default():
|
|
||||||
"""
|
|
||||||
A configuration explicitly provided in the request should take precedence
|
|
||||||
over the user's default room configuration.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_configuration={"everyone_can_mute": False})
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_empty_configuration_falls_back_to_default():
|
|
||||||
"""
|
|
||||||
An empty configuration in the request should not be considered an explicit
|
|
||||||
value: the user's default room configuration should still be applied.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_configuration={"everyone_can_mute": True})
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
"configuration": {},
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.configuration == {"everyone_can_mute": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_empty_user_default_configuration():
|
|
||||||
"""
|
|
||||||
When the user's default room configuration is empty, the new room should
|
|
||||||
keep its default empty configuration.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_configuration={})
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.configuration == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_request_precedence_over_user_empty():
|
|
||||||
"""
|
|
||||||
When the user's default room configuration is empty, the request should take precedence.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_configuration={})
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{"name": "my room", "configuration": {"everyone_can_mute": True}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.configuration == {"everyone_can_mute": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_blank_user_default_access_level():
|
|
||||||
"""
|
|
||||||
A blank default room access level (stored as an empty string) should be
|
|
||||||
treated as unset: the instance default should be applied to the new room
|
|
||||||
instead of persisting an invalid empty access level.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_access_level="")
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
Test rooms API endpoints in the Meet core app: list.
|
Test rooms API endpoints in the Meet core app: list.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
@@ -156,3 +160,40 @@ 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 generate_user_access_token(user):
|
||||||
|
"""Generate a valid user access JWT signed with the token secret."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": "test-app",
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ 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 (
|
|
||||||
LobbyService,
|
|
||||||
)
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -29,7 +26,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
|
||||||
@@ -47,11 +43,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() == {
|
||||||
@@ -78,7 +73,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
|
||||||
@@ -96,11 +90,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() == {
|
||||||
@@ -127,7 +120,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
|
||||||
@@ -168,11 +160,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 +188,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
|
||||||
@@ -206,9 +196,7 @@ 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("core.services.lobby.uuid.uuid4", return_value="123"),
|
||||||
LobbyService, "_get_or_create_participant_id", return_value="123"
|
|
||||||
),
|
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||||
),
|
),
|
||||||
@@ -221,11 +209,6 @@ def test_request_entry_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 == "123"
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": "123",
|
"id": "123",
|
||||||
@@ -235,9 +218,10 @@ def test_request_entry_public_room(settings):
|
|||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Verify lobby cache is still empty after the request
|
# The accepted participant is persisted, out of the waiting list
|
||||||
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 len(lobby_keys) == 1
|
||||||
|
assert cache.get(lobby_keys[0])["status"] == "accepted"
|
||||||
|
|
||||||
|
|
||||||
def test_request_entry_authenticated_user_public_room(settings):
|
def test_request_entry_authenticated_user_public_room(settings):
|
||||||
@@ -247,7 +231,6 @@ def test_request_entry_authenticated_user_public_room(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
|
||||||
@@ -256,9 +239,8 @@ def test_request_entry_authenticated_user_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,
|
"core.services.lobby.uuid.uuid4",
|
||||||
"_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(
|
||||||
@@ -273,11 +255,6 @@ 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": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
@@ -287,9 +264,10 @@ 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
|
# The accepted participant is persisted, out of the waiting list
|
||||||
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 len(lobby_keys) == 1
|
||||||
|
assert cache.get(lobby_keys[0])["status"] == "accepted"
|
||||||
|
|
||||||
|
|
||||||
def test_request_entry_waiting_participant_public_room(settings):
|
def test_request_entry_waiting_participant_public_room(settings):
|
||||||
@@ -297,7 +275,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
|
||||||
@@ -311,9 +288,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Simulate a browser with existing participant cookie
|
# Simulate a returning participant echoing its identifier
|
||||||
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
|
|
||||||
|
|
||||||
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(
|
||||||
@@ -322,16 +297,14 @@ 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": "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": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
@@ -637,15 +610,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(
|
||||||
@@ -654,9 +626,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/",
|
||||||
@@ -670,34 +639,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 = 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
|
||||||
@@ -716,7 +683,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(
|
||||||
@@ -737,3 +703,124 @@ 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):
|
||||||
|
"""An identifier unknown to the room's lobby must not be honored."""
|
||||||
|
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": forged_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 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(returned_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_entry_malformed_participant_id(settings):
|
||||||
|
"""A non-UUID identifier is rejected by the serializer 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()
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ 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 RoomFactory, UserFactory, UserResourceAccessFactory
|
||||||
from core.services.lobby import LobbyService
|
from core.services.lobby import (
|
||||||
|
LobbyParticipant,
|
||||||
|
LobbyParticipantStatus,
|
||||||
|
LobbyService,
|
||||||
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -87,7 +91,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 +117,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 +157,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 +304,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 +334,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 +365,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 +385,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 +416,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 +444,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 +473,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 +853,15 @@ 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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Accept participant
|
# Accept participant
|
||||||
LobbyService().handle_participant_entry(room.id, participant_identity, True)
|
LobbyService().handle_participant_entry(room.id, participant_identity, True)
|
||||||
@@ -1020,3 +1032,6 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
|
# todo - try to pass another scheme to make sure it defers to the next auth
|
||||||
|
|||||||
@@ -69,7 +69,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 +87,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 +107,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 +126,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 +140,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 +156,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 +180,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 +198,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 +220,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 +240,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 +260,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 +277,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 +295,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 +312,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 +327,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 +338,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 +351,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 +382,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 +397,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
|
||||||
@@ -382,7 +419,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
|
||||||
@@ -402,7 +439,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
|
||||||
@@ -419,7 +456,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
|
||||||
@@ -436,7 +473,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
|
||||||
@@ -462,7 +499,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
|
||||||
@@ -476,7 +513,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
|
||||||
@@ -490,7 +527,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
|
||||||
@@ -504,7 +541,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
|
||||||
@@ -519,7 +559,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
|
||||||
@@ -536,7 +579,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
|
||||||
@@ -550,7 +593,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
|
||||||
@@ -565,10 +611,16 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
|
# todo - try to pass another scheme to make sure it defers to the next auth
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ Test rooms API endpoints in the Meet core app: retrieve.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
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
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
@@ -453,8 +456,6 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
{
|
{
|
||||||
"id": str(other_user_access.id),
|
"id": str(other_user_access.id),
|
||||||
"user": {
|
"user": {
|
||||||
"default_room_access_level": None,
|
|
||||||
"default_room_configuration": {},
|
|
||||||
"id": str(other_user_access.user.id),
|
"id": str(other_user_access.user.id),
|
||||||
"email": other_user_access.user.email,
|
"email": other_user_access.user.email,
|
||||||
"full_name": other_user_access.user.full_name,
|
"full_name": other_user_access.user.full_name,
|
||||||
@@ -468,8 +469,6 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
{
|
{
|
||||||
"id": str(user_access.id),
|
"id": str(user_access.id),
|
||||||
"user": {
|
"user": {
|
||||||
"default_room_access_level": None,
|
|
||||||
"default_room_configuration": {},
|
|
||||||
"id": str(user_access.user.id),
|
"id": str(user_access.user.id),
|
||||||
"email": user_access.user.email,
|
"email": user_access.user.email,
|
||||||
"full_name": user_access.user.full_name,
|
"full_name": user_access.user.full_name,
|
||||||
@@ -507,3 +506,40 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
role=str(user_access.role),
|
role=str(user_access.role),
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_user_access_token(user):
|
||||||
|
"""Generate a valid user access JWT signed with the token secret."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": "test-app",
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
# Authenticated as the owner: privileged fields are included
|
||||||
|
assert response.data["pin_code"] == room.pin_code
|
||||||
|
|||||||
@@ -110,7 +110,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
|
||||||
@@ -128,7 +128,7 @@ def test_start_subtitle_disabled_by_default(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 == 404
|
assert response.status_code == 404
|
||||||
@@ -148,7 +148,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
|
||||||
@@ -178,7 +178,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
|
||||||
@@ -198,7 +198,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
|
||||||
@@ -219,10 +219,13 @@ 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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# todo - try to pass another scheme to make sure it defers to the next auth
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ Test rooms API endpoints in the Meet core app: update.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
@@ -437,3 +441,45 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
|
|||||||
"configuration": {"can_publish_sources": ["camera"]},
|
"configuration": {"can_publish_sources": ["camera"]},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_user_access_token(user):
|
||||||
|
"""Generate a valid user access JWT signed with the token secret."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": "test-app",
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_update_authenticated_with_user_access_token():
|
||||||
|
"""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 can
|
||||||
|
room.accesses.filter(user=user).update(role="administrator")
|
||||||
|
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"
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ from core.services.livekit_events import (
|
|||||||
)
|
)
|
||||||
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
|
||||||
from core.services.sip_management import (
|
from core.services.telephony import TelephonyException, TelephonyService
|
||||||
SIPException,
|
|
||||||
SIPManagement,
|
|
||||||
)
|
|
||||||
from core.utils import NotificationError
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -62,7 +59,7 @@ def test_initialization(
|
|||||||
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
||||||
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
||||||
assert isinstance(service.lobby_service, LobbyService)
|
assert isinstance(service.lobby_service, LobbyService)
|
||||||
assert isinstance(service.sip_management, SIPManagement)
|
assert isinstance(service.telephony_service, TelephonyService)
|
||||||
assert isinstance(service.recording_events, RecordingEventsService)
|
assert isinstance(service.recording_events, RecordingEventsService)
|
||||||
|
|
||||||
|
|
||||||
@@ -75,12 +72,11 @@ def test_initialization(
|
|||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_success( # noqa: PLR0913 # pylint: disable=too-many-arguments, too-many-positional-arguments
|
def test_handle_egress_ended_success(
|
||||||
mock_update_metadata, mock_notify, mode, notification_type, service, settings
|
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||||
):
|
):
|
||||||
"""Should successfully stop recording and notifies all participant."""
|
"""Should successfully stop recording and notifies all participant."""
|
||||||
|
|
||||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
@@ -159,12 +155,11 @@ def test_handle_egress_updated_non_handled(
|
|||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_metadata_update_fails( # noqa: PLR0913 # pylint: disable=too-many-arguments, too-many-positional-arguments
|
def test_handle_egress_ended_metadata_update_fails(
|
||||||
mock_update_metadata, mock_notify, mode, notification_type, service, settings
|
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||||
):
|
):
|
||||||
"""Should successfully stop and save recording when metadata's update fails."""
|
"""Should successfully stop and save recording when metadata's update fails."""
|
||||||
|
|
||||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
@@ -474,11 +469,11 @@ def test_handle_egress_ended_ignores_non_savable_recording(
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||||
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||||
):
|
):
|
||||||
"""Should clear lobby cache and delete SIP dispatch rule when room finishes."""
|
"""Should clear lobby cache and delete telephony dispatch rule when room finishes."""
|
||||||
settings.ROOM_TELEPHONY_ENABLED = True
|
settings.ROOM_TELEPHONY_ENABLED = True
|
||||||
mock_room_name = uuid.uuid4()
|
mock_room_name = uuid.uuid4()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
@@ -491,31 +486,12 @@ def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||||
def test_handle_room_finished_deletes_dispatch_rule_when_only_roomkit_enabled(
|
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
|
||||||
):
|
|
||||||
"""Should delete dispatch rule when only roomkit is enabled when room finishes."""
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
mock_room_name = uuid.uuid4()
|
|
||||||
mock_data = mock.MagicMock()
|
|
||||||
mock_data.room.name = str(mock_room_name)
|
|
||||||
|
|
||||||
service._handle_room_finished(mock_data)
|
|
||||||
|
|
||||||
mock_delete_dispatch_rule.assert_called_once_with(mock_room_name)
|
|
||||||
mock_clear_cache.assert_called_once_with(mock_room_name)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
|
||||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
|
||||||
def test_handle_room_finished_skips_telephony_when_disabled(
|
def test_handle_room_finished_skips_telephony_when_disabled(
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||||
):
|
):
|
||||||
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
|
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
settings.ROOMKIT_ENABLED = False
|
|
||||||
mock_room_name = uuid.uuid4()
|
mock_room_name = uuid.uuid4()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.room.name = str(mock_room_name)
|
mock_data.room.name = str(mock_room_name)
|
||||||
@@ -529,7 +505,7 @@ def test_handle_room_finished_skips_telephony_when_disabled(
|
|||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
|
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
|
||||||
)
|
)
|
||||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||||
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||||
):
|
):
|
||||||
@@ -552,9 +528,9 @@ def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
|||||||
|
|
||||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
SIPManagement,
|
TelephonyService,
|
||||||
"delete_dispatch_rule",
|
"delete_dispatch_rule",
|
||||||
side_effect=SIPException("Test error"),
|
side_effect=TelephonyException("Test error"),
|
||||||
)
|
)
|
||||||
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||||
@@ -565,7 +541,7 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
|||||||
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
expected_error = (
|
expected_error = (
|
||||||
"Failed to delete sip dispatch rule for room "
|
"Failed to delete telephony dispatch rule for room "
|
||||||
"00000000-0000-0000-0000-000000000000"
|
"00000000-0000-0000-0000-000000000000"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -586,11 +562,11 @@ def test_handle_room_finished_raises_error_for_invalid_room_name(service):
|
|||||||
service._handle_room_finished(mock_data)
|
service._handle_room_finished(mock_data)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||||
def test_handle_room_started_creates_dispatch_rule_successfully(
|
def test_handle_room_started_creates_dispatch_rule_successfully(
|
||||||
mock_ensure_dispatch_rule, service, settings
|
mock_create_dispatch_rule, service, settings
|
||||||
):
|
):
|
||||||
"""Should ensure the SIP dispatch rule exists when room starts successfully."""
|
"""Should create telephony dispatch rule when room starts successfully."""
|
||||||
settings.ROOM_TELEPHONY_ENABLED = True
|
settings.ROOM_TELEPHONY_ENABLED = True
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
@@ -598,75 +574,22 @@ def test_handle_room_started_creates_dispatch_rule_successfully(
|
|||||||
|
|
||||||
service._handle_room_started(mock_data)
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
mock_create_dispatch_rule.assert_called_once_with(room)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||||
def test_handle_room_started_creates_dispatch_rule_when_only_roomkit_enabled(
|
|
||||||
mock_ensure_dispatch_rule, service, settings
|
|
||||||
):
|
|
||||||
"""Should ensure the dispatch rule exists when only roomkit is enabled during room start."""
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
room = RoomFactory()
|
|
||||||
mock_data = mock.MagicMock()
|
|
||||||
mock_data.room.name = str(room.id)
|
|
||||||
|
|
||||||
service._handle_room_started(mock_data)
|
|
||||||
|
|
||||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule", return_value=False)
|
|
||||||
def test_handle_room_started_ignores_existing_dispatch_rule(
|
|
||||||
mock_ensure_dispatch_rule, service, settings
|
|
||||||
):
|
|
||||||
"""Should proceed silently when the dispatch rule already exists when room starts."""
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = True
|
|
||||||
room = RoomFactory()
|
|
||||||
mock_data = mock.MagicMock()
|
|
||||||
mock_data.room.name = str(room.id)
|
|
||||||
|
|
||||||
# ensure_dispatch_rule reports the rule as pre-existing: nothing to raise
|
|
||||||
service._handle_room_started(mock_data)
|
|
||||||
|
|
||||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(
|
|
||||||
SIPManagement,
|
|
||||||
"ensure_dispatch_rule",
|
|
||||||
side_effect=SIPException("Test error"),
|
|
||||||
)
|
|
||||||
def test_handle_room_started_raises_error_when_dispatch_rule_creation_fails(
|
|
||||||
mock_ensure_dispatch_rule, service, settings
|
|
||||||
):
|
|
||||||
"""Should raise ActionFailedError when ensuring the dispatch rule fails when room starts."""
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = True
|
|
||||||
room = RoomFactory()
|
|
||||||
mock_data = mock.MagicMock()
|
|
||||||
mock_data.room.name = str(room.id)
|
|
||||||
|
|
||||||
expected_error = f"Failed to create sip dispatch rule for room {room.id}"
|
|
||||||
|
|
||||||
with pytest.raises(ActionFailedError, match=expected_error):
|
|
||||||
service._handle_room_started(mock_data)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
|
||||||
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
||||||
mock_ensure_dispatch_rule, service, settings
|
mock_create_dispatch_rule, service, settings
|
||||||
):
|
):
|
||||||
"""Should skip ensuring the SIP dispatch rule when telephony is disabled during room start."""
|
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
settings.ROOMKIT_ENABLED = False
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.room.name = str(room.id)
|
mock_data.room.name = str(room.id)
|
||||||
|
|
||||||
service._handle_room_started(mock_data)
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
mock_ensure_dispatch_rule.assert_not_called()
|
mock_create_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ Test lobby service.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=W0621,W0613, W0212, R0913
|
# pylint: disable=W0621,W0613, W0212, R0913
|
||||||
# ruff: noqa: PLR0913
|
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -11,7 +10,6 @@ from unittest import mock
|
|||||||
from django.conf import settings
|
from django.conf import 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
|
||||||
|
|
||||||
@@ -135,59 +133,6 @@ def test_get_cache_key(lobby_service, 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)
|
||||||
@@ -266,11 +211,12 @@ def test_request_entry_public_room(
|
|||||||
color="#123456",
|
color="#123456",
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
||||||
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, request, 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"}
|
||||||
@@ -304,11 +250,12 @@ def test_request_entry_trusted_room(
|
|||||||
color="#123456",
|
color="#123456",
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
||||||
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, request, 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"}
|
||||||
@@ -325,18 +272,19 @@ def test_request_entry_trusted_room(
|
|||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.lobby.LobbyService.enter")
|
@mock.patch("core.services.lobby.LobbyService._notify_entry_request")
|
||||||
|
@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
|
||||||
|
one is unknown to the lobby and therefore discarded - and the room is
|
||||||
|
notified of the entry request."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
|
||||||
request.user = AnonymousUser()
|
request.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(
|
||||||
@@ -345,14 +293,20 @@ def test_request_entry_new_participant(
|
|||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
)
|
)
|
||||||
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, request, 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(room.id, 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")
|
||||||
@@ -361,7 +315,6 @@ def test_request_entry_waiting_participant(
|
|||||||
):
|
):
|
||||||
"""Test requesting entry for a waiting participant."""
|
"""Test requesting entry for a waiting participant."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
@@ -372,10 +325,11 @@ def test_request_entry_waiting_participant(
|
|||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
)
|
)
|
||||||
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, request, 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
|
||||||
@@ -390,7 +344,6 @@ def test_request_entry_accepted_participant(
|
|||||||
"""Test requesting entry for an accepted participant."""
|
"""Test requesting entry for an accepted participant."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
@@ -400,12 +353,13 @@ def test_request_entry_accepted_participant(
|
|||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
|
||||||
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, request, 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"}
|
||||||
@@ -428,7 +382,6 @@ def test_request_entry_participant_with_role(
|
|||||||
"""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()
|
request = mock.Mock()
|
||||||
request.user = UserFactory()
|
request.user = UserFactory()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
@@ -440,12 +393,13 @@ def test_request_entry_participant_with_role(
|
|||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
|
||||||
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, request, 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"}
|
||||||
@@ -472,73 +426,47 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_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")
|
def test_create_participant(
|
||||||
def test_enter_success(
|
|
||||||
mock_notify,
|
|
||||||
mock_generate_color,
|
mock_generate_color,
|
||||||
mock_cache,
|
mock_cache,
|
||||||
lobby_service,
|
lobby_service,
|
||||||
participant_id,
|
|
||||||
username,
|
username,
|
||||||
|
settings,
|
||||||
):
|
):
|
||||||
"""Test successful participant entry."""
|
"""A created participant is waiting, colored, and persisted."""
|
||||||
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(room.id, 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"
|
||||||
|
|
||||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
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=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
mock_notify.assert_called_once_with(
|
|
||||||
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# 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")
|
||||||
def test_enter_with_notification_error(
|
def test_notify_entry_request_with_notification_error(mock_notify, lobby_service):
|
||||||
mock_notify,
|
"""A notification error must not break the entry request flow."""
|
||||||
mock_generate_color,
|
|
||||||
mock_cache,
|
|
||||||
lobby_service,
|
|
||||||
participant_id,
|
|
||||||
username,
|
|
||||||
):
|
|
||||||
"""Test participant entry with notification error."""
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+35
-162
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Test SIP mamagement service.
|
Test telephony service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=W0212
|
# pylint: disable=W0212
|
||||||
@@ -20,11 +20,7 @@ from livekit.protocol.sip import (
|
|||||||
|
|
||||||
from core.factories import RoomFactory
|
from core.factories import RoomFactory
|
||||||
from core.models import RoomAccessLevel
|
from core.models import RoomAccessLevel
|
||||||
from core.services.sip_management import (
|
from core.services.telephony import TelephonyException, TelephonyService
|
||||||
DispatchRuleConflictError,
|
|
||||||
SIPException,
|
|
||||||
SIPManagement,
|
|
||||||
)
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -39,9 +35,9 @@ def create_mock_livekit_client():
|
|||||||
|
|
||||||
def test_rule_name():
|
def test_rule_name():
|
||||||
"""Test rule name generation."""
|
"""Test rule name generation."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
rule_name = sip_management._rule_name(room.id)
|
rule_name = telephony_service._rule_name(room.id)
|
||||||
|
|
||||||
assert rule_name == f"SIP_{str(room.id)}"
|
assert rule_name == f"SIP_{str(room.id)}"
|
||||||
|
|
||||||
@@ -49,14 +45,14 @@ def test_rule_name():
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_create_dispatch_rule_success(mock_client_factory):
|
def test_create_dispatch_rule_success(mock_client_factory):
|
||||||
"""Test successful dispatch rule creation."""
|
"""Test successful dispatch rule creation."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
mock_api = create_mock_livekit_client()
|
||||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
sip_management.create_dispatch_rule(room)
|
telephony_service.create_dispatch_rule(room)
|
||||||
|
|
||||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||||
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
|
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
|
||||||
@@ -71,7 +67,7 @@ def test_create_dispatch_rule_success(mock_client_factory):
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_create_dispatch_rule_api_failure(mock_client_factory):
|
def test_create_dispatch_rule_api_failure(mock_client_factory):
|
||||||
"""Test dispatch rule creation when API fails."""
|
"""Test dispatch rule creation when API fails."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
mock_api = create_mock_livekit_client()
|
||||||
@@ -80,8 +76,8 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(SIPException, match="Could not create dispatch rule"):
|
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
|
||||||
sip_management.create_dispatch_rule(room)
|
telephony_service.create_dispatch_rule(room)
|
||||||
|
|
||||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
@@ -90,7 +86,7 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_list_dispatch_rules_ids_success(mock_client_factory):
|
def test_list_dispatch_rules_ids_success(mock_client_factory):
|
||||||
"""Test successful listing of dispatch rule IDs."""
|
"""Test successful listing of dispatch rule IDs."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_rules = [
|
mock_rules = [
|
||||||
@@ -115,7 +111,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||||
|
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
assert "rule-1" in result
|
assert "rule-1" in result
|
||||||
@@ -131,7 +127,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
||||||
"""Test listing dispatch rule IDs when no rules exist."""
|
"""Test listing dispatch rule IDs when no rules exist."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
mock_api = create_mock_livekit_client()
|
||||||
@@ -140,7 +136,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == []
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
@@ -149,7 +145,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
||||||
"""Test listing dispatch rule IDs when no rules match the room."""
|
"""Test listing dispatch rule IDs when no rules match the room."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_rules = [
|
mock_rules = [
|
||||||
@@ -167,7 +163,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == []
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
@@ -176,7 +172,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
||||||
"""Test listing dispatch rule IDs when API fails."""
|
"""Test listing dispatch rule IDs when API fails."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
mock_api = create_mock_livekit_client()
|
||||||
@@ -185,34 +181,34 @@ def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(SIPException, match="Could not list dispatch rules"):
|
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
|
||||||
async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||||
|
|
||||||
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
|
||||||
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.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting dispatch rules when no rules exist."""
|
"""Test deleting dispatch rules when no rules exist."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = []
|
mock_list_rules.return_value = []
|
||||||
|
|
||||||
result = sip_management.delete_dispatch_rule(room.id)
|
result = telephony_service.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
mock_list_rules.assert_called_once_with(room.id)
|
mock_list_rules.assert_called_once_with(room.id)
|
||||||
mock_client_factory.assert_not_called()
|
mock_client_factory.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting a single dispatch rule."""
|
"""Test deleting a single dispatch rule."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = ["rule-1"]
|
mock_list_rules.return_value = ["rule-1"]
|
||||||
@@ -220,7 +216,7 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
|||||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
result = sip_management.delete_dispatch_rule(room.id)
|
result = telephony_service.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||||
@@ -230,11 +226,11 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
|||||||
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.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting multiple dispatch rules."""
|
"""Test deleting multiple dispatch rules."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
||||||
@@ -242,7 +238,7 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
|
|||||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
result = sip_management.delete_dispatch_rule(room.id)
|
result = telephony_service.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
|
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
|
||||||
@@ -257,11 +253,11 @@ 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.services.telephony.TelephonyService._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):
|
||||||
"""Test deleting multiple dispatch rules when one deletion fails."""
|
"""Test deleting multiple dispatch rules when one deletion fails."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
||||||
@@ -281,18 +277,18 @@ def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rul
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||||
sip_management.delete_dispatch_rule(room.id)
|
telephony_service.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
|
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
|
||||||
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.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting dispatch rules when API fails immediately."""
|
"""Test deleting dispatch rules when API fails immediately."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = ["rule-1"]
|
mock_list_rules.return_value = ["rule-1"]
|
||||||
@@ -302,131 +298,8 @@ def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||||
sip_management.delete_dispatch_rule(room.id)
|
telephony_service.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_create_dispatch_rule_conflict_raises_dedicated_error(mock_client_factory):
|
|
||||||
"""Test that a LiveKit conflict error raises DispatchRuleConflictError."""
|
|
||||||
sip_management = SIPManagement()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
|
||||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
side_effect=TwirpError(
|
|
||||||
msg=(
|
|
||||||
"Dispatch rule for the same trunk, inbound number, number, and "
|
|
||||||
"PIN combination already exists in dispatch rule"
|
|
||||||
),
|
|
||||||
code="already_exists",
|
|
||||||
status=409,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
mock_client_factory.return_value = mock_api
|
|
||||||
|
|
||||||
with pytest.raises(DispatchRuleConflictError):
|
|
||||||
sip_management.create_dispatch_rule(room)
|
|
||||||
|
|
||||||
mock_api.aclose.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_ensure_dispatch_rule_creates_when_missing(mock_client_factory):
|
|
||||||
"""Test that ensure_dispatch_rule creates the rule when none exists."""
|
|
||||||
sip_management = SIPManagement()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
|
||||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
return_value=ListSIPDispatchRuleResponse(items=[])
|
|
||||||
)
|
|
||||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
|
||||||
mock_client_factory.return_value = mock_api
|
|
||||||
|
|
||||||
created = sip_management.ensure_dispatch_rule(room)
|
|
||||||
|
|
||||||
assert created is True
|
|
||||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
|
||||||
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
|
|
||||||
|
|
||||||
assert isinstance(create_request, CreateSIPDispatchRuleRequest)
|
|
||||||
assert create_request.name == f"SIP_{str(room.id)}"
|
|
||||||
assert create_request.rule.dispatch_rule_direct.room_name == str(room.id)
|
|
||||||
assert create_request.rule.dispatch_rule_direct.pin == str(room.pin_code)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_ensure_dispatch_rule_skips_when_existing(mock_client_factory):
|
|
||||||
"""Test that ensure_dispatch_rule is idempotent when the rule already exists."""
|
|
||||||
sip_management = SIPManagement()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
|
||||||
|
|
||||||
existing_rule = SIPDispatchRuleInfo(
|
|
||||||
sip_dispatch_rule_id="rule-1", name=f"SIP_{str(room.id)}"
|
|
||||||
)
|
|
||||||
mock_api = create_mock_livekit_client()
|
|
||||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
return_value=ListSIPDispatchRuleResponse(items=[existing_rule])
|
|
||||||
)
|
|
||||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
|
||||||
mock_client_factory.return_value = mock_api
|
|
||||||
|
|
||||||
created = sip_management.ensure_dispatch_rule(room)
|
|
||||||
|
|
||||||
assert created is False
|
|
||||||
mock_api.sip.create_sip_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_ensure_dispatch_rule_returns_false_on_conflict(mock_client_factory):
|
|
||||||
"""Test that ensure_dispatch_rule tolerates a concurrent rule creation.
|
|
||||||
|
|
||||||
If the rule is created by a concurrent caller (e.g. the LiveKit webhook)
|
|
||||||
between the existence check and the creation, LiveKit rejects the
|
|
||||||
duplicate and ensure_dispatch_rule reports the rule as already existing.
|
|
||||||
"""
|
|
||||||
sip_management = SIPManagement()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
|
||||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
return_value=ListSIPDispatchRuleResponse(items=[])
|
|
||||||
)
|
|
||||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
side_effect=TwirpError(
|
|
||||||
msg=(
|
|
||||||
"Dispatch rule for the same trunk, inbound number, number, and "
|
|
||||||
"PIN combination already exists in dispatch rule"
|
|
||||||
),
|
|
||||||
code="already_exists",
|
|
||||||
status=409,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
mock_client_factory.return_value = mock_api
|
|
||||||
|
|
||||||
created = sip_management.ensure_dispatch_rule(room)
|
|
||||||
|
|
||||||
assert created is False
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_ensure_dispatch_rule_raises_on_other_failures(mock_client_factory):
|
|
||||||
"""Test that ensure_dispatch_rule propagates unexpected LiveKit failures."""
|
|
||||||
sip_management = SIPManagement()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
|
||||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
return_value=ListSIPDispatchRuleResponse(items=[])
|
|
||||||
)
|
|
||||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500)
|
|
||||||
)
|
|
||||||
mock_client_factory.return_value = mock_api
|
|
||||||
|
|
||||||
with pytest.raises(SIPException, match="Could not create dispatch rule"):
|
|
||||||
sip_management.ensure_dispatch_rule(room)
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the TransitCodeService.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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) 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
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""
|
||||||
|
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 RoomFactory, UserFactory
|
||||||
|
from core.models import RoleChoices
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def generate_user_access_token(user, **overrides):
|
||||||
|
"""Generate a valid user access JWT signed with the token secret."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": "test-app",
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
payload = {key: value for key, value in payload.items() if value is not None}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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_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_access",
|
||||||
|
"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_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_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
|
||||||
@@ -119,8 +119,6 @@ def test_api_users_retrieve_me_authenticated(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"default_room_access_level": None,
|
|
||||||
"default_room_configuration": {},
|
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"full_name": user.full_name,
|
"full_name": user.full_name,
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
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 UserFactory
|
||||||
|
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_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()
|
||||||
|
|
||||||
|
code = TransitCodeService().create_code(user, client_id="my-app")
|
||||||
|
|
||||||
|
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["token_type"] == "user_access"
|
||||||
|
assert payload["user_id"] == str(user.id)
|
||||||
|
assert payload["client_id"] == "my-app"
|
||||||
|
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()
|
||||||
|
|
||||||
|
code = TransitCodeService().create_code(user)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
code = TransitCodeService().create_code(user)
|
||||||
|
|
||||||
|
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()
|
||||||
|
code = TransitCodeService().create_code(user)
|
||||||
|
|
||||||
|
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
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
"""
|
|
||||||
Test the default room preferences exposed on the users API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from core import factories
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_me_includes_default_room_preferences():
|
|
||||||
"""The "me" endpoint should expose the user's default room preferences."""
|
|
||||||
user = factories.UserFactory(
|
|
||||||
default_room_access_level="restricted",
|
|
||||||
default_room_configuration={"everyone_can_mute": False},
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.get("/api/v1.0/users/me/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
content = response.json()
|
|
||||||
assert content["default_room_access_level"] == "restricted"
|
|
||||||
assert content["default_room_configuration"] == {"everyone_can_mute": False}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_update_default_room_preferences():
|
|
||||||
"""Users should be able to update their own default room preferences."""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/v1.0/users/{user.id!s}/",
|
|
||||||
{
|
|
||||||
"default_room_access_level": "trusted",
|
|
||||||
"default_room_configuration": {
|
|
||||||
"can_publish_sources": ["microphone", "camera"],
|
|
||||||
"everyone_can_mute": False,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
user.refresh_from_db()
|
|
||||||
assert user.default_room_access_level == "trusted"
|
|
||||||
assert user.default_room_configuration == {
|
|
||||||
"can_publish_sources": ["microphone", "camera"],
|
|
||||||
"everyone_can_mute": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_update_default_room_access_level_invalid():
|
|
||||||
"""An invalid access level should be rejected."""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/v1.0/users/{user.id!s}/",
|
|
||||||
{"default_room_access_level": "invalid"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
user.refresh_from_db()
|
|
||||||
assert user.default_room_access_level is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_update_default_room_configuration_invalid():
|
|
||||||
"""An invalid room configuration should be rejected."""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/v1.0/users/{user.id!s}/",
|
|
||||||
{"default_room_configuration": {"unknown_field": True}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
user.refresh_from_db()
|
|
||||||
assert user.default_room_configuration == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_update_other_user_default_room_preferences_forbidden():
|
|
||||||
"""Users should not be able to update someone else's preferences."""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
other_user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/v1.0/users/{other_user.id!s}/",
|
|
||||||
{"default_room_access_level": "restricted"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
other_user.refresh_from_db()
|
|
||||||
assert other_user.default_room_access_level is None
|
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
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_test_token(user, scopes):
|
||||||
|
"""Generate a valid application JWT token for testing."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
scope_string = " ".join(scopes)
|
||||||
|
|
||||||
|
application = ApplicationFactory()
|
||||||
|
|
||||||
|
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_with_rs_token():
|
||||||
|
"""A resource-server-authenticated user should be able to mint a code."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
# todo - add a decorator instead
|
||||||
|
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/")
|
||||||
|
|
||||||
|
mock_rs_authenticate.assert_called_once()
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
code_data = TransitCodeService().consume_code(response.data["transit_code"])
|
||||||
|
assert code_data == {
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"client_id": "rs-client",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_with_rs_token_missing_scope():
|
||||||
|
"""A resource server token without the scope should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
# todo - add a decorator instead
|
||||||
|
with mock.patch.object(
|
||||||
|
ResourceServerAuthentication,
|
||||||
|
"authenticate",
|
||||||
|
return_value=(user, {"scope": "rooms:list", "client_id": "rs-client"}),
|
||||||
|
):
|
||||||
|
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 == 403
|
||||||
|
assert "users:session" in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# todo - add a test to make sure the addon authentification doesn't allow to mint a transit token
|
||||||
@@ -184,13 +184,12 @@ def test_models_rooms_is_public_property():
|
|||||||
|
|
||||||
|
|
||||||
@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_disabled_skips_pin_generation(
|
||||||
mock_generate_unique_pin_code, settings
|
mock_generate_unique_pin_code, settings
|
||||||
):
|
):
|
||||||
"""Telephony and roomkit both disabled should not generate pin codes."""
|
"""Telephony disabled should not generate pin codes."""
|
||||||
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
settings.ROOMKIT_ENABLED = False
|
|
||||||
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
|
|
||||||
@@ -198,18 +197,6 @@ def test_telephony_and_roomkit_disabled_skips_pin_generation(
|
|||||||
assert room.pin_code is None
|
assert room.pin_code is None
|
||||||
|
|
||||||
|
|
||||||
def test_roomkit_enabled_generates_pin_code(settings):
|
|
||||||
"""Roomkit enabled alone should generate pin codes, even without telephony."""
|
|
||||||
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
|
|
||||||
room = RoomFactory()
|
|
||||||
|
|
||||||
assert room.pin_code is not None
|
|
||||||
assert len(room.pin_code) == settings.ROOM_TELEPHONY_PIN_LENGTH
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_and_custom_pin_length(settings):
|
def test_default_and_custom_pin_length(settings):
|
||||||
"""Pin codes should be created with correct configured length."""
|
"""Pin codes should be created with correct configured length."""
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from rest_framework.routers import DefaultRouter, SimpleRouter
|
|||||||
from core.addons import viewsets as addons_viewsets
|
from core.addons import viewsets as addons_viewsets
|
||||||
from core.api import get_frontend_configuration, viewsets
|
from core.api import get_frontend_configuration, viewsets
|
||||||
from core.external_api import viewsets as external_viewsets
|
from core.external_api import viewsets as external_viewsets
|
||||||
from core.roomkit import viewsets as roomkit_viewsets
|
|
||||||
|
|
||||||
# - Main endpoints
|
# - Main endpoints
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -20,11 +19,6 @@ router.register("files", viewsets.FileViewSet, basename="files")
|
|||||||
router.register(
|
router.register(
|
||||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||||
)
|
)
|
||||||
router.register(
|
|
||||||
"roomkit",
|
|
||||||
roomkit_viewsets.RoomKitViewSet,
|
|
||||||
basename="roomkit",
|
|
||||||
)
|
|
||||||
router.register(
|
router.register(
|
||||||
"addons/sessions",
|
"addons/sessions",
|
||||||
addons_viewsets.SessionViewSet,
|
addons_viewsets.SessionViewSet,
|
||||||
@@ -43,6 +37,11 @@ external_router.register(
|
|||||||
external_viewsets.RoomViewSet,
|
external_viewsets.RoomViewSet,
|
||||||
basename="external_room",
|
basename="external_room",
|
||||||
)
|
)
|
||||||
|
external_router.register(
|
||||||
|
"users",
|
||||||
|
external_viewsets.UserViewSet,
|
||||||
|
basename="external_user",
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ class Base(Configuration):
|
|||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
|
"core.authentication.user_token.UserAccessJWTAuthentication",
|
||||||
"core.authentication.backends.SessionAuthenticationWith401",
|
"core.authentication.backends.SessionAuthenticationWith401",
|
||||||
),
|
),
|
||||||
"DEFAULT_PARSER_CLASSES": [
|
"DEFAULT_PARSER_CLASSES": [
|
||||||
@@ -344,16 +345,16 @@ class Base(Configuration):
|
|||||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
),
|
),
|
||||||
|
"exchange_access_token": values.Value(
|
||||||
|
default="30/minute",
|
||||||
|
environ_name="EXCHANGE_ACCESS_TOKEN_THROTTLE_RATES",
|
||||||
|
environ_prefix=None,
|
||||||
|
),
|
||||||
"creation_callback": values.Value(
|
"creation_callback": values.Value(
|
||||||
default="600/minute",
|
default="600/minute",
|
||||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
),
|
),
|
||||||
"roomkit_join": values.Value(
|
|
||||||
default="300/minute",
|
|
||||||
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
|
|
||||||
environ_prefix=None,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||||
@@ -846,11 +847,6 @@ class Base(Configuration):
|
|||||||
environ_name="LOBBY_NOTIFICATION_TYPE",
|
environ_name="LOBBY_NOTIFICATION_TYPE",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
LOBBY_COOKIE_NAME = values.Value(
|
|
||||||
"lobbyParticipantId",
|
|
||||||
environ_name="LOBBY_COOKIE_NAME",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calendar integrations
|
# Calendar integrations
|
||||||
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||||
@@ -886,21 +882,6 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Roomkit (meeting-room SIP devices) integration
|
|
||||||
ROOMKIT_ENABLED = values.BooleanValue(
|
|
||||||
False,
|
|
||||||
environ_name="ROOMKIT_ENABLED",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
# Server-to-server API token allowing the LiveKit SIP module to call the
|
|
||||||
# roomkit endpoints (e.g. join a room on behalf of a meeting-room device
|
|
||||||
# dialing in before any WebRTC participant).
|
|
||||||
ROOMKIT_SERVER_TO_SERVER_API_TOKEN = SecretFileValue(
|
|
||||||
None,
|
|
||||||
environ_name="ROOMKIT_SERVER_TO_SERVER_API_TOKEN",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Subtitles settings
|
# Subtitles settings
|
||||||
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
|
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
|
||||||
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
|
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
|
||||||
@@ -973,6 +954,61 @@ class Base(Configuration):
|
|||||||
environ_name="APPLICATION_BASE_URL",
|
environ_name="APPLICATION_BASE_URL",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# User access tokens (embedded frontend / iframe support)
|
||||||
|
USER_ACCESS_TOKEN_ENABLED = values.BooleanValue(
|
||||||
|
False, environ_name="USER_ACCESS_TOKEN_ENABLED", environ_prefix=None
|
||||||
|
)
|
||||||
|
USER_ACCESS_TOKEN_SECRET_KEY = SecretFileValue(
|
||||||
|
None, environ_name="USER_ACCESS_TOKEN_SECRET_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
USER_ACCESS_TOKEN_ALG = values.Value(
|
||||||
|
"HS256",
|
||||||
|
environ_name="USER_ACCESS_TOKEN_ALG",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
USER_ACCESS_TOKEN_ISSUER = values.Value(
|
||||||
|
"lasuite-meet",
|
||||||
|
environ_name="USER_ACCESS_TOKEN_ISSUER",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
USER_ACCESS_TOKEN_AUDIENCE = values.Value(
|
||||||
|
None,
|
||||||
|
environ_name="USER_ACCESS_TOKEN_AUDIENCE",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
# Lifetime of the user access token obtained through the exchange
|
||||||
|
# endpoint. It never transits through a URL, so it can cover a full
|
||||||
|
# meeting (default: 2 hours).
|
||||||
|
USER_ACCESS_TOKEN_TTL = values.PositiveIntegerValue(
|
||||||
|
7200,
|
||||||
|
environ_name="USER_ACCESS_TOKEN_TTL",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
# Lifetime of the single-use transit code handed to the frontend
|
||||||
|
# through a URL fragment. Kept very short by design: it must only
|
||||||
|
# survive the redirect and the exchange call.
|
||||||
|
TRANSIT_CODE_TTL = values.PositiveIntegerValue(
|
||||||
|
60,
|
||||||
|
environ_name="TRANSIT_CODE_TTL",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
TRANSIT_CODE_CACHE_PREFIX = values.Value(
|
||||||
|
"transit-code",
|
||||||
|
environ_name="TRANSIT_CODE_CACHE_PREFIX",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
# Number of random bytes per code (48 bytes -> 64 url-safe characters)
|
||||||
|
TRANSIT_CODE_NBYTES = values.PositiveIntegerValue(
|
||||||
|
48,
|
||||||
|
environ_name="TRANSIT_CODE_NBYTES",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
USER_ACCESS_TOKEN_TYPE = values.Value(
|
||||||
|
"Bearer",
|
||||||
|
environ_name="USER_ACCESS_TOKEN_TYPE",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
# Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when
|
# Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when
|
||||||
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
|
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
|
||||||
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
|
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
|
||||||
@@ -1270,6 +1306,10 @@ class Test(Base):
|
|||||||
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||||
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||||
|
|
||||||
|
USER_ACCESS_TOKEN_ENABLED = True
|
||||||
|
USER_ACCESS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-room" # noqa:S105
|
||||||
|
USER_ACCESS_TOKEN_AUDIENCE = "Test inc." # noqa:S105
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ dependencies = [
|
|||||||
"django-storages[s3]==1.14.6",
|
"django-storages[s3]==1.14.6",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django-pydantic-field==0.5.4",
|
"django-pydantic-field==0.5.4",
|
||||||
"django==5.2.16",
|
"django==5.2.14",
|
||||||
"djangorestframework==3.17.1",
|
"djangorestframework==3.17.1",
|
||||||
"drf_spectacular==0.29.0",
|
"drf_spectacular==0.29.0",
|
||||||
"dockerflow==2026.3.4",
|
"dockerflow==2026.3.4",
|
||||||
|
|||||||
Generated
+4
-4
@@ -586,16 +586,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django"
|
name = "django"
|
||||||
version = "5.2.16"
|
version = "5.2.14"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asgiref" },
|
{ name = "asgiref" },
|
||||||
{ name = "sqlparse" },
|
{ name = "sqlparse" },
|
||||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/13/1e5e3e4c15dcecb04281b3cb2a46a4670e1cef131068e202f6040df19224/django-5.2.16-py3-none-any.whl", hash = "sha256:04f354bf9d807a86ad1a8392fe3808d362358a8eafc322848e0e43e59b24371d", size = 8311943, upload-time = "2026-07-07T13:52:11.223Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1260,7 +1260,7 @@ requires-dist = [
|
|||||||
{ name = "brotli", specifier = "==1.2.0" },
|
{ name = "brotli", specifier = "==1.2.0" },
|
||||||
{ name = "celery", extras = ["redis"], specifier = "==5.6.3" },
|
{ name = "celery", extras = ["redis"], specifier = "==5.6.3" },
|
||||||
{ name = "dj-database-url", specifier = "==3.1.2" },
|
{ name = "dj-database-url", specifier = "==3.1.2" },
|
||||||
{ name = "django", specifier = "==5.2.16" },
|
{ name = "django", specifier = "==5.2.14" },
|
||||||
{ name = "django-configurations", specifier = "==2.5.1" },
|
{ name = "django-configurations", specifier = "==2.5.1" },
|
||||||
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
||||||
{ name = "django-countries", specifier = "==9.0.0" },
|
{ name = "django-countries", specifier = "==9.0.0" },
|
||||||
|
|||||||
+24
-17
@@ -12,6 +12,7 @@ import { routes } from './routes'
|
|||||||
import './i18n/init'
|
import './i18n/init'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { AppInitialization } from '@/components/AppInitialization'
|
import { AppInitialization } from '@/components/AppInitialization'
|
||||||
|
import { TransitCodeGate } from '@/features/auth/components/TransitCodeGate'
|
||||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||||
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
|
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
|
||||||
|
|
||||||
@@ -24,23 +25,29 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{!isSDKContext && <AppInitialization />}
|
<TransitCodeGate>
|
||||||
<Suspense fallback={null}>
|
{!isSDKContext && <AppInitialization />}
|
||||||
<I18nProvider locale={i18n.language}>
|
<Suspense fallback={null}>
|
||||||
<Layout>
|
<I18nProvider locale={i18n.language}>
|
||||||
<Switch>
|
<Layout>
|
||||||
{Object.entries(routes).map(([, route], i) => (
|
<Switch>
|
||||||
<Route key={i} path={route.path} component={route.Component} />
|
{Object.entries(routes).map(([, route], i) => (
|
||||||
))}
|
<Route
|
||||||
<Route component={NotFoundScreen} />
|
key={i}
|
||||||
</Switch>
|
path={route.path}
|
||||||
</Layout>
|
component={route.Component}
|
||||||
<ReactQueryDevtools
|
/>
|
||||||
initialIsOpen={false}
|
))}
|
||||||
buttonPosition="bottom-left"
|
<Route component={NotFoundScreen} />
|
||||||
/>
|
</Switch>
|
||||||
</I18nProvider>
|
</Layout>
|
||||||
</Suspense>
|
<ReactQueryDevtools
|
||||||
|
initialIsOpen={false}
|
||||||
|
buttonPosition="bottom-left"
|
||||||
|
/>
|
||||||
|
</I18nProvider>
|
||||||
|
</Suspense>
|
||||||
|
</TransitCodeGate>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { ApiError } from './ApiError'
|
import { ApiError } from './ApiError'
|
||||||
import { apiUrl } from './apiUrl'
|
import { apiUrl } from './apiUrl'
|
||||||
|
import { getAccessToken } from '@/stores/accessToken'
|
||||||
|
|
||||||
export const fetchApi = async <T = Record<string, unknown>>(
|
export const fetchApi = async <T = Record<string, unknown>>(
|
||||||
url: string,
|
url: string,
|
||||||
options?: RequestInit
|
options?: RequestInit
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
const csrfToken = getCsrfToken()
|
const csrfToken = getCsrfToken()
|
||||||
|
// Embedded (iframe) mode: the user access token obtained through the
|
||||||
|
// transit code exchange authenticates requests in place of the session
|
||||||
|
// cookie, which is blocked in third-party contexts.
|
||||||
|
const accessToken = getAccessToken()
|
||||||
const response = await fetch(apiUrl(url), {
|
const response = await fetch(apiUrl(url), {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(!!csrfToken && { 'X-CSRFToken': csrfToken }),
|
...(!!csrfToken && { 'X-CSRFToken': csrfToken }),
|
||||||
|
...(!!accessToken && { Authorization: `Bearer ${accessToken}` }),
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { fetchApi } from './fetchApi'
|
|||||||
import { keys } from './queryKeys'
|
import { keys } from './queryKeys'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { RecordingMode } from '@/features/recording'
|
import { RecordingMode } from '@/features/recording'
|
||||||
import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
|
||||||
import type { Track } from 'livekit-client'
|
import type { Track } from 'livekit-client'
|
||||||
type Source = Track.Source
|
type Source = Track.Source
|
||||||
|
|
||||||
@@ -50,9 +49,6 @@ export interface ApiConfig {
|
|||||||
international_phone_number?: string
|
international_phone_number?: string
|
||||||
default_country?: string
|
default_country?: string
|
||||||
}
|
}
|
||||||
resource?: {
|
|
||||||
default_access_level?: ApiAccessLevel
|
|
||||||
}
|
|
||||||
manifest_link?: string
|
manifest_link?: string
|
||||||
livekit: {
|
livekit: {
|
||||||
url: string
|
url: string
|
||||||
|
|||||||
@@ -59,10 +59,9 @@ export const Avatar = React.memo(
|
|||||||
<text
|
<text
|
||||||
x="50"
|
x="50"
|
||||||
y="50"
|
y="50"
|
||||||
dy="-0.08em"
|
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="central"
|
dominantBaseline="central"
|
||||||
fontSize="52"
|
fontSize={initials.length > 1 ? 48 : 52}
|
||||||
fontWeight="500"
|
fontWeight="500"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import { BackendLanguage } from '@/utils/languages'
|
import { BackendLanguage } from '@/utils/languages'
|
||||||
import type {
|
|
||||||
ApiAccessLevel,
|
|
||||||
RoomConfiguration,
|
|
||||||
} from '@/features/rooms/api/ApiRoom'
|
|
||||||
|
|
||||||
export type ApiUser = {
|
export type ApiUser = {
|
||||||
id: string
|
id: string
|
||||||
@@ -11,6 +7,4 @@ export type ApiUser = {
|
|||||||
last_name: string
|
last_name: string
|
||||||
language: BackendLanguage
|
language: BackendLanguage
|
||||||
timezone: string
|
timezone: string
|
||||||
default_room_access_level?: ApiAccessLevel | null
|
|
||||||
default_room_configuration?: RoomConfiguration | null
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
import { setAccessToken } from '@/stores/accessToken'
|
||||||
|
import { consumeTransitCodeFromFragment } from '../utils/transitCode'
|
||||||
|
|
||||||
|
type ApiAccessToken = {
|
||||||
|
access_token: string
|
||||||
|
token_type: string
|
||||||
|
expires_in: number
|
||||||
|
scope: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange a single-use transit code for a user access token.
|
||||||
|
*
|
||||||
|
* The endpoint is unauthenticated: the code itself is the credential.
|
||||||
|
*/
|
||||||
|
export const exchangeAccessToken = (code: string): Promise<ApiAccessToken> => {
|
||||||
|
return fetchApi<ApiAccessToken>('/users/exchange-access-token/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const runInitialization = async (): Promise<void> => {
|
||||||
|
const code = consumeTransitCodeFromFragment()
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { access_token } = await exchangeAccessToken(code)
|
||||||
|
setAccessToken(access_token)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Transit code exchange failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialization: Promise<void> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap the embedded (iframe) authentication, if applicable.
|
||||||
|
*
|
||||||
|
* When, and only when, a transit code is present in the URL fragment,
|
||||||
|
* exchange it for a user access token and keep it in the in-memory
|
||||||
|
* accessToken store: fetchApi then sends it as a Bearer header on every
|
||||||
|
* api call, authenticating the user exactly like a session cookie would.
|
||||||
|
*
|
||||||
|
* Must complete before anything fires an authenticated query, which the
|
||||||
|
* TransitCodeGate component guarantees by gating the app tree on it.
|
||||||
|
*
|
||||||
|
* Memoized: the fragment is consumed and the code exchanged exactly once,
|
||||||
|
* however many times this is called (StrictMode double-invoked effects,
|
||||||
|
* among others). Subsequent calls await the same promise.
|
||||||
|
*
|
||||||
|
* A failed exchange (expired or already used code) is not fatal: the app
|
||||||
|
* starts unauthenticated, falling back to the regular session flow.
|
||||||
|
*/
|
||||||
|
export const initializeAccessTokenFromFragment = (): Promise<void> => {
|
||||||
|
if (!initialization) {
|
||||||
|
initialization = runInitialization()
|
||||||
|
}
|
||||||
|
return initialization
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ApiError } from '@/api/ApiError'
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import { type ApiUser } from './ApiUser'
|
import { type ApiUser } from './ApiUser'
|
||||||
import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
|
import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
|
||||||
|
import { getAccessToken } from '@/stores/accessToken'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fetch the logged-in user from the api.
|
* fetch the logged-in user from the api.
|
||||||
@@ -25,7 +26,13 @@ export const fetchUser = (
|
|||||||
if (error instanceof ApiError && error.statusCode === 401) {
|
if (error instanceof ApiError && error.statusCode === 401) {
|
||||||
// make sure to not resolve the promise while trying to silent login
|
// make sure to not resolve the promise while trying to silent login
|
||||||
// so that consumers of fetchUser don't think the work already ended
|
// so that consumers of fetchUser don't think the work already ended
|
||||||
if (opts.attemptSilent && canAttemptSilentLogin()) {
|
// Never attempt a silent login in embedded (token) mode: an OIDC
|
||||||
|
// redirect inside the iframe would break the embed.
|
||||||
|
if (
|
||||||
|
opts.attemptSilent &&
|
||||||
|
!getAccessToken() &&
|
||||||
|
canAttemptSilentLogin()
|
||||||
|
) {
|
||||||
attemptSilentLogin(30)
|
attemptSilentLogin(30)
|
||||||
} else {
|
} else {
|
||||||
resolve(false)
|
resolve(false)
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
|
||||||
import { fetchApi } from '@/api/fetchApi'
|
|
||||||
import type { ApiError } from '@/api/ApiError'
|
|
||||||
import { type ApiUser } from './ApiUser'
|
|
||||||
|
|
||||||
export type PatchUserParams = {
|
|
||||||
userId: string
|
|
||||||
user: Partial<
|
|
||||||
Pick<
|
|
||||||
ApiUser,
|
|
||||||
| 'timezone'
|
|
||||||
| 'language'
|
|
||||||
| 'default_room_access_level'
|
|
||||||
| 'default_room_configuration'
|
|
||||||
>
|
|
||||||
>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const patchUser = ({ userId, user }: PatchUserParams) => {
|
|
||||||
return fetchApi<ApiUser>(`/users/${userId}/`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(user),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const patchUserMutationKey = ['patchUser']
|
|
||||||
|
|
||||||
export function usePatchUser(
|
|
||||||
options?: UseMutationOptions<ApiUser, ApiError, PatchUserParams>
|
|
||||||
) {
|
|
||||||
return useMutation<ApiUser, ApiError, PatchUserParams>({
|
|
||||||
mutationKey: patchUserMutationKey,
|
|
||||||
mutationFn: patchUser,
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||||
|
import { useHash } from '@/hooks/useHash'
|
||||||
|
import { initializeAccessTokenFromFragment } from '../api/exchangeAccessToken'
|
||||||
|
import { hasTransitCodeInFragment } from '../utils/transitCode'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gates the app tree on the embedded (iframe) authentication bootstrap.
|
||||||
|
*
|
||||||
|
* Without a transit code in the URL fragment — the overwhelmingly common
|
||||||
|
* case — the component early returns children synchronously: no state,
|
||||||
|
* no effect, no extra render, no loading screen.
|
||||||
|
*
|
||||||
|
* When a transit code is present, children are not mounted until it has
|
||||||
|
* been exchanged for a user access token, so that every authenticated
|
||||||
|
* query already carries the Authorization header. A loading screen is
|
||||||
|
* displayed in the meantime, as UserAware does.
|
||||||
|
*/
|
||||||
|
export const TransitCodeGate = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) => {
|
||||||
|
const hash = useHash()
|
||||||
|
|
||||||
|
// Latch the decision on the initial hash: the bootstrap scrubs the
|
||||||
|
// fragment as soon as it starts, and the gate must not flip back to the
|
||||||
|
// fast path while the exchange is still in flight.
|
||||||
|
const [needsExchange] = useState(() => hasTransitCodeInFragment(hash))
|
||||||
|
|
||||||
|
if (!needsExchange) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TransitCodeExchange>{children}</TransitCodeExchange>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only ever mounted when a transit code is present: runs the memoized
|
||||||
|
* bootstrap (safe against StrictMode double-invoked effects) and holds
|
||||||
|
* children back until it settles.
|
||||||
|
*/
|
||||||
|
const TransitCodeExchange = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const [isReady, setIsReady] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
initializeAccessTokenFromFragment().finally(() => {
|
||||||
|
console.log('$$ transit code exchange finished')
|
||||||
|
if (isMounted) {
|
||||||
|
console.log('$$ setIsReady')
|
||||||
|
setIsReady(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
console.log('$$ isReady', isReady)
|
||||||
|
|
||||||
|
return isReady ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<LoadingScreen header={false} footer={false} delay={1000} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
const TRANSIT_CODE_FRAGMENT_PARAM = 'transit_code'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a URL fragment carries a transit code. Pure check, does not
|
||||||
|
* consume anything.
|
||||||
|
*/
|
||||||
|
export const hasTransitCodeInFragment = (hash: string): boolean => {
|
||||||
|
if (!hash) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return new URLSearchParams(hash.replace(/^#/, '')).has(
|
||||||
|
TRANSIT_CODE_FRAGMENT_PARAM
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the transit code from the URL fragment, if any.
|
||||||
|
*
|
||||||
|
* The fragment is scrubbed from the address bar immediately, before any
|
||||||
|
* network call, so the code never lingers in the browser history. Any
|
||||||
|
* other fragment content is preserved.
|
||||||
|
*/
|
||||||
|
export const consumeTransitCodeFromFragment = (): string | null => {
|
||||||
|
if (typeof window === 'undefined' || !window.location.hash) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.hash.substring(1))
|
||||||
|
const code = params.get(TRANSIT_CODE_FRAGMENT_PARAM)
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
params.delete(TRANSIT_CODE_FRAGMENT_PARAM)
|
||||||
|
const remaining = params.toString()
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
window.location.pathname +
|
||||||
|
window.location.search +
|
||||||
|
(remaining ? `#${remaining}` : '')
|
||||||
|
)
|
||||||
|
|
||||||
|
return code
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { accessTokenStore } from '@/stores/accessToken'
|
||||||
|
import { resolveMediaUrl } from '../utils/resolveMediaUrl'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive companion of resolveMediaUrl for browser-native consumers
|
||||||
|
* (CSS url(), img src attributes): resolves a list of /media/ URLs and
|
||||||
|
* returns a stable lookup, identity in regular mode.
|
||||||
|
*
|
||||||
|
* Object URLs come from the shared session-lifetime cache and are never
|
||||||
|
* revoked here: they may be used concurrently by the background
|
||||||
|
* processors.
|
||||||
|
*/
|
||||||
|
export const useResolvedMediaUrls = (
|
||||||
|
urls: (string | null | undefined)[]
|
||||||
|
): ((url: string) => string) => {
|
||||||
|
const [resolved, setResolved] = useState<Record<string, string>>({})
|
||||||
|
const { accessToken } = useSnapshot(accessTokenStore)
|
||||||
|
|
||||||
|
// Stable dependency for the effect, insensitive to array identity
|
||||||
|
const urlsKey = urls.filter(Boolean).sort().join('\n')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken || !urlsKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = true
|
||||||
|
|
||||||
|
const resolveAll = async () => {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
urlsKey.split('\n').map(async (url) => {
|
||||||
|
try {
|
||||||
|
return [url, await resolveMediaUrl(url)] as const
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
return [url, url] as const
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
if (isMounted) {
|
||||||
|
setResolved(Object.fromEntries(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolveAll()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
}, [accessToken, urlsKey])
|
||||||
|
|
||||||
|
// Stable identity so that consumers can safely list the resolver in
|
||||||
|
// their memo dependencies: it only changes when resolutions land.
|
||||||
|
return useCallback((url: string) => resolved[url] ?? url, [resolved])
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { getAccessToken } from '@/stores/accessToken'
|
||||||
|
|
||||||
|
// Session-lifetime cache: object URLs are shared between every consumer
|
||||||
|
// of a given media (background processors, thumbnails) and are therefore
|
||||||
|
// never revoked - their number is bounded by the user's custom
|
||||||
|
// backgrounds, and they die with the page like the access token does.
|
||||||
|
const objectUrlCache = new Map<string, string>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an authenticated /media/ URL for the embedded (token) mode.
|
||||||
|
*
|
||||||
|
* Media files are served behind an nginx auth_request subrequest that
|
||||||
|
* authenticates the original request. In regular mode the session cookie
|
||||||
|
* rides along browser-native loads (img.src, CSS url()) and the URL is
|
||||||
|
* returned unchanged, without any fetch. In embedded mode the
|
||||||
|
* third-party cookie is blocked and native loads cannot carry the
|
||||||
|
* Authorization header, so the media is fetched here with the Bearer
|
||||||
|
* header - which the media-auth endpoint accepts, as it sits behind the
|
||||||
|
* default authentication stack - and exposed as a blob object URL.
|
||||||
|
*/
|
||||||
|
export const resolveMediaUrl = async (url: string): Promise<string> => {
|
||||||
|
const accessToken = getAccessToken()
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = objectUrlCache.get(url)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to resolve media url ${url}: HTTP ${response.status}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(await response.blob())
|
||||||
|
objectUrlCache.set(url, objectUrl)
|
||||||
|
|
||||||
|
return objectUrl
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
|
import { useCallback, useEffect, useLayoutEffect } from 'react'
|
||||||
|
|
||||||
const MIN_HEIGHT = 130
|
const MIN_HEIGHT = 130
|
||||||
const MIN_WIDTH = 140
|
const MIN_WIDTH = 140
|
||||||
@@ -10,6 +11,72 @@ const MIN_VISIBLE_TILES = 1
|
|||||||
const ASPECT_RATIO = 16 / 10
|
const ASPECT_RATIO = 16 / 10
|
||||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||||
|
|
||||||
|
type CarouselOrientation = 'vertical' | 'horizontal'
|
||||||
|
|
||||||
|
interface CarouselLayoutState {
|
||||||
|
orientation: CarouselOrientation
|
||||||
|
maxVisibleTiles: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CarouselLayoutObserverProps {
|
||||||
|
asideEl: React.RefObject<HTMLDivElement>
|
||||||
|
orientation?: CarouselOrientation
|
||||||
|
onLayoutChange: (layout: CarouselLayoutState) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CarouselLayoutObserver = ({
|
||||||
|
orientation,
|
||||||
|
asideEl,
|
||||||
|
onLayoutChange,
|
||||||
|
}: CarouselLayoutObserverProps) => {
|
||||||
|
const { width, height } = useSize(asideEl)
|
||||||
|
|
||||||
|
// Hysteresis memory: avoids flapping between N and N+1 tiles when the
|
||||||
|
// container size hovers around a breakpoint. A ref (not state) because
|
||||||
|
// updating it must not trigger a re-render.
|
||||||
|
const prevTilesRef = React.useRef(0)
|
||||||
|
|
||||||
|
const carouselOrientation: CarouselOrientation =
|
||||||
|
orientation ?? (height >= width ? 'vertical' : 'horizontal')
|
||||||
|
|
||||||
|
const tileSpan =
|
||||||
|
carouselOrientation === 'vertical'
|
||||||
|
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||||
|
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||||
|
|
||||||
|
const scrollBarWidth = getScrollBarWidth()
|
||||||
|
|
||||||
|
const availableSpan =
|
||||||
|
(carouselOrientation === 'vertical' ? height : width) - scrollBarWidth
|
||||||
|
|
||||||
|
const tilesThatFit = Math.max(availableSpan / tileSpan, MIN_VISIBLE_TILES)
|
||||||
|
|
||||||
|
let maxVisibleTiles: number
|
||||||
|
if (Math.abs(tilesThatFit - prevTilesRef.current) < 0.5) {
|
||||||
|
// Within the dead zone: keep the previous count.
|
||||||
|
maxVisibleTiles = Math.round(prevTilesRef.current)
|
||||||
|
} else {
|
||||||
|
maxVisibleTiles = Math.round(tilesThatFit)
|
||||||
|
prevTilesRef.current = tilesThatFit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply cosmetic layout output straight to the DOM.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = asideEl.current
|
||||||
|
if (!el) return
|
||||||
|
el.dataset.lkOrientation = carouselOrientation
|
||||||
|
el.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString())
|
||||||
|
}, [asideEl, carouselOrientation, maxVisibleTiles])
|
||||||
|
|
||||||
|
// Report upward only what the parent actually needs for
|
||||||
|
// `useVisualStableUpdate` (and only when it changes — see parent handler).
|
||||||
|
useEffect(() => {
|
||||||
|
onLayoutChange({ orientation: carouselOrientation, maxVisibleTiles })
|
||||||
|
}, [carouselOrientation, maxVisibleTiles, onLayoutChange])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
||||||
tracks: TrackReferenceOrPlaceholder[]
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
@@ -40,52 +107,42 @@ export function CarouselLayout({
|
|||||||
...props
|
...props
|
||||||
}: CarouselLayoutProps) {
|
}: CarouselLayoutProps) {
|
||||||
const asideEl = React.useRef<HTMLDivElement>(null)
|
const asideEl = React.useRef<HTMLDivElement>(null)
|
||||||
const [prevTiles, setPrevTiles] = React.useState(0)
|
|
||||||
const { width, height } = useSize(asideEl)
|
|
||||||
const carouselOrientation = orientation
|
|
||||||
? orientation
|
|
||||||
: height >= width
|
|
||||||
? 'vertical'
|
|
||||||
: 'horizontal'
|
|
||||||
|
|
||||||
const tileSpan =
|
const [layout, setLayout] = React.useState<CarouselLayoutState>({
|
||||||
carouselOrientation === 'vertical'
|
orientation: orientation ?? 'vertical',
|
||||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
maxVisibleTiles: MIN_VISIBLE_TILES,
|
||||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
})
|
||||||
const scrollBarWidth = getScrollBarWidth()
|
|
||||||
|
|
||||||
const tilesThatFit =
|
// Stable callback + identity check: the parent only re-renders when the
|
||||||
carouselOrientation === 'vertical'
|
// derived layout genuinely changed, not on every resize tick.
|
||||||
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
|
||||||
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
setLayout((prev) =>
|
||||||
|
prev.orientation === next.orientation &&
|
||||||
|
prev.maxVisibleTiles === next.maxVisibleTiles
|
||||||
|
? prev
|
||||||
|
: next
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
let maxVisibleTiles = Math.round(tilesThatFit)
|
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
|
||||||
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
|
||||||
maxVisibleTiles = Math.round(prevTiles)
|
|
||||||
} else if (prevTiles !== tilesThatFit) {
|
|
||||||
setPrevTiles(tilesThatFit)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
if (asideEl.current) {
|
|
||||||
asideEl.current.dataset.lkOrientation = carouselOrientation
|
|
||||||
asideEl.current.style.setProperty(
|
|
||||||
'--lk-max-visible-tiles',
|
|
||||||
maxVisibleTiles.toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, [maxVisibleTiles, carouselOrientation])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<>
|
||||||
key={carouselOrientation}
|
<CarouselLayoutObserver
|
||||||
className="lk-carousel"
|
asideEl={asideEl}
|
||||||
ref={asideEl}
|
orientation={orientation}
|
||||||
{...props}
|
onLayoutChange={handleLayoutChange}
|
||||||
>
|
/>
|
||||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
{/* `key` intentionally remounts the container when orientation flips, */}
|
||||||
</aside>
|
{/* which resets scroll position and re-runs the observer measurement. */}
|
||||||
|
<aside
|
||||||
|
key={layout.orientation}
|
||||||
|
className="lk-carousel"
|
||||||
|
ref={asideEl}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,37 @@ import { mergeProps } from '@/utils/mergeProps'
|
|||||||
import { PaginationIndicator } from './PaginationIndicator'
|
import { PaginationIndicator } from './PaginationIndicator'
|
||||||
import { useGridLayout } from '../hooks/useGridLayout'
|
import { useGridLayout } from '../hooks/useGridLayout'
|
||||||
import { PaginationControl } from './PaginationControl'
|
import { PaginationControl } from './PaginationControl'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||||
|
|
||||||
|
interface GridLayoutObserverProps {
|
||||||
|
gridEl: React.RefObject<HTMLDivElement>
|
||||||
|
trackCount: number
|
||||||
|
onMaxTilesChange: (maxTiles: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headless component that runs the layout calculation in isolation and
|
||||||
|
* reports the resulting tile capacity upward.
|
||||||
|
*
|
||||||
|
* `useGridLayout` re-renders its host on every layout recalculation
|
||||||
|
* (e.g. container resizes). Rendering it in a null child means only this
|
||||||
|
* component churns; the parent `GridLayout` re-renders solely when
|
||||||
|
* `maxTiles` actually changes, since `setState` bails out on equal values.
|
||||||
|
*/
|
||||||
|
const GridLayoutObserver = ({
|
||||||
|
gridEl,
|
||||||
|
trackCount,
|
||||||
|
onMaxTilesChange,
|
||||||
|
}: GridLayoutObserverProps) => {
|
||||||
|
const { layout } = useGridLayout(gridEl, trackCount)
|
||||||
|
useEffect(() => {
|
||||||
|
onMaxTilesChange(layout.maxTiles)
|
||||||
|
}, [onMaxTilesChange, layout.maxTiles])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface GridLayoutProps
|
export interface GridLayoutProps
|
||||||
extends
|
extends
|
||||||
@@ -38,15 +67,14 @@ export interface GridLayoutProps
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||||
const gridEl = React.createRef<HTMLDivElement>()
|
const gridEl = useRef<HTMLDivElement>(null)
|
||||||
|
const [maxTiles, setMaxTiles] = useState(1)
|
||||||
|
|
||||||
const elementProps = React.useMemo(
|
const elementProps = React.useMemo(
|
||||||
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
||||||
[props]
|
[props]
|
||||||
)
|
)
|
||||||
|
const pagination = usePagination(maxTiles, tracks)
|
||||||
const { layout } = useGridLayout(gridEl, tracks.length)
|
|
||||||
const pagination = usePagination(layout.maxTiles, tracks)
|
|
||||||
useSpeakerPromotionTrigger(pagination.tracks)
|
useSpeakerPromotionTrigger(pagination.tracks)
|
||||||
|
|
||||||
useSwipe(gridEl, {
|
useSwipe(gridEl, {
|
||||||
@@ -60,8 +88,13 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
|||||||
data-lk-pagination={pagination.totalPageCount > 1}
|
data-lk-pagination={pagination.totalPageCount > 1}
|
||||||
{...elementProps}
|
{...elementProps}
|
||||||
>
|
>
|
||||||
|
<GridLayoutObserver
|
||||||
|
gridEl={gridEl}
|
||||||
|
trackCount={tracks.length}
|
||||||
|
onMaxTilesChange={setMaxTiles}
|
||||||
|
/>
|
||||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||||
{tracks.length > layout.maxTiles && (
|
{tracks.length > maxTiles && (
|
||||||
<>
|
<>
|
||||||
<PaginationIndicator
|
<PaginationIndicator
|
||||||
totalPageCount={pagination.totalPageCount}
|
totalPageCount={pagination.totalPageCount}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { fetchApi } from '@/api/fetchApi'
|
|||||||
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
|
||||||
|
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||||
|
|
||||||
export const useMuteParticipant = () => {
|
export const useMuteParticipant = () => {
|
||||||
const apiRoomData = useRoomData()
|
const apiRoomData = useRoomData()
|
||||||
@@ -36,7 +37,7 @@ export const useMuteParticipant = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headers = !isAdminOrOwner
|
const headers = !isAdminOrOwner
|
||||||
? { Authorization: `Bearer ${apiRoomData.livekit.token}` }
|
? getLiveKitAuthHeaders(apiRoomData.livekit.token)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
let response
|
let response
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||||
|
|
||||||
export const useRenameParticipant = () => {
|
export const useRenameParticipant = () => {
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
@@ -15,11 +16,10 @@ export const useRenameParticipant = () => {
|
|||||||
throw new Error('LiveKit token is not available')
|
throw new Error('LiveKit token is not available')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = getLiveKitAuthHeaders(token)
|
||||||
return fetchApi(`rooms/${data.id}/rename/`, {
|
return fetchApi(`rooms/${data.id}/rename/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name,
|
name,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import type { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
|
import type { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { getLobbyParticipantId } from '@/stores/lobby'
|
||||||
|
|
||||||
export interface RequestEntryParams {
|
export interface RequestEntryParams {
|
||||||
roomId: string
|
roomId: string
|
||||||
@@ -15,6 +16,7 @@ export enum ApiLobbyStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiRequestEntry {
|
export interface ApiRequestEntry {
|
||||||
|
id?: string
|
||||||
status: ApiLobbyStatus
|
status: ApiLobbyStatus
|
||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
}
|
}
|
||||||
@@ -23,10 +25,12 @@ export const requestEntry = async ({
|
|||||||
roomId,
|
roomId,
|
||||||
username = '',
|
username = '',
|
||||||
}: RequestEntryParams) => {
|
}: RequestEntryParams) => {
|
||||||
|
const participantId = getLobbyParticipantId(roomId)
|
||||||
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
|
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username,
|
username,
|
||||||
|
...(participantId && { participant_id: participantId }),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||||
|
|
||||||
export const useRaiseHand = () => {
|
export const useRaiseHand = () => {
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
@@ -15,11 +16,10 @@ export const useRaiseHand = () => {
|
|||||||
throw new Error('LiveKit token is not available')
|
throw new Error('LiveKit token is not available')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = getLiveKitAuthHeaders(token)
|
||||||
return fetchApi(`rooms/${data.id}/toggle-hand/`, {
|
return fetchApi(`rooms/${data.id}/toggle-hand/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
raised,
|
raised,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ApiLobbyStatus,
|
ApiLobbyStatus,
|
||||||
type ApiRequestEntry,
|
type ApiRequestEntry,
|
||||||
} from '../api/requestEntry'
|
} from '../api/requestEntry'
|
||||||
|
import { setLobbyParticipantId } from '@/stores/lobby'
|
||||||
|
|
||||||
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
||||||
export const POLL_INTERVAL_MS = 1000
|
export const POLL_INTERVAL_MS = 1000
|
||||||
@@ -43,6 +44,11 @@ export const useLobby = ({
|
|||||||
roomId,
|
roomId,
|
||||||
username,
|
username,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (response.id) {
|
||||||
|
setLobbyParticipantId(roomId, response.id)
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||||
clearWaitingTimeout()
|
clearWaitingTimeout()
|
||||||
setStatus(ApiLobbyStatus.ACCEPTED)
|
setStatus(ApiLobbyStatus.ACCEPTED)
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { useLocalParticipant } from '@livekit/components-react'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
|
|
||||||
export const MEDIA_STATE_ELEMENT_ID = 'media-state'
|
|
||||||
export const MEDIA_STATE_CHANGED_EVENT = 'media-state-changed'
|
|
||||||
|
|
||||||
export type MediaStateChangedDetail = {
|
|
||||||
microphoneEnabled: boolean
|
|
||||||
cameraEnabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exposes the local participant's media state in the DOM so external tools
|
|
||||||
* (e.g. bots automating the frontend) can reliably read the microphone and
|
|
||||||
* camera state, and watch for changes with a MutationObserver:
|
|
||||||
*
|
|
||||||
* const el = document.getElementById('media-state')
|
|
||||||
* new MutationObserver(...).observe(el, { attributes: true })
|
|
||||||
*/
|
|
||||||
export const MediaStateObserver = () => {
|
|
||||||
const { isMicrophoneEnabled, isCameraEnabled } = useLocalParticipant()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent<MediaStateChangedDetail>(MEDIA_STATE_CHANGED_EVENT, {
|
|
||||||
detail: {
|
|
||||||
microphoneEnabled: isMicrophoneEnabled,
|
|
||||||
cameraEnabled: isCameraEnabled,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}, [isMicrophoneEnabled, isCameraEnabled])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id={MEDIA_STATE_ELEMENT_ID}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
data-microphone-enabled={isMicrophoneEnabled ? 'true' : 'false'}
|
|
||||||
data-camera-enabled={isCameraEnabled ? 'true' : 'false'}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+9
-4
@@ -1,4 +1,5 @@
|
|||||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||||
|
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
|
||||||
import posthog from 'posthog-js'
|
import posthog from 'posthog-js'
|
||||||
import {
|
import {
|
||||||
FilesetResolver,
|
FilesetResolver,
|
||||||
@@ -85,7 +86,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
this.sourceSettings = this.source!.getSettings()
|
this.sourceSettings = this.source!.getSettings()
|
||||||
this.videoElement = opts.element as HTMLVideoElement
|
this.videoElement = opts.element as HTMLVideoElement
|
||||||
|
|
||||||
this._initVirtualBackgroundImage()
|
await this._initVirtualBackgroundImage()
|
||||||
this._createMainCanvas()
|
this._createMainCanvas()
|
||||||
this._createMaskCanvas()
|
this._createMaskCanvas()
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
posthog.capture('firefox-blurring-init')
|
posthog.capture('firefox-blurring-init')
|
||||||
}
|
}
|
||||||
|
|
||||||
_initVirtualBackgroundImage() {
|
async _initVirtualBackgroundImage() {
|
||||||
if (this.options.type !== 'virtual') {
|
if (this.options.type !== 'virtual') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Virtual background is only supported for virtual background'
|
'Virtual background is only supported for virtual background'
|
||||||
@@ -115,15 +116,19 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
this.virtualBackgroundImage &&
|
this.virtualBackgroundImage &&
|
||||||
this.virtualBackgroundImage.src !== this.options.imagePath
|
this.virtualBackgroundImage.src !== this.options.imagePath
|
||||||
if (this.options.imagePath || needsUpdate) {
|
if (this.options.imagePath || needsUpdate) {
|
||||||
|
// Embedded (token) mode: img.src cannot carry the Authorization
|
||||||
|
// header, resolve the media to a blob object URL first. Identity
|
||||||
|
// in regular mode.
|
||||||
|
const imagePath = await resolveMediaUrl(this.options.imagePath!)
|
||||||
this.virtualBackgroundImage = document.createElement('img')
|
this.virtualBackgroundImage = document.createElement('img')
|
||||||
this.virtualBackgroundImage.crossOrigin = 'anonymous'
|
this.virtualBackgroundImage.crossOrigin = 'anonymous'
|
||||||
this.virtualBackgroundImage.src = this.options.imagePath!
|
this.virtualBackgroundImage.src = imagePath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(opts: ProcessorConfig): Promise<void> {
|
async update(opts: ProcessorConfig): Promise<void> {
|
||||||
this.options = opts
|
this.options = opts
|
||||||
this._initVirtualBackgroundImage()
|
await this._initVirtualBackgroundImage()
|
||||||
}
|
}
|
||||||
|
|
||||||
_initWorker() {
|
_initWorker() {
|
||||||
|
|||||||
+14
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||||
|
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
|
||||||
import {
|
import {
|
||||||
ProcessorWrapper,
|
ProcessorWrapper,
|
||||||
BackgroundProcessor,
|
BackgroundProcessor,
|
||||||
@@ -47,7 +48,16 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init(opts: ProcessorOptions<Track.Kind>) {
|
async init(opts: ProcessorOptions<Track.Kind>) {
|
||||||
return this.processor.init(opts)
|
await this.processor.init(opts)
|
||||||
|
// Embedded (token) mode: the constructor passed the raw imagePath,
|
||||||
|
// whose native load cannot carry the Authorization header. Swap it
|
||||||
|
// for a resolved blob object URL. No-op in regular mode.
|
||||||
|
if (this.opts.type === 'virtual') {
|
||||||
|
const imagePath = await resolveMediaUrl(this.opts.imagePath)
|
||||||
|
if (imagePath !== this.opts.imagePath) {
|
||||||
|
await this.processor.updateTransformerOptions({ imagePath })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async restart(opts: ProcessorOptions<Track.Kind>) {
|
async restart(opts: ProcessorOptions<Track.Kind>) {
|
||||||
@@ -59,6 +69,9 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(opts: ProcessorConfig): Promise<void> {
|
async update(opts: ProcessorConfig): Promise<void> {
|
||||||
|
if (opts.type === 'virtual') {
|
||||||
|
opts = { ...opts, imagePath: await resolveMediaUrl(opts.imagePath) }
|
||||||
|
}
|
||||||
this.opts = opts
|
this.opts = opts
|
||||||
|
|
||||||
const newProcessorType =
|
const newProcessorType =
|
||||||
|
|||||||
+10
-1
@@ -8,6 +8,7 @@ import {
|
|||||||
ProcessorType,
|
ProcessorType,
|
||||||
} from '../blur'
|
} from '../blur'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useResolvedMediaUrls } from '@/features/files/hooks/useResolvedMediaUrls'
|
||||||
import { Button, Dialog, H, P, Text, ToggleButton } from '@/primitives'
|
import { Button, Dialog, H, P, Text, ToggleButton } from '@/primitives'
|
||||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||||
import { HStack, styled } from '@/styled-system/jsx'
|
import { HStack, styled } from '@/styled-system/jsx'
|
||||||
@@ -277,6 +278,14 @@ export const EffectsConfiguration = ({
|
|||||||
filesQ.data.count >= appConfig.background_image.max_count_by_user) ??
|
filesQ.data.count >= appConfig.background_image.max_count_by_user) ??
|
||||||
false
|
false
|
||||||
|
|
||||||
|
// Thumbnails are browser-native loads (CSS url()) which cannot carry
|
||||||
|
// the Authorization header in embedded (token) mode: resolve them. The
|
||||||
|
// processor configs keep the stable raw URLs - they are persisted in
|
||||||
|
// the user choices - and the processors resolve them internally.
|
||||||
|
const resolveMediaUrl = useResolvedMediaUrls(
|
||||||
|
(filesQ.data?.results ?? []).map((file) => file.url)
|
||||||
|
)
|
||||||
|
|
||||||
const getHandleSelectChangeFile = useCallback(
|
const getHandleSelectChangeFile = useCallback(
|
||||||
(file: ApiFileItem) => {
|
(file: ApiFileItem) => {
|
||||||
return async () => {
|
return async () => {
|
||||||
@@ -754,7 +763,7 @@ export const EffectsConfiguration = ({
|
|||||||
bgSize: 'cover',
|
bgSize: 'cover',
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${option.file.url!})`,
|
backgroundImage: `url(${resolveMediaUrl(option.file.url!)})`,
|
||||||
}}
|
}}
|
||||||
data-attr={`toggle-virtual-${option.file.id}`}
|
data-attr={`toggle-virtual-${option.file.id}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { SidePanel } from '../components/SidePanel'
|
|||||||
import { RecordingProvider } from '@/features/recording'
|
import { RecordingProvider } from '@/features/recording'
|
||||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||||
import { ConnectionObserver } from '../components/ConnectionObserver'
|
import { ConnectionObserver } from '../components/ConnectionObserver'
|
||||||
import { MediaStateObserver } from '../components/MediaStateObserver'
|
|
||||||
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
|
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
|
||||||
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
|
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
|
||||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||||
@@ -64,7 +63,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
<>
|
<>
|
||||||
<RoomMetadataSynchronizer />
|
<RoomMetadataSynchronizer />
|
||||||
<ConnectionObserver />
|
<ConnectionObserver />
|
||||||
<MediaStateObserver />
|
|
||||||
<ChatProvider />
|
<ChatProvider />
|
||||||
<VideoResolutionSubscription />
|
<VideoResolutionSubscription />
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const LIVEKIT_AUTH_SCHEME = 'X-LiveKit-Token'
|
||||||
|
|
||||||
|
export const getLiveKitAuthHeaders = (token: string) => {
|
||||||
|
return {
|
||||||
|
Authorization: `${LIVEKIT_AUTH_SCHEME} ${token}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,9 @@ export class CallbackIdHandler {
|
|||||||
private readonly storageKey = 'popup_callback_id'
|
private readonly storageKey = 'popup_callback_id'
|
||||||
|
|
||||||
private generateId(): string {
|
private generateId(): string {
|
||||||
// The id is the only thing guarding /rooms/creation-callback/, which is
|
return (
|
||||||
// unauthenticated, so it comes from the CSPRNG rather than Math.random.
|
Math.random().toString(36).substring(2, 15) +
|
||||||
const bytes = new Uint8Array(16)
|
Math.random().toString(36).substring(2, 15)
|
||||||
crypto.getRandomValues(bytes)
|
|
||||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
|
|
||||||
''
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +1,22 @@
|
|||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { useRef } from 'react'
|
|
||||||
import { Heading } from 'react-aria-components'
|
|
||||||
import { RiSettings3Line, RiDoorOpenLine } from '@remixicon/react'
|
|
||||||
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
||||||
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
|
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
|
||||||
import { Tab, TabList, TabPanel, Tabs } from '@/primitives/Tabs'
|
|
||||||
import { text } from '@/primitives/Text.tsx'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { useUser } from '@/features/auth/api/useUser'
|
import { useUser } from '@/features/auth/api/useUser'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
import { logout } from '@/features/auth/utils/logout'
|
import { logout } from '@/features/auth/utils/logout'
|
||||||
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
|
||||||
import { RoomsTab } from './tabs/RoomsTab'
|
|
||||||
|
|
||||||
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
|
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
|
||||||
|
|
||||||
enum SettingsDialogTabKey {
|
|
||||||
GENERAL = 'general',
|
|
||||||
ROOMS = 'rooms',
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabsStyle = css({
|
|
||||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
|
||||||
width: '50rem', // fixme size copied from meet settings modal
|
|
||||||
marginY: '-1rem', // fixme hacky solution to cancel modal padding
|
|
||||||
maxWidth: '100%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
height: 'calc(100vh - 2rem)',
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabListContainerStyle = css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
borderRight: '1px solid lightGray', // fixme poor color management
|
|
||||||
paddingY: '1rem',
|
|
||||||
paddingLeft: '0.2rem',
|
|
||||||
paddingRight: '1.5rem',
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabPanelContainerStyle = css({
|
|
||||||
display: 'flex',
|
|
||||||
flexGrow: '1',
|
|
||||||
marginTop: '3.5rem',
|
|
||||||
minWidth: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabPanelStyle = css({
|
|
||||||
flexGrow: '1',
|
|
||||||
minWidth: 0,
|
|
||||||
overflowY: 'auto',
|
|
||||||
paddingRight: '1.5rem',
|
|
||||||
paddingBottom: '1rem',
|
|
||||||
})
|
|
||||||
|
|
||||||
export const SettingsDialog = (props: SettingsDialogProps) => {
|
export const SettingsDialog = (props: SettingsDialogProps) => {
|
||||||
const { t, i18n } = useTranslation('settings')
|
const { t, i18n } = useTranslation('settings')
|
||||||
const { user, isLoggedIn } = useUser()
|
const { user, isLoggedIn } = useUser()
|
||||||
const { languagesList, currentLanguage } = useLanguageLabels()
|
const { languagesList, currentLanguage } = useLanguageLabels()
|
||||||
|
|
||||||
const dialogEl = useRef<HTMLDivElement>(null)
|
|
||||||
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
|
|
||||||
|
|
||||||
const userDisplay =
|
const userDisplay =
|
||||||
user?.full_name && user?.email
|
user?.full_name && user?.email
|
||||||
? `${user.full_name} (${user.email})`
|
? `${user.full_name} (${user.email})`
|
||||||
: user?.email
|
: user?.email
|
||||||
|
return (
|
||||||
const generalContent = (
|
<Dialog title={t('dialog.heading')} {...props}>
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
minWidth: '360px',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<H lvl={2}>{t('account.heading')}</H>
|
<H lvl={2}>{t('account.heading')}</H>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<>
|
<>
|
||||||
@@ -104,56 +47,6 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
|||||||
i18n.changeLanguage(lang as string)
|
i18n.changeLanguage(lang as string)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Without tabs there is no rail to host the heading, so keep the plain dialog.
|
|
||||||
if (!isLoggedIn) {
|
|
||||||
return (
|
|
||||||
<Dialog title={t('dialog.heading')} {...props} role="dialog" type="flex">
|
|
||||||
{generalContent}
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
|
|
||||||
<Tabs
|
|
||||||
orientation="vertical"
|
|
||||||
className={tabsStyle}
|
|
||||||
defaultSelectedKey={SettingsDialogTabKey.GENERAL}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={tabListContainerStyle}
|
|
||||||
style={{
|
|
||||||
flex: isWideScreen ? '0 0 16rem' : undefined,
|
|
||||||
paddingTop: !isWideScreen ? '64px' : undefined,
|
|
||||||
paddingRight: !isWideScreen ? '1rem' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isWideScreen && (
|
|
||||||
<Heading slot="title" level={1} className={text({ variant: 'h1' })}>
|
|
||||||
{t('dialog.heading')}
|
|
||||||
</Heading>
|
|
||||||
)}
|
|
||||||
<TabList border={false}>
|
|
||||||
<Tab icon highlight id={SettingsDialogTabKey.GENERAL}>
|
|
||||||
<RiSettings3Line />
|
|
||||||
{isWideScreen && t(`tabs.${SettingsDialogTabKey.GENERAL}`)}
|
|
||||||
</Tab>
|
|
||||||
<Tab icon highlight id={SettingsDialogTabKey.ROOMS}>
|
|
||||||
<RiDoorOpenLine />
|
|
||||||
{isWideScreen && t(`tabs.${SettingsDialogTabKey.ROOMS}`)}
|
|
||||||
</Tab>
|
|
||||||
</TabList>
|
|
||||||
</div>
|
|
||||||
<div className={tabPanelContainerStyle}>
|
|
||||||
<TabPanel id={SettingsDialogTabKey.GENERAL} className={tabPanelStyle}>
|
|
||||||
{generalContent}
|
|
||||||
</TabPanel>
|
|
||||||
<RoomsTab id={SettingsDialogTabKey.ROOMS} />
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useUser } from '@/features/auth/api/useUser'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
|
||||||
import {
|
|
||||||
usePatchUser,
|
|
||||||
patchUserMutationKey,
|
|
||||||
} from '@/features/auth/api/patchUser'
|
|
||||||
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
|
||||||
import { ApiAccessLevel, RoomConfiguration } from '@/features/rooms/api/ApiRoom'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { queryClient } from '@/api/queryClient'
|
|
||||||
import { keys } from '@/api/queryKeys'
|
|
||||||
import { Track } from 'livekit-client'
|
|
||||||
import Source = Track.Source
|
|
||||||
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
|
|
||||||
import { updatePublishSources } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
|
||||||
import { Field, H, Text } from '@/primitives'
|
|
||||||
import { TabPanel } from '@/primitives/Tabs'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { Separator as RACSeparator } from 'react-aria-components'
|
|
||||||
|
|
||||||
type RoomsTabProps = {
|
|
||||||
id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RoomsTab = ({ id }: RoomsTabProps) => {
|
|
||||||
const { t } = useTranslation('settings', { keyPrefix: 'roomDefaults' })
|
|
||||||
const { t: tAdmin } = useTranslation('rooms', {
|
|
||||||
keyPrefix: 'admin',
|
|
||||||
useSuspense: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { user } = useUser()
|
|
||||||
const { data: configData } = useConfig()
|
|
||||||
|
|
||||||
// Optimistic updates: patch the cache immediately so the UI updates
|
|
||||||
// instantly and concurrent saves always build on the latest local state.
|
|
||||||
// Since each PATCH replaces the full JSON config, this avoids overwriting
|
|
||||||
// earlier changes with a stale snapshot.
|
|
||||||
//
|
|
||||||
// No per-request rollback: later requests already include earlier changes.
|
|
||||||
// Once the last in-flight save completes, re-fetch the server state once to
|
|
||||||
// restore the UI if all saves failed.
|
|
||||||
const { mutate: patchUser } = usePatchUser({
|
|
||||||
onMutate: async ({ user: partialUser }) => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: [keys.user] })
|
|
||||||
queryClient.setQueryData<ApiUser | false>([keys.user], (previous) =>
|
|
||||||
previous ? { ...previous, ...partialUser } : previous
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
if (queryClient.isMutating({ mutationKey: patchUserMutationKey }) === 1) {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [keys.user] })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const configuration: RoomConfiguration = useMemo(
|
|
||||||
() => user?.default_room_configuration ?? {},
|
|
||||||
[user?.default_room_configuration]
|
|
||||||
)
|
|
||||||
|
|
||||||
const currentSources: Source[] = useMemo(() => {
|
|
||||||
const defaultSources = configData?.livekit?.default_sources ?? []
|
|
||||||
if (!Array.isArray(configuration?.can_publish_sources)) {
|
|
||||||
return defaultSources
|
|
||||||
}
|
|
||||||
return configuration.can_publish_sources
|
|
||||||
}, [configData, configuration])
|
|
||||||
|
|
||||||
const accessLevel =
|
|
||||||
user?.default_room_access_level ??
|
|
||||||
configData?.resource?.default_access_level ??
|
|
||||||
ApiAccessLevel.PUBLIC
|
|
||||||
|
|
||||||
// Every change saves immediately; the optimistic onMutate above keeps the
|
|
||||||
// cached user (and therefore `configuration`) in sync right away.
|
|
||||||
const saveConfiguration = (newConfiguration: RoomConfiguration) => {
|
|
||||||
if (!user) return
|
|
||||||
patchUser({
|
|
||||||
userId: user.id,
|
|
||||||
user: { default_room_configuration: newConfiguration },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateSource = (sources: Source[], enabled: boolean) =>
|
|
||||||
saveConfiguration({
|
|
||||||
...configuration,
|
|
||||||
can_publish_sources: updatePublishSources(
|
|
||||||
currentSources,
|
|
||||||
sources,
|
|
||||||
enabled
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
const isMicrophoneEnabled = isSubsetOf([Source.Microphone], currentSources)
|
|
||||||
const isCameraEnabled = isSubsetOf([Source.Camera], currentSources)
|
|
||||||
const isScreenShareEnabled = isSubsetOf(
|
|
||||||
[Source.ScreenShare, Source.ScreenShareAudio],
|
|
||||||
currentSources
|
|
||||||
)
|
|
||||||
const isMutingEnabled = configuration?.everyone_can_mute ?? true
|
|
||||||
|
|
||||||
const saveAccessLevel = (newAccessLevel: ApiAccessLevel) => {
|
|
||||||
if (!user) return
|
|
||||||
patchUser({
|
|
||||||
userId: user.id,
|
|
||||||
user: { default_room_access_level: newAccessLevel },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TabPanel padding={'md'} flex id={id}>
|
|
||||||
<H lvl={2}>{t('heading')}</H>
|
|
||||||
<Text variant="note" margin={'md'}>
|
|
||||||
{t('description')}
|
|
||||||
</Text>
|
|
||||||
<RACSeparator
|
|
||||||
className={css({
|
|
||||||
border: 'none',
|
|
||||||
height: '1px',
|
|
||||||
width: '100%',
|
|
||||||
flexShrink: 0,
|
|
||||||
background: 'greyscale.250',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<H
|
|
||||||
lvl={3}
|
|
||||||
variant={'h2'}
|
|
||||||
className={css({
|
|
||||||
fontWeight: 500,
|
|
||||||
})}
|
|
||||||
margin="sm"
|
|
||||||
>
|
|
||||||
{tAdmin('moderation.title')}
|
|
||||||
</H>
|
|
||||||
<Text
|
|
||||||
variant="note"
|
|
||||||
wrap="balance"
|
|
||||||
className={css({
|
|
||||||
textStyle: 'sm',
|
|
||||||
})}
|
|
||||||
margin={'md'}
|
|
||||||
>
|
|
||||||
{tAdmin('moderation.description')}
|
|
||||||
</Text>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={tAdmin('moderation.microphone.label')}
|
|
||||||
isSelected={isMicrophoneEnabled}
|
|
||||||
onChange={(enabled) => updateSource([Source.Microphone], enabled)}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={tAdmin('moderation.camera.label')}
|
|
||||||
isSelected={isCameraEnabled}
|
|
||||||
onChange={(enabled) => updateSource([Source.Camera], enabled)}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={tAdmin('moderation.screenshare.label')}
|
|
||||||
isSelected={isScreenShareEnabled}
|
|
||||||
onChange={(enabled) =>
|
|
||||||
updateSource([Source.ScreenShare, Source.ScreenShareAudio], enabled)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={tAdmin('moderation.mute.label')}
|
|
||||||
isSelected={isMutingEnabled}
|
|
||||||
onChange={(enabled) =>
|
|
||||||
saveConfiguration({ ...configuration, everyone_can_mute: enabled })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<RACSeparator
|
|
||||||
className={css({
|
|
||||||
border: 'none',
|
|
||||||
height: '1px',
|
|
||||||
width: '100%',
|
|
||||||
flexShrink: 0,
|
|
||||||
marginY: '1rem',
|
|
||||||
background: 'greyscale.250',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<H
|
|
||||||
lvl={3}
|
|
||||||
variant={'h2'}
|
|
||||||
className={css({
|
|
||||||
fontWeight: 500,
|
|
||||||
})}
|
|
||||||
margin="sm"
|
|
||||||
>
|
|
||||||
{tAdmin('access.title')}
|
|
||||||
</H>
|
|
||||||
<Field
|
|
||||||
type="radioGroup"
|
|
||||||
label={tAdmin('access.type')}
|
|
||||||
value={accessLevel}
|
|
||||||
labelProps={{
|
|
||||||
className: css({
|
|
||||||
fontSize: '1rem',
|
|
||||||
paddingBottom: '1rem',
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
onChange={(value) => saveAccessLevel(value as ApiAccessLevel)}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
value: ApiAccessLevel.PUBLIC,
|
|
||||||
label: tAdmin('access.levels.public.label'),
|
|
||||||
description: tAdmin('access.levels.public.description'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: ApiAccessLevel.TRUSTED,
|
|
||||||
label: tAdmin('access.levels.trusted.label'),
|
|
||||||
description: tAdmin('access.levels.trusted.description'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: ApiAccessLevel.RESTRICTED,
|
|
||||||
label: tAdmin('access.levels.restricted.label'),
|
|
||||||
description: tAdmin('access.levels.restricted.description'),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</TabPanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import type { ApiError } from '@/api/ApiError'
|
import type { ApiError } from '@/api/ApiError'
|
||||||
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { getLiveKitAuthHeaders } from '@/features/rooms/utils/getLiveKitAuthHeaders'
|
||||||
|
|
||||||
export interface StartSubtitleParams {
|
export interface StartSubtitleParams {
|
||||||
id: string
|
id: string
|
||||||
@@ -14,9 +15,7 @@ const startSubtitle = ({
|
|||||||
}: StartSubtitleParams): Promise<ApiRoom> => {
|
}: StartSubtitleParams): Promise<ApiRoom> => {
|
||||||
return fetchApi(`rooms/${id}/start-subtitle/`, {
|
return fetchApi(`rooms/${id}/start-subtitle/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: getLiveKitAuthHeaders(token),
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useLocationProperty } from 'wouter/use-browser-location'
|
||||||
|
|
||||||
|
const hashSelector = () =>
|
||||||
|
typeof window !== 'undefined' ? window.location.hash : ''
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive window.location.hash, subscribed to wouter's navigation
|
||||||
|
* events (the same low-level primitive wouter builds useSearch upon).
|
||||||
|
*/
|
||||||
|
export const useHash = (): string => useLocationProperty(hashSelector, () => '')
|
||||||
@@ -179,11 +179,6 @@
|
|||||||
"notifications": "Benachrichtigungen",
|
"notifications": "Benachrichtigungen",
|
||||||
"accessibility": "Barrierefreiheit",
|
"accessibility": "Barrierefreiheit",
|
||||||
"transcription": "Transkription",
|
"transcription": "Transkription",
|
||||||
"shortcuts": "Tastenkürzel",
|
"shortcuts": "Tastenkürzel"
|
||||||
"rooms": "Räume"
|
|
||||||
},
|
|
||||||
"roomDefaults": {
|
|
||||||
"heading": "Standardeinstellungen für Räume",
|
|
||||||
"description": "Wählen Sie die Einstellungen, die standardmäßig auf neue von Ihnen erstellte Räume angewendet werden. Sie können sie für jedes Meeting weiterhin in den Moderationseinstellungen ändern."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,11 +179,6 @@
|
|||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"accessibility": "Accessibility",
|
"accessibility": "Accessibility",
|
||||||
"transcription": "Transcription",
|
"transcription": "Transcription",
|
||||||
"shortcuts": "Shortcuts",
|
"shortcuts": "Shortcuts"
|
||||||
"rooms": "Rooms"
|
|
||||||
},
|
|
||||||
"roomDefaults": {
|
|
||||||
"heading": "Default room settings",
|
|
||||||
"description": "Choose the settings applied by default to the new rooms you create. You can still change them for each meeting from the host settings."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,11 +179,6 @@
|
|||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"accessibility": "Accessibilité",
|
"accessibility": "Accessibilité",
|
||||||
"transcription": "Transcription",
|
"transcription": "Transcription",
|
||||||
"shortcuts": "Raccourcis",
|
"shortcuts": "Raccourcis"
|
||||||
"rooms": "Réunions"
|
|
||||||
},
|
|
||||||
"roomDefaults": {
|
|
||||||
"heading": "Paramètres par défaut des réunions",
|
|
||||||
"description": "Choisissez les paramètres appliqués par défaut aux nouvelles réunions que vous créez. Vous pourrez toujours les modifier pour chaque réunion depuis les paramètres d’administration."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,11 +179,6 @@
|
|||||||
"notifications": "Meldingen",
|
"notifications": "Meldingen",
|
||||||
"accessibility": "Toegankelijkheid",
|
"accessibility": "Toegankelijkheid",
|
||||||
"transcription": "Transcriptie",
|
"transcription": "Transcriptie",
|
||||||
"shortcuts": "Sneltoetsen",
|
"shortcuts": "Sneltoetsen"
|
||||||
"rooms": "Vergaderingen"
|
|
||||||
},
|
|
||||||
"roomDefaults": {
|
|
||||||
"heading": "Standaardinstellingen voor vergaderingen",
|
|
||||||
"description": "Kies de instellingen die standaard worden toegepast op nieuwe vergaderingen die u aanmaakt. U kunt ze voor elke vergadering nog steeds wijzigen via de hostinstellingen."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { proxy } from 'valtio'
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
accessToken: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User access token for the embedded (iframe) mode.
|
||||||
|
*
|
||||||
|
* When Meet is rendered inside an iframe, third-party session cookies are
|
||||||
|
* blocked: the host application passes a single-use transit code in the
|
||||||
|
* URL fragment, exchanged at startup for a user access token (see
|
||||||
|
* features/auth/api/exchangeAccessToken) that authenticates every api
|
||||||
|
* call exactly like a session cookie would.
|
||||||
|
*
|
||||||
|
* The token deliberately lives in this in-memory store only: unlike other
|
||||||
|
* stores, it is never persisted (no subscribe/localStorage) and never
|
||||||
|
* appears in a URL. It is lost on reload, in which case the host page is
|
||||||
|
* expected to mint a fresh transit code.
|
||||||
|
*
|
||||||
|
* A non-null token also tells the app it is running in embedded mode:
|
||||||
|
* components can react to it with useSnapshot(accessTokenStore).
|
||||||
|
*/
|
||||||
|
export const accessTokenStore = proxy<State>({
|
||||||
|
accessToken: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const setAccessToken = (accessToken: string | null) => {
|
||||||
|
accessTokenStore.accessToken = accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAccessToken = () => accessTokenStore.accessToken
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { proxy } from 'valtio'
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
participantIds: Record<string, string | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const layoutStore = proxy<State>({
|
||||||
|
participantIds: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const setLobbyParticipantId = (
|
||||||
|
roomId: string,
|
||||||
|
participantId: string
|
||||||
|
) => {
|
||||||
|
layoutStore.participantIds[roomId] = participantId
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearParticipantId = (roomId: string) => {
|
||||||
|
delete layoutStore.participantIds[roomId]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getLobbyParticipantId = (roomId: string) =>
|
||||||
|
layoutStore.participantIds[roomId]
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { proxy, subscribe } from 'valtio'
|
import { proxy, subscribe } from 'valtio'
|
||||||
|
import { initializeAccessTokenFromFragment } from '@/features/auth/api/exchangeAccessToken'
|
||||||
|
import { getAccessToken } from '@/stores/accessToken'
|
||||||
import {
|
import {
|
||||||
ProcessorConfig,
|
ProcessorConfig,
|
||||||
ProcessorType,
|
ProcessorType,
|
||||||
@@ -48,10 +50,19 @@ if (userChoicesStore.processorConfig?.type === ProcessorType.VIRTUAL) {
|
|||||||
// we restore clear the processor config to avoid displaying a black screen.
|
// we restore clear the processor config to avoid displaying a black screen.
|
||||||
userChoicesStore.processorConfig = undefined
|
userChoicesStore.processorConfig = undefined
|
||||||
} else if (userChoicesStore.processorConfig.fileId) {
|
} else if (userChoicesStore.processorConfig.fileId) {
|
||||||
|
// Embedded (token) mode: this module loads before the transit code
|
||||||
|
// exchange has settled - wait for it, and carry the Bearer header,
|
||||||
|
// otherwise the check below would wrongly clear the config.
|
||||||
|
await initializeAccessTokenFromFragment()
|
||||||
|
const accessToken = getAccessToken()
|
||||||
|
|
||||||
// Checking if the image is still available / accessible
|
// Checking if the image is still available / accessible
|
||||||
await fetch(userChoicesStore.processorConfig.imagePath, {
|
await fetch(userChoicesStore.processorConfig.imagePath, {
|
||||||
// We bypass the cache to ensure we have access
|
// We bypass the cache to ensure we have access
|
||||||
cache: 'reload',
|
cache: 'reload',
|
||||||
|
...(accessToken && {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
// if we cannot fetch the image (likely a 401 from the backend because
|
// if we cannot fetch the image (likely a 401 from the backend because
|
||||||
|
|||||||
@@ -143,9 +143,13 @@ def test_media_info_ignores_empty_stream_entry(monkeypatch: pytest.MonkeyPatch)
|
|||||||
|
|
||||||
def test_extract_audio_from_video():
|
def test_extract_audio_from_video():
|
||||||
"""Test that extract_audio_from_video can extract audio from a video file."""
|
"""Test that extract_audio_from_video can extract audio from a video file."""
|
||||||
path = extract_audio_from_media(MEDIA_INFO_SAMPLE_VISIO)
|
path = None
|
||||||
# A bit of cleanup logic since this is not a generator
|
# A bit of cleanup logic since this is not a generator
|
||||||
try:
|
try:
|
||||||
|
path = extract_audio_from_media(MEDIA_INFO_SAMPLE_VISIO)
|
||||||
assert path.name.endswith(".m4a")
|
assert path.name.endswith(".m4a")
|
||||||
|
except Exception as e:
|
||||||
|
pytest.fail(f"Failed to extract audio from video: {e}")
|
||||||
finally:
|
finally:
|
||||||
path.unlink(missing_ok=True)
|
if path and path.exists():
|
||||||
|
path.unlink()
|
||||||
|
|||||||
Reference in New Issue
Block a user