mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-13 12:17:24 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 932f400a2e | |||
| a3f22e0a4f | |||
| 0a879a96fd | |||
| ed7fa7312a | |||
| 2f948fd53a | |||
| e701a89036 | |||
| 7fbcbc89ed |
+1
-3
@@ -14,12 +14,11 @@ and this project adheres to
|
||||
- ✨(frontend) add configurable documentation menu item
|
||||
- ✨(frontend) allow promoting authenticated participants
|
||||
- ✨(frontend) introduce an "unauthenticated" participant badge
|
||||
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(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 @tanstack/react-query from 5.101.0 to 5.101.1
|
||||
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
||||
@@ -27,7 +26,6 @@ and this project adheres to
|
||||
- 📝(legal) update terms of service
|
||||
- 💄(frontend) render Avatar initials in uppercase
|
||||
- 💄(frontend) improve participant name rendering in the list
|
||||
- 🚚(backend) rename TelephonyService to SIPManagement
|
||||
|
||||
## Fixed
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ COMPOSE_EXEC = $(COMPOSE) exec
|
||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||
COMPOSE_RUN_LINT = $(COMPOSE_RUN) --no-deps app-dev
|
||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||
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
|
||||
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
|
||||
PATH_FRONT = ./src/frontend
|
||||
|
||||
@@ -81,6 +72,7 @@ create-env-files: \
|
||||
env.d/development/common \
|
||||
env.d/development/crowdin \
|
||||
env.d/development/postgresql \
|
||||
env.d/development/kc_postgresql \
|
||||
env.d/development/summary \
|
||||
env.d/development/kube-secret \
|
||||
env.d/development/multi_user_transcriber \
|
||||
@@ -132,7 +124,6 @@ logs: ## display app-dev logs (follow mode)
|
||||
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 nginx
|
||||
@$(COMPOSE) up -d livekit
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_DB)
|
||||
.PHONY: run-backend
|
||||
@@ -197,23 +188,27 @@ demo: ## flush db then create a demo for load testing purpose
|
||||
@$(MANAGE) create_demo
|
||||
.PHONY: demo
|
||||
|
||||
# Nota bene: Black should come after isort just in case they don't agree...
|
||||
lint: ## lint back-end python sources
|
||||
@$(COMPOSE_RUN_LINT) sh -c "$(LINT_BACK)"
|
||||
lint: \
|
||||
lint-ruff-format \
|
||||
lint-ruff-check \
|
||||
lint-pylint
|
||||
.PHONY: lint
|
||||
|
||||
lint-ruff-format: ## format back-end python sources with ruff
|
||||
@echo 'lint:ruff-format started…'
|
||||
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_FORMAT)
|
||||
@$(COMPOSE_RUN_APP) ruff format .
|
||||
.PHONY: lint-ruff-format
|
||||
|
||||
lint-ruff-check: ## lint back-end python sources with ruff
|
||||
@echo 'lint:ruff-check started…'
|
||||
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_CHECK)
|
||||
@$(COMPOSE_RUN_APP) ruff check . --fix
|
||||
.PHONY: lint-ruff-check
|
||||
|
||||
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
||||
@echo 'lint:pylint started…'
|
||||
@$(COMPOSE_RUN_LINT) $(LINT_PYLINT)
|
||||
@$(COMPOSE_RUN_APP) pylint meet demo core
|
||||
.PHONY: lint-pylint
|
||||
|
||||
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
||||
@@ -287,6 +282,9 @@ env.d/development/common:
|
||||
env.d/development/postgresql:
|
||||
cp -n env.d/development/postgresql.dist env.d/development/postgresql
|
||||
|
||||
env.d/development/kc_postgresql:
|
||||
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
|
||||
|
||||
env.d/development/summary:
|
||||
cp -n env.d/development/summary.dist env.d/development/summary
|
||||
|
||||
@@ -391,6 +389,12 @@ build-k8s-cluster: \
|
||||
./bin/start-kind.sh
|
||||
.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
|
||||
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||
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')
|
||||
|
||||
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
|
||||
+37
-12
@@ -85,6 +85,7 @@ services:
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- redis
|
||||
- livekit
|
||||
- createbuckets
|
||||
- createwebhook
|
||||
extra_hosts:
|
||||
@@ -96,7 +97,7 @@ services:
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
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:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
@@ -131,7 +132,7 @@ services:
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
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:
|
||||
- DJANGO_CONFIGURATION=Demo
|
||||
env_file:
|
||||
@@ -147,7 +148,7 @@ services:
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- dex
|
||||
- keycloak
|
||||
- app-dev
|
||||
networks:
|
||||
- resource-server
|
||||
@@ -187,16 +188,40 @@ services:
|
||||
volumes:
|
||||
- ".:/app"
|
||||
|
||||
# OIDC provider for the development stack. Dex uses in-memory storage, so it
|
||||
# needs no database and no volume: restarting it rotates the signing keys and
|
||||
# drops every active session, which is fine locally.
|
||||
dex:
|
||||
image: dexidp/dex:v2.45.1
|
||||
command: ["dex", "serve", "/etc/dex/config.yaml"]
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
volumes:
|
||||
- ./docker/auth/dex.yaml:/etc/dex/config.yaml:ro
|
||||
expose:
|
||||
- "5556"
|
||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
||||
command:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
- --import-realm
|
||||
- --proxy=edge
|
||||
- --hostname-url=http://localhost:8083
|
||||
- --hostname-admin-url=http://localhost:8083/
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
KC_DB: postgres
|
||||
KC_DB_URL_HOST: kc_postgresql
|
||||
KC_DB_URL_DATABASE: keycloak
|
||||
KC_DB_PASSWORD: pass
|
||||
KC_DB_USERNAME: meet
|
||||
KC_DB_SCHEMA: public
|
||||
PROXY_ADDRESS_FORWARDING: 'true'
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
# Dex configuration for the local development stack.
|
||||
#
|
||||
# This file replaces the former Keycloak "meet" realm (docker/auth/realm.json).
|
||||
# The client and the users below are a one-to-one port of that realm.
|
||||
#
|
||||
# Storage is in-memory on purpose: no database container, no volume, ~30 MB of
|
||||
# RAM instead of the Keycloak + PostgreSQL pair. The trade-off is that
|
||||
# restarting the `dex` service rotates the signing keys and drops every active
|
||||
# session, so you have to log in again.
|
||||
|
||||
# Must match OIDC_OP_URL in env.d/development/common. Dex serves all of its
|
||||
# endpoints under the path component of the issuer, i.e. /dex/auth, /dex/token,
|
||||
# /dex/keys, /dex/userinfo and /dex/.well-known/openid-configuration.
|
||||
issuer: http://localhost:8083/dex
|
||||
|
||||
storage:
|
||||
type: memory
|
||||
|
||||
web:
|
||||
http: 0.0.0.0:5556
|
||||
allowedOrigins:
|
||||
- http://localhost:3000
|
||||
- http://localhost:8071
|
||||
|
||||
logger:
|
||||
level: info
|
||||
format: text
|
||||
|
||||
oauth2:
|
||||
# Logging in implies authorization: no consent screen, as with the realm.
|
||||
skipApprovalScreen: true
|
||||
|
||||
expiry:
|
||||
idTokens: 24h
|
||||
signingKeys: 6h
|
||||
|
||||
staticClients:
|
||||
- id: meet
|
||||
name: Meet
|
||||
secret: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
# Dex does not support wildcards: every callback URL must be listed
|
||||
# explicitly. The path is the one exposed by mozilla-django-oidc through
|
||||
# lasuite.oidc_login, mounted under api/<version>/ by core.urls.
|
||||
redirectURIs:
|
||||
- http://localhost:3000/api/v1.0/callback/
|
||||
- http://localhost:3200/api/v1.0/callback/
|
||||
- http://localhost:8070/api/v1.0/callback/
|
||||
- http://localhost:8071/api/v1.0/callback/
|
||||
- http://localhost:8088/api/v1.0/callback/
|
||||
|
||||
enablePasswordDB: true
|
||||
|
||||
# Dex's local password database authenticates on the *email address*, not on
|
||||
# the username, so the login is now "meet@meet.world" (password unchanged).
|
||||
#
|
||||
# Hashes are bcrypt with cost 10, the minimum dex accepts. To add a user:
|
||||
# htpasswd -bnBC 10 "" <password> | tr -d ':\n'
|
||||
staticPasswords:
|
||||
- email: meet@meet.world
|
||||
hash: "$2b$10$qVCVTnaF67S/7a.pQM4djOgpj61FxD/yz6LoiQdtX0TKISelAfZxC"
|
||||
username: meet
|
||||
name: John Doe
|
||||
preferredUsername: John
|
||||
userID: 4ad6106f-a64f-43eb-ad0e-380d2cad9a9d
|
||||
groups:
|
||||
- user
|
||||
|
||||
- email: user@chromium.e2e
|
||||
hash: "$2b$10$4Rs3Jd/Q23RM09g7c1Z/yeGmEjoAYlMKXDBkkjERaRDlz0Doiwl2q"
|
||||
username: user-e2e-chromium
|
||||
name: E2E Chromium
|
||||
preferredUsername: E2E
|
||||
userID: 1cd83dfc-153f-4987-b8a6-a2ac72d39122
|
||||
groups:
|
||||
- user
|
||||
|
||||
- email: user@webkit.e2e
|
||||
hash: "$2b$10$D50UlVVMA7qWlB.Pw8P02eMJpo8qfwWuGiA63IeTqq/3mAE7RyH3m"
|
||||
username: user-e2e-webkit
|
||||
name: E2E Webkit
|
||||
preferredUsername: E2E
|
||||
userID: 9b9bd390-a6e5-42f8-a06d-9a11ede7bb8c
|
||||
groups:
|
||||
- user
|
||||
|
||||
- email: user@firefox.e2e
|
||||
hash: "$2b$10$0D8WW7.KXMkzSY2b9JhwYeIM3WkTPQCwGd36/G3TZ/HHh4ObCVRga"
|
||||
username: user-e2e-firefox
|
||||
name: E2E Firefox
|
||||
preferredUsername: E2E
|
||||
userID: ec3e8750-7629-42f1-a0c3-6e23968a2fba
|
||||
groups:
|
||||
- user
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,7 @@ server {
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://dex:5556;
|
||||
proxy_pass http://keycloak:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
version: '3'
|
||||
|
||||
# You can add any necessary service here that will join the same docker network
|
||||
# sharing the OIDC provider. Services added to the 'meet_resource-server'
|
||||
# network will be able to communicate with dex (through nginx) and the backend
|
||||
# on that network.
|
||||
# sharing keycloak. Services added to the 'meet_resource-server' network will be
|
||||
# able to communicate with keycloak and the backend on that network.
|
||||
services:
|
||||
# busybox service is only used for testing purposes. It provides curl to test
|
||||
# connectivity to the backend and the OIDC provider. Replace this with your
|
||||
# relevant application services that need to communicate with them.
|
||||
# connectivity to the backend and keycloak services. Replace this with your
|
||||
# relevant application services that need to communicate with keycloak.
|
||||
busybox:
|
||||
image: alpine:latest
|
||||
privileged: true
|
||||
|
||||
@@ -71,12 +71,8 @@ $ make bootstrap FLUSH_ARGS='--no-input'
|
||||
|
||||
2. Access the project:
|
||||
- The frontend is available at [http://localhost:3000](http://localhost:3000) with the default credentials:
|
||||
- email: meet@meet.world
|
||||
- username: meet
|
||||
- password: meet
|
||||
|
||||
Authentication is handled by [dex](https://dexidp.io/), configured in
|
||||
`docker/auth/dex.yaml`. It logs you in by email address, and its storage is
|
||||
in-memory: restarting the `dex` container logs everyone out.
|
||||
- The Django backend is available at [http://localhost:8071](http://localhost:8071)
|
||||
|
||||
---
|
||||
@@ -147,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/).
|
||||
|
||||
### 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).
|
||||
|
||||
@@ -31,35 +31,24 @@ MEDIA_BASE_URL=http://localhost:3000
|
||||
FILE_UPLOAD_ENABLED=True
|
||||
|
||||
# OIDC
|
||||
# Provider is dex (docker/auth/dex.yaml), served behind nginx on port 8083.
|
||||
# Endpoints reached by the browser use localhost, the ones called server-side
|
||||
# by the backend use the nginx service name.
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/dex/keys
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/dex/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/dex/token
|
||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/dex/userinfo
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/dex/token/introspect
|
||||
OIDC_OP_URL=http://localhost:8083/dex
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/meet/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/userinfo
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token/introspect
|
||||
OIDC_OP_URL=http://localhost:8083/realms/meet
|
||||
|
||||
OIDC_RP_CLIENT_ID=meet
|
||||
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO=RS256
|
||||
# "profile" is required: dex only emits the name claims under that scope.
|
||||
OIDC_RP_SCOPES="openid email profile"
|
||||
|
||||
# Dex exposes the display name through the standard "name" and
|
||||
# "preferred_username" claims and never emits given_name/family_name.
|
||||
OIDC_USERINFO_FULLNAME_FIELDS=name
|
||||
OIDC_USERINFO_SHORTNAME_FIELD=preferred_username
|
||||
OIDC_RP_SCOPES="openid email"
|
||||
|
||||
LOGIN_REDIRECT_URL=http://localhost:3000
|
||||
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
|
||||
LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
|
||||
# Dex has no notion of ACR, the eIDAS level requested from ProConnect in
|
||||
# production is meaningless here and would just be ignored.
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={}
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
OIDC_RS_CLIENT_ID=meet
|
||||
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
@@ -74,7 +63,7 @@ ALLOW_UNREGISTERED_ROOMS=False
|
||||
|
||||
# Recording
|
||||
RECORDING_ENABLE=True
|
||||
RECORDING_STORAGE_EVENT_ENABLE=False
|
||||
RECORDING_STORAGE_EVENT_ENABLE=True
|
||||
RECORDING_STORAGE_EVENT_TOKEN=password
|
||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
||||
SUMMARY_SERVICE_API_TOKEN=password
|
||||
@@ -96,10 +85,6 @@ RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
# Telephony
|
||||
ROOM_TELEPHONY_ENABLED=True
|
||||
|
||||
# RoomKit
|
||||
# ROOMKIT_ENABLED = True
|
||||
# ROOMKIT_SERVER_TO_SERVER_API_TOKEN = ThisIsAnExampleKeyForDevPurposeOnly
|
||||
|
||||
# Metadata
|
||||
METADATA_COLLECTOR_ENABLED=True
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Postgresql db container configuration
|
||||
POSTGRES_DB=keycloak
|
||||
POSTGRES_USER=meet
|
||||
POSTGRES_PASSWORD=pass
|
||||
|
||||
# App database configuration
|
||||
DB_HOST=kc_postgresql
|
||||
DB_NAME=keycloak
|
||||
DB_USER=meet
|
||||
DB_PASSWORD=pass
|
||||
DB_PORT=5433
|
||||
Generated
+5
-5
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next": "^26.3.4",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
@@ -9364,9 +9364,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"version": "26.3.4",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
|
||||
"integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -9383,7 +9383,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "26.3.6",
|
||||
"i18next": "26.3.4",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,3 @@ class AnalyticsEvent(StrEnum):
|
||||
|
||||
# Rooms
|
||||
ROOM_CREATED = "room_created"
|
||||
|
||||
# Roomkit (meeting-room SIP devices)
|
||||
ROOMKIT_JOINED = "roomkit_joined"
|
||||
|
||||
@@ -16,7 +16,7 @@ class FeatureFlag:
|
||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||
"addons": "ADDONS_ENABLED",
|
||||
"application": "APPLICATION_ENABLED",
|
||||
"roomkit": "ROOMKIT_ENABLED",
|
||||
"user_access_token": "USER_ACCESS_TOKEN_ENABLED",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -273,6 +273,11 @@ class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||
"""Validate request entry data."""
|
||||
|
||||
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):
|
||||
@@ -580,3 +585,25 @@ class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
||||
# useless bad requests
|
||||
type = 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."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from lasuite.drf.throttling import MonitoredThrottleMixin
|
||||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
from . import serializers
|
||||
|
||||
|
||||
def sentry_monitoring_throttle_failure(message):
|
||||
"""Log when a failure occurs to detect rate limiting issues."""
|
||||
@@ -42,13 +42,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
def get_cache_key(self, request, view):
|
||||
"""Use the lobby participant cookie ID as the throttle cache key.
|
||||
|
||||
Only throttle if a cookie is already set. If no cookie exists yet,
|
||||
return None to skip throttling — the cookie will be set on the first
|
||||
response, and throttling will apply from the second request onward.
|
||||
Only throttle requests carrying a participant identifier. The
|
||||
identifier is returned by the first request-entry response and
|
||||
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
|
||||
multiple users behind the same NAT/proxy, and is consistent with how
|
||||
LobbyService identifies participants.
|
||||
Keying on the identifier rather than the IP address prevents
|
||||
penalising multiple users behind the same NAT/proxy, and is
|
||||
consistent with how the lobby identifies participants.
|
||||
|
||||
Note: as per DRF documentation, application-level throttling is not a
|
||||
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:
|
||||
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:
|
||||
return None # No throttling for cookieless requests
|
||||
participant_id = serializer.validated_data.get("participant_id")
|
||||
|
||||
if not participant_id:
|
||||
return None # No throttling for unidentified requests
|
||||
|
||||
return self.cache_format % {
|
||||
"scope": self.scope,
|
||||
@@ -75,13 +80,12 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
scope = "creation_callback"
|
||||
|
||||
|
||||
class RoomKitJoinRateThrottle(MonitoredUserRateThrottle):
|
||||
"""Throttle the LiveKit SIP module requesting roomkit joins.
|
||||
class ExchangeAccessTokenAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle anonymous transit code exchange attempts.
|
||||
|
||||
The roomkit endpoints are authenticated as a machine user, so all requests
|
||||
share a single throttle bucket. This is not a security measure against
|
||||
brute-force attacks but a guard against accidental hammering from a buggy
|
||||
SIP module.
|
||||
Abuse mitigation only, not a security boundary: DRF throttling is
|
||||
best-effort. The security of the exchange rests on the codes'
|
||||
entropy and single use.
|
||||
"""
|
||||
|
||||
scope = "roomkit_join"
|
||||
scope = "exchange_access_token"
|
||||
|
||||
@@ -69,6 +69,7 @@ from core.recording.worker.mediator import (
|
||||
WorkerServiceMediator,
|
||||
)
|
||||
from core.services.invitation import InvitationService
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.livekit_events import (
|
||||
LiveKitEventsService,
|
||||
LiveKitWebhookError,
|
||||
@@ -93,6 +94,7 @@ from core.services.room_roles import (
|
||||
RoomRoleService,
|
||||
)
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.services.transit_code import TransitCodeService
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||
@@ -229,6 +231,76 @@ class UserViewSet(
|
||||
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(
|
||||
mixins.CreateModelMixin,
|
||||
@@ -499,10 +571,7 @@ class RoomViewSet(
|
||||
request=request,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
||||
lobby_service.prepare_response(response, participant.id)
|
||||
|
||||
return response
|
||||
return drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
|
||||
@@ -9,6 +9,8 @@ from rest_framework import authentication, exceptions
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
LIVEKIT_AUTH_SCHEME = "X-LiveKit-Token"
|
||||
|
||||
|
||||
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
||||
"""Authenticate using LiveKit token and load the associated Django user."""
|
||||
@@ -20,9 +22,14 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
||||
return None # No authentication attempted
|
||||
|
||||
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(
|
||||
"Authorization header must be: Bearer <token>"
|
||||
f"Authorization header must be: {LIVEKIT_AUTH_SCHEME} <token>"
|
||||
)
|
||||
|
||||
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):
|
||||
"""Permissions applying to the room API endpoint."""
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from rest_framework import (
|
||||
from core import analytics, api, models
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
from ..services.provisional_user_service import (
|
||||
ProvisionalUserCreationDisabledError,
|
||||
@@ -218,3 +219,62 @@ class RoomViewSet(
|
||||
"$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),
|
||||
),
|
||||
]
|
||||
@@ -429,14 +429,7 @@ class Room(Resource):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Generate a unique n-digit pin code for new rooms."""
|
||||
|
||||
# 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
|
||||
):
|
||||
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
|
||||
self.pin_code = self.generate_unique_pin_code(
|
||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||
)
|
||||
@@ -776,6 +769,7 @@ class ApplicationScope(models.TextChoices):
|
||||
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
|
||||
ROOMS_UPDATE = "rooms:update", _("Update rooms")
|
||||
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
|
||||
USERS_SESSION = "users:session", _("Create user session tokens")
|
||||
|
||||
|
||||
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,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from .sip_management import SIPException, SIPManagement
|
||||
from .telephony import TelephonyException, TelephonyService
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -107,7 +107,7 @@ class LiveKitEventsService:
|
||||
)
|
||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||
self.lobby_service = LobbyService()
|
||||
self.sip_management = SIPManagement()
|
||||
self.telephony_service = TelephonyService()
|
||||
self.recording_events = RecordingEventsService()
|
||||
|
||||
self._filter_regex = None
|
||||
@@ -245,12 +245,12 @@ class LiveKitEventsService:
|
||||
except models.Room.DoesNotExist as 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:
|
||||
self.sip_management.ensure_dispatch_rule(room)
|
||||
except SIPException as e:
|
||||
self.telephony_service.create_dispatch_rule(room)
|
||||
except TelephonyException as e:
|
||||
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
|
||||
|
||||
def _handle_room_finished(self, data):
|
||||
@@ -265,12 +265,12 @@ class LiveKitEventsService:
|
||||
)
|
||||
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:
|
||||
self.sip_management.delete_dispatch_rule(room_id)
|
||||
except SIPException as e:
|
||||
self.telephony_service.delete_dispatch_rule(room_id)
|
||||
except TelephonyException as e:
|
||||
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
|
||||
|
||||
try:
|
||||
|
||||
@@ -86,23 +86,6 @@ class LobbyService:
|
||||
"""Generate cache key for participant(s) data."""
|
||||
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
|
||||
def can_bypass_lobby(room, user, role) -> bool:
|
||||
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||
@@ -135,6 +118,7 @@ class LobbyService:
|
||||
room: models.Room,
|
||||
request,
|
||||
username: str,
|
||||
participant_id: Optional[uuid.UUID] = None,
|
||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||
"""Request entry to a room for a participant.
|
||||
|
||||
@@ -149,22 +133,20 @@ class LobbyService:
|
||||
5. If denied, do nothing.
|
||||
"""
|
||||
|
||||
participant_id = self._get_or_create_participant_id(request)
|
||||
participant = self._get_participant(room.id, participant_id)
|
||||
participant = None
|
||||
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)
|
||||
user_role = room.get_role(request.user)
|
||||
|
||||
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
||||
if participant is None:
|
||||
participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=utils.generate_color(participant_id),
|
||||
)
|
||||
else:
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
self._save_participant(room.id, participant)
|
||||
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
@@ -172,18 +154,18 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
participant_id=participant_id,
|
||||
participant_id=participant.id,
|
||||
role=user_role,
|
||||
)
|
||||
return participant, livekit_config
|
||||
|
||||
livekit_config = None
|
||||
|
||||
if participant is None:
|
||||
participant = self.enter(room.id, participant_id, username)
|
||||
if is_new_participant:
|
||||
self._notify_entry_request(room_id)
|
||||
|
||||
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:
|
||||
# wrongly named, contains access token to join a room
|
||||
@@ -193,7 +175,7 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
participant_id=participant_id,
|
||||
participant_id=participant.id,
|
||||
role=user_role,
|
||||
)
|
||||
|
||||
@@ -210,27 +192,36 @@ class LobbyService:
|
||||
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
||||
)
|
||||
|
||||
def enter(
|
||||
self, room_id: UUID, participant_id: str, username: str
|
||||
) -> LobbyParticipant:
|
||||
"""Add participant to waiting lobby.
|
||||
def _create_participant(self, room_id: UUID, username: str) -> LobbyParticipant:
|
||||
"""Create and persist a new waiting participant.
|
||||
|
||||
Create a new participant entry in waiting status and notify room
|
||||
participants of the new entry request.
|
||||
Participant identifiers are minted here, server-side, exclusively.
|
||||
"""
|
||||
|
||||
color = utils.generate_color(participant_id)
|
||||
|
||||
participant_id = str(uuid.uuid4())
|
||||
participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
username=username,
|
||||
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:
|
||||
utils.notify_participants(
|
||||
room_name=str(room_id),
|
||||
room_name=room_id,
|
||||
notification_data={
|
||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||
},
|
||||
@@ -239,15 +230,6 @@ class LobbyService:
|
||||
# If room not created yet, there is no participants to notify
|
||||
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(
|
||||
self, room_id: UUID, participant_id: str
|
||||
) -> 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 asgiref.sync import async_to_sync
|
||||
from livekit.api import TwirpError, TwirpErrorCode
|
||||
from livekit.api import TwirpError
|
||||
from livekit.protocol.sip import (
|
||||
CreateSIPDispatchRuleRequest,
|
||||
DeleteSIPDispatchRuleRequest,
|
||||
@@ -17,16 +17,12 @@ from core import utils
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class SIPException(Exception):
|
||||
"""Exception raised when SIP operations fail."""
|
||||
class TelephonyException(Exception):
|
||||
"""Exception raised when telephony operations fail."""
|
||||
|
||||
|
||||
class DispatchRuleConflictError(SIPException):
|
||||
"""Raised when a dispatch rule already exists for the same routing criteria."""
|
||||
|
||||
|
||||
class SIPManagement:
|
||||
"""Service for managing SIP access through the telephony or roomkit system (SIP)."""
|
||||
class TelephonyService:
|
||||
"""Service for managing participant access through the telephony system (SIP)."""
|
||||
|
||||
def _rule_name(self, room_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):
|
||||
"""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.
|
||||
"""
|
||||
|
||||
@@ -55,12 +51,10 @@ class SIPManagement:
|
||||
try:
|
||||
await lkapi.sip.create_sip_dispatch_rule(create=request)
|
||||
except TwirpError as e:
|
||||
if e.code == TwirpErrorCode.ALREADY_EXISTS:
|
||||
raise DispatchRuleConflictError("Dispatch rule already exists") from e
|
||||
logger.exception(
|
||||
"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:
|
||||
await lkapi.aclose()
|
||||
@@ -85,7 +79,7 @@ class SIPManagement:
|
||||
)
|
||||
except TwirpError as e:
|
||||
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:
|
||||
await lkapi.aclose()
|
||||
|
||||
@@ -100,28 +94,6 @@ class SIPManagement:
|
||||
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 def delete_dispatch_rule(self, room_id):
|
||||
"""Delete all SIP inbound dispatch rules associated with a specific room."""
|
||||
@@ -146,7 +118,7 @@ class SIPManagement:
|
||||
|
||||
except TwirpError as e:
|
||||
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:
|
||||
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 +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,9 +2,14 @@
|
||||
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
|
||||
from django.core.cache import cache
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -109,3 +114,38 @@ def test_api_rooms_create_authenticated_existing_slug():
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
||||
|
||||
|
||||
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_create_authenticated_with_user_access_token():
|
||||
"""A user access token should create a room exactly like a session would."""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
response = client.post("/api/v1.0/rooms/", {"name": "my room"})
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
Test rooms API endpoints in the Meet core app: list.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.test import APIClient
|
||||
@@ -156,3 +160,40 @@ def test_api_rooms_list_pagination_page_size():
|
||||
assert len(content["results"]) == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
|
||||
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 ...factories import RoomFactory, UserFactory
|
||||
from ...models import RoomAccessLevel
|
||||
from ...services.lobby import (
|
||||
LobbyService,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -29,7 +26,6 @@ def test_request_entry_anonymous(settings):
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
@@ -47,11 +43,10 @@ def test_request_entry_anonymous(settings):
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was properly set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
# The participant identifier is returned in the response body; no
|
||||
# cookie is involved anymore
|
||||
assert not response.cookies
|
||||
participant_id = response.json()["id"]
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
@@ -78,7 +73,6 @@ def test_request_entry_authenticated_user(settings):
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
@@ -96,11 +90,10 @@ def test_request_entry_authenticated_user(settings):
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was properly set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
# The participant identifier is returned in the response body; no
|
||||
# cookie is involved anymore
|
||||
assert not response.cookies
|
||||
participant_id = response.json()["id"]
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
@@ -127,7 +120,6 @@ def test_request_entry_with_existing_participants(settings):
|
||||
client = APIClient()
|
||||
|
||||
# Configure test settings for cookies and cache
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Add two participants already waiting in the lobby
|
||||
@@ -168,11 +160,10 @@ def test_request_entry_with_existing_participants(settings):
|
||||
# Verify successful response
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was properly set for the new participant
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
# The participant identifier is returned in the response body; no
|
||||
# cookie is involved anymore
|
||||
assert not response.cookies
|
||||
participant_id = response.json()["id"]
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
@@ -197,7 +188,6 @@ def test_request_entry_public_room(settings):
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
@@ -206,9 +196,7 @@ def test_request_entry_public_room(settings):
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
LobbyService, "_get_or_create_participant_id", return_value="123"
|
||||
),
|
||||
mock.patch("core.services.lobby.uuid.uuid4", return_value="123"),
|
||||
mock.patch.object(
|
||||
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
|
||||
|
||||
# Verify the lobby cookie was set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
assert cookie.value == "123"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": "123",
|
||||
@@ -235,9 +218,10 @@ def test_request_entry_public_room(settings):
|
||||
"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}_*")
|
||||
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):
|
||||
@@ -247,7 +231,6 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
@@ -256,9 +239,8 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
LobbyService,
|
||||
"_get_or_create_participant_id",
|
||||
mock.patch(
|
||||
"core.services.lobby.uuid.uuid4",
|
||||
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
),
|
||||
mock.patch.object(
|
||||
@@ -273,11 +255,6 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
|
||||
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
|
||||
assert response.json() == {
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
@@ -287,9 +264,10 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
"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}_*")
|
||||
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):
|
||||
@@ -297,7 +275,6 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# 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
|
||||
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
|
||||
|
||||
# Simulate a returning participant echoing its identifier
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
@@ -322,16 +297,14 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
):
|
||||
response = client.post(
|
||||
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
|
||||
|
||||
# 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
|
||||
assert response.json() == {
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
@@ -637,15 +610,14 @@ def test_list_waiting_participants_empty(settings):
|
||||
@mock.patch.object(
|
||||
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
|
||||
):
|
||||
"""Anonymous users without a cookie should not be throttled."""
|
||||
"""Requests without a participant identifier should not be throttled."""
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
|
||||
|
||||
response = client.post(
|
||||
@@ -654,9 +626,6 @@ def test_request_entry_throttling_anonymous_without_cookie(
|
||||
)
|
||||
|
||||
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(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
@@ -670,34 +639,32 @@ def test_request_entry_throttling_anonymous_without_cookie(
|
||||
@mock.patch.object(
|
||||
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
|
||||
):
|
||||
"""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)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||
|
||||
participant_id = str(uuid.uuid4())
|
||||
client.cookies.load({"mocked-cookie": participant_id})
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
{"username": "test_user", "participant_id": participant_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
{"username": "test_user", "participant_id": participant_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
{"username": "test_user", "participant_id": participant_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
@@ -716,7 +683,6 @@ def test_request_entry_throttling_authenticated_user(
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||
|
||||
response = client.post(
|
||||
@@ -737,3 +703,124 @@ def test_request_entry_throttling_authenticated_user(
|
||||
)
|
||||
|
||||
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.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.lobby import (
|
||||
LobbyParticipant,
|
||||
LobbyParticipantStatus,
|
||||
LobbyService,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -87,7 +91,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -113,7 +117,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -153,7 +157,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
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,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
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,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -361,7 +365,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
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,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -412,7 +416,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -440,7 +444,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -469,7 +473,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
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())
|
||||
|
||||
# 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
|
||||
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"}
|
||||
|
||||
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()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
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
|
||||
@@ -84,7 +87,10 @@ def test_toggle_hand_lower_success(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
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
|
||||
@@ -101,7 +107,10 @@ def test_toggle_hand_raise_sets_timestamp(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
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
|
||||
@@ -117,7 +126,10 @@ def test_toggle_hand_identity_derived_from_token(
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
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
|
||||
@@ -128,7 +140,9 @@ def test_toggle_hand_missing_raised_field(room, token):
|
||||
"""Test toggle hand with missing raised field returns 400."""
|
||||
client = APIClient()
|
||||
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 "raised" in response.data
|
||||
@@ -142,7 +156,7 @@ def test_toggle_hand_invalid_raised_field(room, token):
|
||||
url,
|
||||
{"raised": "not-a-boolean"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
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()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
|
||||
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
|
||||
@@ -181,7 +198,10 @@ def test_toggle_hand_unexpected_twirp_error(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
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
|
||||
@@ -200,7 +220,7 @@ def test_toggle_hand_raise_success_anonymous(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -220,7 +240,7 @@ def test_toggle_hand_lower_success_anonymous(
|
||||
url,
|
||||
{"raised": False},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -240,7 +260,7 @@ def test_toggle_hand_identity_derived_from_token_anonymous(
|
||||
url,
|
||||
{"raised": True},
|
||||
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
|
||||
@@ -257,7 +277,10 @@ def test_rename_participant_success(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
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
|
||||
@@ -272,7 +295,10 @@ def test_rename_participant_sets_correct_name(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
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
|
||||
@@ -286,7 +312,10 @@ def test_rename_participant_uses_identity_from_token(
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
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
|
||||
@@ -298,7 +327,7 @@ def test_rename_participant_empty_name(room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
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
|
||||
@@ -309,7 +338,9 @@ def test_rename_participant_missing_name(room, token):
|
||||
"""Test rename with missing name field returns 400."""
|
||||
client = APIClient()
|
||||
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 "name" in response.data
|
||||
@@ -320,7 +351,10 @@ def test_rename_participant_name_too_long(room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
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
|
||||
@@ -348,7 +382,7 @@ def test_rename_participant_forbidden_token_for_wrong_room(user):
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {wrong_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
|
||||
)
|
||||
|
||||
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()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
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
|
||||
@@ -382,7 +419,7 @@ def test_rename_participant_success_anonymous(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -402,7 +439,7 @@ def test_rename_participant_uses_identity_from_token_anonymous(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
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
|
||||
@@ -419,7 +456,7 @@ def test_rename_participant_sets_correct_name_anonymous(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
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
|
||||
@@ -436,7 +473,7 @@ def test_rename_participant_forbidden_anonymous_token_for_wrong_room(anonymous_t
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -462,7 +499,7 @@ def test_toggle_hand_expired_token(room, expired_token):
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -476,7 +513,7 @@ def test_rename_participant_expired_token(room, expired_token):
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -490,7 +527,7 @@ def test_toggle_hand_malformed_token(room):
|
||||
url,
|
||||
{"raised": True},
|
||||
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
|
||||
@@ -504,7 +541,10 @@ def test_toggle_hand_room_not_found(user):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
|
||||
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
|
||||
@@ -519,7 +559,10 @@ def test_toggle_hand_participant_not_found(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
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
|
||||
@@ -536,7 +579,7 @@ def test_rename_participant_malformed_token(room):
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
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
|
||||
@@ -550,7 +593,10 @@ def test_rename_participant_room_not_found(user):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
|
||||
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
|
||||
@@ -565,10 +611,16 @@ def test_rename_participant_not_found(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
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.data == {"error": "Participant not found"}
|
||||
|
||||
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
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -503,3 +506,40 @@ def test_api_rooms_retrieve_administrators(
|
||||
role=str(user_access.role),
|
||||
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(
|
||||
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
|
||||
@@ -128,7 +128,7 @@ def test_start_subtitle_disabled_by_default(mock_livekit_token):
|
||||
response = client.post(
|
||||
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
|
||||
@@ -148,7 +148,7 @@ def test_start_subtitle_valid_token(
|
||||
response = client.post(
|
||||
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
|
||||
@@ -178,7 +178,7 @@ def test_start_subtitle_twirp_error(
|
||||
response = client.post(
|
||||
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
|
||||
@@ -198,7 +198,7 @@ def test_start_subtitle_wrong_room(settings, mock_livekit_token):
|
||||
response = client.post(
|
||||
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
|
||||
@@ -219,10 +219,13 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
|
||||
response = client.post(
|
||||
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.json() == {
|
||||
"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
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
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"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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.room_management import RoomManagementException
|
||||
from core.services.sip_management import (
|
||||
SIPException,
|
||||
SIPManagement,
|
||||
)
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -62,7 +59,7 @@ def test_initialization(
|
||||
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
||||
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
||||
assert isinstance(service.lobby_service, LobbyService)
|
||||
assert isinstance(service.sip_management, SIPManagement)
|
||||
assert isinstance(service.telephony_service, TelephonyService)
|
||||
assert isinstance(service.recording_events, RecordingEventsService)
|
||||
|
||||
|
||||
@@ -75,12 +72,11 @@ def test_initialization(
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@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
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service, settings
|
||||
def test_handle_egress_ended_success(
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop recording and notifies all participant."""
|
||||
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
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.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
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service, settings
|
||||
def test_handle_egress_ended_metadata_update_fails(
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""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")
|
||||
mock_data = mock.MagicMock()
|
||||
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(SIPManagement, "delete_dispatch_rule")
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
||||
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
|
||||
mock_room_name = uuid.uuid4()
|
||||
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(SIPManagement, "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")
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_skips_telephony_when_disabled(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
mock_room_name = uuid.uuid4()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(mock_room_name)
|
||||
@@ -529,7 +505,7 @@ def test_handle_room_finished_skips_telephony_when_disabled(
|
||||
@mock.patch.object(
|
||||
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(
|
||||
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(
|
||||
SIPManagement,
|
||||
TelephonyService,
|
||||
"delete_dispatch_rule",
|
||||
side_effect=SIPException("Test error"),
|
||||
side_effect=TelephonyException("Test error"),
|
||||
)
|
||||
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
||||
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"
|
||||
|
||||
expected_error = (
|
||||
"Failed to delete sip dispatch rule for room "
|
||||
"Failed to delete telephony dispatch rule for room "
|
||||
"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)
|
||||
|
||||
|
||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||
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
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -598,75 +574,22 @@ def test_handle_room_started_creates_dispatch_rule_successfully(
|
||||
|
||||
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")
|
||||
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")
|
||||
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||
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.ROOMKIT_ENABLED = False
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
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):
|
||||
|
||||
@@ -3,7 +3,6 @@ Test lobby service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613, W0212, R0913
|
||||
# ruff: noqa: PLR0913
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
@@ -11,7 +10,6 @@ from unittest import mock
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -135,59 +133,6 @@ def test_get_cache_key(lobby_service, participant_id):
|
||||
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):
|
||||
"""Should return True for public rooms regardless of user auth and role."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
@@ -266,11 +211,12 @@ def test_request_entry_public_room(
|
||||
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)
|
||||
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 livekit_config == {"token": "test-token"}
|
||||
@@ -304,11 +250,12 @@ def test_request_entry_trusted_room(
|
||||
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)
|
||||
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 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)
|
||||
|
||||
|
||||
@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(
|
||||
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.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
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)
|
||||
|
||||
participant_data = LobbyParticipant(
|
||||
@@ -345,14 +293,20 @@ def test_request_entry_new_participant(
|
||||
id=participant_id,
|
||||
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 livekit_config is None
|
||||
mock_enter.assert_called_once_with(room.id, participant_id, username)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
# The provided identifier was looked up, found unknown, and replaced
|
||||
# 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")
|
||||
@@ -361,7 +315,6 @@ def test_request_entry_waiting_participant(
|
||||
):
|
||||
"""Test requesting entry for a waiting participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
@@ -372,10 +325,11 @@ def test_request_entry_waiting_participant(
|
||||
id=participant_id,
|
||||
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)
|
||||
|
||||
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 livekit_config is None
|
||||
@@ -390,7 +344,6 @@ def test_request_entry_accepted_participant(
|
||||
"""Test requesting entry for an accepted participant."""
|
||||
request = mock.Mock()
|
||||
request.user = AnonymousUser()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
@@ -400,12 +353,13 @@ def test_request_entry_accepted_participant(
|
||||
id=participant_id,
|
||||
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)
|
||||
|
||||
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 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."""
|
||||
request = mock.Mock()
|
||||
request.user = UserFactory()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
@@ -440,12 +393,13 @@ def test_request_entry_participant_with_role(
|
||||
id=participant_id,
|
||||
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)
|
||||
|
||||
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 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.utils.generate_color")
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_enter_success(
|
||||
mock_notify,
|
||||
def test_create_participant(
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
lobby_service,
|
||||
participant_id,
|
||||
username,
|
||||
settings,
|
||||
):
|
||||
"""Test successful participant entry."""
|
||||
"""A created participant is waiting, colored, and persisted."""
|
||||
mock_generate_color.return_value = "#123456"
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
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.username == username
|
||||
assert participant.id == participant_id
|
||||
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(
|
||||
"mocked_cache_key",
|
||||
participant.to_dict(),
|
||||
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")
|
||||
def test_enter_with_notification_error(
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
lobby_service,
|
||||
participant_id,
|
||||
username,
|
||||
):
|
||||
"""Test participant entry with notification error."""
|
||||
mock_generate_color.return_value = "#123456"
|
||||
def test_notify_entry_request_with_notification_error(mock_notify, lobby_service):
|
||||
"""A notification error must not break the entry request flow."""
|
||||
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)
|
||||
participant = lobby_service.enter(room.id, participant_id, username)
|
||||
lobby_service._notify_entry_request("room-id")
|
||||
|
||||
mock_generate_color.assert_called_once_with(participant_id)
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert participant.username == username
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key",
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name="room-id", notification_data={"type": "participantWaiting"}
|
||||
)
|
||||
|
||||
|
||||
|
||||
+35
-162
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Test SIP mamagement service.
|
||||
Test telephony service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212
|
||||
@@ -20,11 +20,7 @@ from livekit.protocol.sip import (
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.sip_management import (
|
||||
DispatchRuleConflictError,
|
||||
SIPException,
|
||||
SIPManagement,
|
||||
)
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -39,9 +35,9 @@ def create_mock_livekit_client():
|
||||
|
||||
def test_rule_name():
|
||||
"""Test rule name generation."""
|
||||
sip_management = SIPManagement()
|
||||
telephony_service = TelephonyService()
|
||||
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)}"
|
||||
|
||||
@@ -49,14 +45,14 @@ def test_rule_name():
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_create_dispatch_rule_success(mock_client_factory):
|
||||
"""Test successful dispatch rule creation."""
|
||||
sip_management = SIPManagement()
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
||||
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()
|
||||
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")
|
||||
def test_create_dispatch_rule_api_failure(mock_client_factory):
|
||||
"""Test dispatch rule creation when API fails."""
|
||||
sip_management = SIPManagement()
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
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
|
||||
|
||||
with pytest.raises(SIPException, match="Could not create dispatch rule"):
|
||||
sip_management.create_dispatch_rule(room)
|
||||
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
|
||||
telephony_service.create_dispatch_rule(room)
|
||||
|
||||
mock_api.sip.create_sip_dispatch_rule.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")
|
||||
def test_list_dispatch_rules_ids_success(mock_client_factory):
|
||||
"""Test successful listing of dispatch rule IDs."""
|
||||
sip_management = SIPManagement()
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_rules = [
|
||||
@@ -115,7 +111,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
|
||||
)
|
||||
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 "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")
|
||||
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
||||
"""Test listing dispatch rule IDs when no rules exist."""
|
||||
sip_management = SIPManagement()
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
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
|
||||
|
||||
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 == []
|
||||
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")
|
||||
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
||||
"""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")
|
||||
|
||||
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
|
||||
|
||||
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 == []
|
||||
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")
|
||||
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
||||
"""Test listing dispatch rule IDs when API fails."""
|
||||
sip_management = SIPManagement()
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
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
|
||||
|
||||
with pytest.raises(SIPException, match="Could not list dispatch rules"):
|
||||
async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
||||
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
|
||||
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
mock_api.sip.list_sip_dispatch_rule.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")
|
||||
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting dispatch rules when no rules exist."""
|
||||
sip_management = SIPManagement()
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
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
|
||||
mock_list_rules.assert_called_once_with(room.id)
|
||||
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")
|
||||
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting a single dispatch rule."""
|
||||
sip_management = SIPManagement()
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
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_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
|
||||
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.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")
|
||||
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting multiple dispatch rules."""
|
||||
sip_management = SIPManagement()
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
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_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 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.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")
|
||||
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting multiple dispatch rules when one deletion fails."""
|
||||
sip_management = SIPManagement()
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
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
|
||||
|
||||
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
||||
sip_management.delete_dispatch_rule(room.id)
|
||||
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||
telephony_service.delete_dispatch_rule(room.id)
|
||||
|
||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting dispatch rules when API fails immediately."""
|
||||
sip_management = SIPManagement()
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
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
|
||||
|
||||
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
||||
sip_management.delete_dispatch_rule(room.id)
|
||||
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||
telephony_service.delete_dispatch_rule(room.id)
|
||||
|
||||
mock_api.sip.delete_sip_dispatch_rule.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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
def test_telephony_and_roomkit_disabled_skips_pin_generation(
|
||||
def test_telephony_disabled_skips_pin_generation(
|
||||
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.ROOMKIT_ENABLED = False
|
||||
|
||||
room = RoomFactory()
|
||||
|
||||
@@ -198,18 +197,6 @@ def test_telephony_and_roomkit_disabled_skips_pin_generation(
|
||||
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):
|
||||
"""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.api import get_frontend_configuration, viewsets
|
||||
from core.external_api import viewsets as external_viewsets
|
||||
from core.roomkit import viewsets as roomkit_viewsets
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
@@ -20,11 +19,6 @@ router.register("files", viewsets.FileViewSet, basename="files")
|
||||
router.register(
|
||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||
)
|
||||
router.register(
|
||||
"roomkit",
|
||||
roomkit_viewsets.RoomKitViewSet,
|
||||
basename="roomkit",
|
||||
)
|
||||
router.register(
|
||||
"addons/sessions",
|
||||
addons_viewsets.SessionViewSet,
|
||||
@@ -43,6 +37,11 @@ external_router.register(
|
||||
external_viewsets.RoomViewSet,
|
||||
basename="external_room",
|
||||
)
|
||||
external_router.register(
|
||||
"users",
|
||||
external_viewsets.UserViewSet,
|
||||
basename="external_user",
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
|
||||
@@ -324,6 +324,7 @@ class Base(Configuration):
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"core.authentication.user_token.UserAccessJWTAuthentication",
|
||||
"core.authentication.backends.SessionAuthenticationWith401",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
@@ -344,16 +345,16 @@ class Base(Configuration):
|
||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||
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(
|
||||
default="600/minute",
|
||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"roomkit_join": values.Value(
|
||||
default="300/minute",
|
||||
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||
@@ -846,11 +847,6 @@ class Base(Configuration):
|
||||
environ_name="LOBBY_NOTIFICATION_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
LOBBY_COOKIE_NAME = values.Value(
|
||||
"lobbyParticipantId",
|
||||
environ_name="LOBBY_COOKIE_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Calendar integrations
|
||||
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||
@@ -886,21 +882,6 @@ class Base(Configuration):
|
||||
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
|
||||
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
|
||||
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
|
||||
@@ -973,6 +954,61 @@ class Base(Configuration):
|
||||
environ_name="APPLICATION_BASE_URL",
|
||||
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
|
||||
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
|
||||
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_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):
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||
|
||||
@@ -40,7 +40,7 @@ dependencies = [
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django-pydantic-field==0.5.4",
|
||||
"django==5.2.16",
|
||||
"django==5.2.14",
|
||||
"djangorestframework==3.17.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2026.3.4",
|
||||
|
||||
Generated
+4
-4
@@ -586,16 +586,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.16"
|
||||
version = "5.2.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
@@ -1260,7 +1260,7 @@ requires-dist = [
|
||||
{ name = "brotli", specifier = "==1.2.0" },
|
||||
{ name = "celery", extras = ["redis"], specifier = "==5.6.3" },
|
||||
{ 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-cors-headers", specifier = "==4.9.0" },
|
||||
{ name = "django-countries", specifier = "==9.0.0" },
|
||||
|
||||
+24
-17
@@ -12,6 +12,7 @@ import { routes } from './routes'
|
||||
import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { TransitCodeGate } from '@/features/auth/components/TransitCodeGate'
|
||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
|
||||
|
||||
@@ -24,23 +25,29 @@ function App() {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{!isSDKContext && <AppInitialization />}
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route key={i} path={route.path} component={route.Component} />
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
<TransitCodeGate>
|
||||
{!isSDKContext && <AppInitialization />}
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route
|
||||
key={i}
|
||||
path={route.path}
|
||||
component={route.Component}
|
||||
/>
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</TransitCodeGate>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { ApiError } from './ApiError'
|
||||
import { apiUrl } from './apiUrl'
|
||||
import { getAccessToken } from '@/stores/accessToken'
|
||||
|
||||
export const fetchApi = async <T = Record<string, unknown>>(
|
||||
url: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> => {
|
||||
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), {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(!!csrfToken && { 'X-CSRFToken': csrfToken }),
|
||||
...(!!accessToken && { Authorization: `Bearer ${accessToken}` }),
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -59,10 +59,9 @@ export const Avatar = React.memo(
|
||||
<text
|
||||
x="50"
|
||||
y="50"
|
||||
dy="-0.08em"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="52"
|
||||
fontSize={initials.length > 1 ? 48 : 52}
|
||||
fontWeight="500"
|
||||
fill="currentColor"
|
||||
>
|
||||
|
||||
@@ -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 { type ApiUser } from './ApiUser'
|
||||
import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
|
||||
import { getAccessToken } from '@/stores/accessToken'
|
||||
|
||||
/**
|
||||
* fetch the logged-in user from the api.
|
||||
@@ -25,7 +26,13 @@ export const fetchUser = (
|
||||
if (error instanceof ApiError && error.statusCode === 401) {
|
||||
// make sure to not resolve the promise while trying to silent login
|
||||
// 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)
|
||||
} else {
|
||||
resolve(false)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { fetchApi } from '@/api/fetchApi'
|
||||
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||
|
||||
export const useMuteParticipant = () => {
|
||||
const apiRoomData = useRoomData()
|
||||
@@ -36,7 +37,7 @@ export const useMuteParticipant = () => {
|
||||
}
|
||||
|
||||
const headers = !isAdminOrOwner
|
||||
? { Authorization: `Bearer ${apiRoomData.livekit.token}` }
|
||||
? getLiveKitAuthHeaders(apiRoomData.livekit.token)
|
||||
: undefined
|
||||
|
||||
let response
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||
|
||||
export const useRenameParticipant = () => {
|
||||
const data = useRoomData()
|
||||
@@ -15,11 +16,10 @@ export const useRenameParticipant = () => {
|
||||
throw new Error('LiveKit token is not available')
|
||||
}
|
||||
|
||||
const headers = getLiveKitAuthHeaders(token)
|
||||
return fetchApi(`rooms/${data.id}/rename/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import type { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
|
||||
import { getLobbyParticipantId } from '@/stores/lobby'
|
||||
|
||||
export interface RequestEntryParams {
|
||||
roomId: string
|
||||
@@ -15,6 +16,7 @@ export enum ApiLobbyStatus {
|
||||
}
|
||||
|
||||
export interface ApiRequestEntry {
|
||||
id?: string
|
||||
status: ApiLobbyStatus
|
||||
livekit?: ApiLiveKit
|
||||
}
|
||||
@@ -23,10 +25,12 @@ export const requestEntry = async ({
|
||||
roomId,
|
||||
username = '',
|
||||
}: RequestEntryParams) => {
|
||||
const participantId = getLobbyParticipantId(roomId)
|
||||
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
...(participantId && { participant_id: participantId }),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||
|
||||
export const useRaiseHand = () => {
|
||||
const data = useRoomData()
|
||||
@@ -15,11 +16,10 @@ export const useRaiseHand = () => {
|
||||
throw new Error('LiveKit token is not available')
|
||||
}
|
||||
|
||||
const headers = getLiveKitAuthHeaders(token)
|
||||
return fetchApi(`rooms/${data.id}/toggle-hand/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
raised,
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ApiLobbyStatus,
|
||||
type ApiRequestEntry,
|
||||
} from '../api/requestEntry'
|
||||
import { setLobbyParticipantId } from '@/stores/lobby'
|
||||
|
||||
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
@@ -43,6 +44,11 @@ export const useLobby = ({
|
||||
roomId,
|
||||
username,
|
||||
})
|
||||
|
||||
if (response.id) {
|
||||
setLobbyParticipantId(roomId, response.id)
|
||||
}
|
||||
|
||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||
clearWaitingTimeout()
|
||||
setStatus(ApiLobbyStatus.ACCEPTED)
|
||||
|
||||
+9
-4
@@ -1,4 +1,5 @@
|
||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
|
||||
import posthog from 'posthog-js'
|
||||
import {
|
||||
FilesetResolver,
|
||||
@@ -85,7 +86,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
this.sourceSettings = this.source!.getSettings()
|
||||
this.videoElement = opts.element as HTMLVideoElement
|
||||
|
||||
this._initVirtualBackgroundImage()
|
||||
await this._initVirtualBackgroundImage()
|
||||
this._createMainCanvas()
|
||||
this._createMaskCanvas()
|
||||
|
||||
@@ -103,7 +104,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
posthog.capture('firefox-blurring-init')
|
||||
}
|
||||
|
||||
_initVirtualBackgroundImage() {
|
||||
async _initVirtualBackgroundImage() {
|
||||
if (this.options.type !== 'virtual') {
|
||||
throw new Error(
|
||||
'Virtual background is only supported for virtual background'
|
||||
@@ -115,15 +116,19 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
this.virtualBackgroundImage &&
|
||||
this.virtualBackgroundImage.src !== this.options.imagePath
|
||||
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.crossOrigin = 'anonymous'
|
||||
this.virtualBackgroundImage.src = this.options.imagePath!
|
||||
this.virtualBackgroundImage.src = imagePath
|
||||
}
|
||||
}
|
||||
|
||||
async update(opts: ProcessorConfig): Promise<void> {
|
||||
this.options = opts
|
||||
this._initVirtualBackgroundImage()
|
||||
await this._initVirtualBackgroundImage()
|
||||
}
|
||||
|
||||
_initWorker() {
|
||||
|
||||
+14
-1
@@ -1,4 +1,5 @@
|
||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
|
||||
import {
|
||||
ProcessorWrapper,
|
||||
BackgroundProcessor,
|
||||
@@ -47,7 +48,16 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
||||
}
|
||||
|
||||
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>) {
|
||||
@@ -59,6 +69,9 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
||||
}
|
||||
|
||||
async update(opts: ProcessorConfig): Promise<void> {
|
||||
if (opts.type === 'virtual') {
|
||||
opts = { ...opts, imagePath: await resolveMediaUrl(opts.imagePath) }
|
||||
}
|
||||
this.opts = opts
|
||||
|
||||
const newProcessorType =
|
||||
|
||||
+10
-1
@@ -8,6 +8,7 @@ import {
|
||||
ProcessorType,
|
||||
} from '../blur'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useResolvedMediaUrls } from '@/features/files/hooks/useResolvedMediaUrls'
|
||||
import { Button, Dialog, H, P, Text, ToggleButton } from '@/primitives'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { HStack, styled } from '@/styled-system/jsx'
|
||||
@@ -277,6 +278,14 @@ export const EffectsConfiguration = ({
|
||||
filesQ.data.count >= appConfig.background_image.max_count_by_user) ??
|
||||
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(
|
||||
(file: ApiFileItem) => {
|
||||
return async () => {
|
||||
@@ -754,7 +763,7 @@ export const EffectsConfiguration = ({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${option.file.url!})`,
|
||||
backgroundImage: `url(${resolveMediaUrl(option.file.url!)})`,
|
||||
}}
|
||||
data-attr={`toggle-virtual-${option.file.id}`}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
const LIVEKIT_AUTH_SCHEME = 'X-LiveKit-Token'
|
||||
|
||||
export const getLiveKitAuthHeaders = (token: string) => {
|
||||
return {
|
||||
Authorization: `${LIVEKIT_AUTH_SCHEME} ${token}`,
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import type { ApiError } from '@/api/ApiError'
|
||||
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { getLiveKitAuthHeaders } from '@/features/rooms/utils/getLiveKitAuthHeaders'
|
||||
|
||||
export interface StartSubtitleParams {
|
||||
id: string
|
||||
@@ -14,9 +15,7 @@ const startSubtitle = ({
|
||||
}: StartSubtitleParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/${id}/start-subtitle/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers: getLiveKitAuthHeaders(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, () => '')
|
||||
@@ -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 { initializeAccessTokenFromFragment } from '@/features/auth/api/exchangeAccessToken'
|
||||
import { getAccessToken } from '@/stores/accessToken'
|
||||
import {
|
||||
ProcessorConfig,
|
||||
ProcessorType,
|
||||
@@ -48,10 +50,19 @@ if (userChoicesStore.processorConfig?.type === ProcessorType.VIRTUAL) {
|
||||
// we restore clear the processor config to avoid displaying a black screen.
|
||||
userChoicesStore.processorConfig = undefined
|
||||
} 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
|
||||
await fetch(userChoicesStore.processorConfig.imagePath, {
|
||||
// We bypass the cache to ensure we have access
|
||||
cache: 'reload',
|
||||
...(accessToken && {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}),
|
||||
})
|
||||
.then((response) => {
|
||||
// if we cannot fetch the image (likely a 401 from the backend because
|
||||
|
||||
Reference in New Issue
Block a user