mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-07 22:10:56 +00:00
✨(backend) audit recordings, transcripts and summaries
Emit audit events across the recording lifecycle: recording.start, recording.stop and recording.delete from the API, recording.end when LiveKit reports the egress ended, recording.transcript.request when the backend sends a recording to the summary service, and recording.transcript.report and recording.summary.report when that service calls back. recording.end has an unknown outcome when LiveKit reports an egress status the backend does not map, and recording.transcript.request names the summary service as service.target. Small bugfix: AuditViewMixin keeps a copy of a deleted target, since deleting an instance clears its primary key.
This commit is contained in:
@@ -21,6 +21,7 @@ and this project adheres to
|
||||
- ✨(backend) add structured audit logging facility
|
||||
- ✨(backend) audit external API token and room operations
|
||||
- 🔒️(backend) audit writes and bulk actions made in the Django admin
|
||||
- ✨(backend) audit recordings, transcripts and summaries
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -61,8 +61,8 @@ hold what ECS does not define: `lasuite.*`, and `entity.target.raw.*` for the fi
|
||||
| `@timestamp` | ISO 8601 with millisecond precision in UTC timezone |
|
||||
| `data_stream.*`, `event.dataset` | `logs`, `<AUDIT_LOG_SERVICE_NAME>.audit` and `AUDIT_LOG_DATA_STREAM_NAMESPACE`: what tells the audit stream apart from the application logs |
|
||||
| `service.name`, `service.environment`, `service.version`, `service.node.name` | `AUDIT_LOG_SERVICE_NAME`, current environment, release and host name (the pod on Kubernetes): the emitter, never the caller |
|
||||
| `service.origin.name` | The internal peer that called the backend, when the actor is a `service`: `roomkit`, `summary` |
|
||||
| `service.target.name` | The peer service the backend called, when one is named |
|
||||
| `service.origin.name` | The internal peer that called the backend, when the actor is a `service`: `roomkit`, `summary`, `livekit` |
|
||||
| `service.target.name` | The peer service the backend called, as `summary` for `recording.transcript.request` |
|
||||
| `event.id` | Unique id of the event, so that a shipper retrying it cannot duplicate it |
|
||||
| `event.action` | What was attempted, from the catalogue below |
|
||||
| `event.category`, `event.type` | ECS classification (`api`, `authentication`, `iam`... / `creation`, `change`, `access`, `denied`, `user`...). Always a combination ECS expects, see [Classification](#classification) |
|
||||
@@ -124,7 +124,7 @@ An audited API action that raises an exception DRF does not handle is still reco
|
||||
|---|---|---|
|
||||
| `user` | A person's account acting for itself: session, OIDC or password login, add-on token, LiveKit token of a known account | That account |
|
||||
| `application` | A client application acting on behalf of a user: a Meet application through its client credentials or its delegated token, or another La Suite application through the resource server. `lasuite.application.client_id` names it | The delegating user |
|
||||
| `service` | An internal peer of the deployment acting on its own behalf, named by `service.origin.name`: the LiveKit SIP bridge (`roomkit`), the summary service (`summary`). | Absent |
|
||||
| `service` | An internal peer of the deployment, named by `service.origin.name`: the LiveKit SIP bridge (`roomkit`), the summary service (`summary`), the LiveKit server reporting on a recording (`livekit`) | Absent |
|
||||
| `system` | The backend itself, with no inbound request | Absent |
|
||||
| `anonymous` | A caller that did not authenticate, or failed to | Absent |
|
||||
|
||||
@@ -141,11 +141,26 @@ an account a user. An event emitted with neither a request nor an actor is the s
|
||||
| `room.update` | A room is updated through the external API, or the attempt fails | `entity.target` = room, refusals included, `lasuite.details.updated_fields`, `previous_access_level` |
|
||||
| `room.retrieve` | A room is read through the external API, or the attempt fails | `entity.target` = room |
|
||||
| `room.list` | Rooms are listed through the external API, or the attempt fails | `lasuite.details.total` |
|
||||
| `recording.start` | A room owner or administrator starts a recording, or the attempt fails: conflict with a recording in progress, worker error | `entity.target` = recording, or the room when none was created, `lasuite.details.collect_metadata` |
|
||||
| `recording.stop` | A room owner or administrator stops the recording in progress, or the attempt fails | `entity.target` = recording, or the room when none is active |
|
||||
| `recording.end` | LiveKit reports a recording ended (`egress_ended` webhook): `success` when its media file is available, `failure` when it was aborted or failed, `unknown` for a status the backend does not map | `service.origin.name` = `livekit`, `entity.target` = recording, as it was before the report is processed, `lasuite.details.worker_event` (`completed`, `limit reached`, `aborted`, `failed`), `error_code` |
|
||||
| `recording.delete` | A recording is deleted, or the attempt fails | `entity.target` = recording |
|
||||
| `recording.transcript.request` | The backend sends a recording to the summary service to be transcribed, or fails to | `lasuite.actor.type` = `system`, `service.target.name` = `summary`, `entity.target` = recording, `lasuite.details.summary_requested`: whether a summary is to be made of the transcript, `job_id` |
|
||||
| `recording.transcript.report` | The summary service reports on a transcript (`external-process-hook`), or a call to the hook is refused | `service.origin.name` = `summary`, `entity.target` = recording, absent for an unknown job, `lasuite.details.job_id`, `status`. A reported `failure` is a `failure` |
|
||||
| `recording.summary.report` | The summary service reports on a summary | As `recording.transcript.report` |
|
||||
| `user.login` | A user logs in or a login attempt fails, `denied` with reason `authentication_failed` | `lasuite.auth.method` = `oidc` or `password`, or `unknown`: named after the backend on success, `lasuite.details.auth_backend`, and after the credentials submitted on failure (a password, or the nonce of the OIDC callback) |
|
||||
| `user.logout` | A user logs out | |
|
||||
| `admin.access` | A signed-in account without staff access reaches an admin page (always denied), once per refused page | `event.category` = `web`, `event.type` = `access`, `event.reason`, `http.response.status_code`: the redirect to the login page |
|
||||
| `admin.<target>.<verb>` | A write is made through the Django admin, see below | |
|
||||
|
||||
### Recordings, transcripts and summaries
|
||||
|
||||
A recording's target carries what was recorded: `mode` is how the media was captured (`screen_recording`, a video;
|
||||
`transcript`, an audio track), `requested_mode` what the user asked for, and `is_transcribed` whether it is sent to
|
||||
the summary service. They differ for a transcript started with a screen capture: `mode` is `screen_recording`,
|
||||
`requested_mode` `transcript`. A transcript and a summary are not recordings but what the summary service derives
|
||||
from one, audited under `recording.transcript.*` and `recording.summary.*` with the recording as their target.
|
||||
|
||||
Actions are always dotted, lower-case, with the format `<target>.<verb>`, and name what was attempted: whether it
|
||||
succeeded is told by `event.outcome`, `lasuite.outcome` and `event.reason`, never by the action.
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ from rest_framework import (
|
||||
)
|
||||
from rest_framework.settings import api_settings
|
||||
|
||||
from core import analytics, enums, models, utils
|
||||
from core import analytics, audit, auditing, enums, models, utils
|
||||
from core.api import throttling
|
||||
from core.api.filters import ListFileFilter
|
||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||
@@ -168,6 +168,7 @@ class UserViewSet(
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
audit.AuditViewMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
@@ -312,6 +313,7 @@ class RoomViewSet(
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
audit_action=auditing.RECORDING_START,
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||
@@ -349,6 +351,11 @@ class RoomViewSet(
|
||||
recording=recording,
|
||||
)
|
||||
|
||||
self.audit_target = recording
|
||||
self.audit_details = {
|
||||
"collect_metadata": bool(recording.options.get("collect_metadata")),
|
||||
}
|
||||
|
||||
except (DjangoValidationError, IntegrityError):
|
||||
# DjangoValidationError covers the Python-level check (full_clean);
|
||||
# IntegrityError covers the race where two concurrent requests both
|
||||
@@ -393,6 +400,7 @@ class RoomViewSet(
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
audit_action=auditing.RECORDING_STOP,
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||
@@ -409,6 +417,8 @@ class RoomViewSet(
|
||||
"No active recording found for this room."
|
||||
) from e
|
||||
|
||||
self.audit_target = recording
|
||||
|
||||
worker_service = get_worker_service(mode=recording.mode)
|
||||
worker_manager = WorkerServiceMediator(worker_service=worker_service)
|
||||
|
||||
@@ -942,6 +952,7 @@ class ResourceAccessViewSet(
|
||||
|
||||
|
||||
class RecordingViewSet(
|
||||
audit.AuditViewMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
@@ -956,6 +967,23 @@ class RecordingViewSet(
|
||||
queryset = models.Recording.objects.all()
|
||||
serializer_class = serializers.RecordingSerializer
|
||||
|
||||
audit_actions = {"destroy": auditing.RECORDING_DELETE}
|
||||
# What the summary service reports on, once its event is validated
|
||||
audit_process_type = None
|
||||
|
||||
def get_audit_action(self):
|
||||
"""Audit a summary apart from the transcript it is made from."""
|
||||
if self.audit_process_type == "summary":
|
||||
return auditing.RECORDING_SUMMARY_REPORT
|
||||
return super().get_audit_action()
|
||||
|
||||
def get_audit_fields(self, status_code, error=None):
|
||||
"""Report a failure of the summary service as one, though it is acknowledged."""
|
||||
fields = super().get_audit_fields(status_code, error)
|
||||
if status_code < 400 and (self.audit_details or {}).get("status") == "failure":
|
||||
fields["outcome"] = audit.Outcome.FAILURE
|
||||
return fields
|
||||
|
||||
def get_queryset(self):
|
||||
"""Restrict recordings to the user's ones."""
|
||||
user = self.request.user
|
||||
@@ -971,6 +999,7 @@ class RecordingViewSet(
|
||||
url_path="external-process-hook",
|
||||
authentication_classes=[RecordingProcessWebhookAuthentication],
|
||||
serializer_class=serializers.ExternalProcessEventSerializer,
|
||||
audit_action=auditing.RECORDING_TRANSCRIPT_REPORT,
|
||||
)
|
||||
def on_external_process_event_received(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Handle incoming external process events for recordings."""
|
||||
@@ -984,12 +1013,16 @@ class RecordingViewSet(
|
||||
|
||||
validated_data = serializer.validated_data
|
||||
job_id = validated_data["job_id"]
|
||||
self.audit_process_type = validated_data.get("type")
|
||||
self.audit_details = {"job_id": job_id, "status": validated_data.get("status")}
|
||||
try:
|
||||
recording = models.Recording.objects.get(external_process_id=job_id)
|
||||
except models.Recording.DoesNotExist as e:
|
||||
logger.warning("No recording found for job_id %s: %s", job_id, e)
|
||||
return ok_response
|
||||
|
||||
self.audit_target = recording
|
||||
|
||||
if validated_data.get("type") == "transcript":
|
||||
if validated_data.get("status") == "success":
|
||||
logger.info(
|
||||
|
||||
@@ -18,6 +18,7 @@ A refusal is recorded under the action that was attempted, with its outcome
|
||||
and reason derived from the response status.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
@@ -94,6 +95,11 @@ class AuditViewMixin:
|
||||
self.audit_target = obj
|
||||
super().check_object_permissions(request, obj)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Keep a copy of the target, since deleting an instance clears its pk."""
|
||||
self.audit_target = copy.copy(instance)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
def finalize_response(self, request, response, *args, **kwargs):
|
||||
"""Audit the response once DRF has built it."""
|
||||
response = super().finalize_response(request, response, *args, **kwargs)
|
||||
|
||||
@@ -34,6 +34,19 @@ ROOM_CREATE = audit.Action("room.create")
|
||||
ROOM_LIST = audit.Action("room.list")
|
||||
ROOM_RETRIEVE = audit.Action("room.retrieve")
|
||||
ROOM_UPDATE = audit.Action("room.update")
|
||||
RECORDING_START = audit.Action("recording.start", types=(EventType.START,))
|
||||
RECORDING_STOP = audit.Action("recording.stop", types=(EventType.END,))
|
||||
RECORDING_END = audit.Action("recording.end", types=(EventType.END,))
|
||||
RECORDING_DELETE = audit.Action("recording.delete", types=(EventType.DELETION,))
|
||||
RECORDING_TRANSCRIPT_REQUEST = audit.Action(
|
||||
"recording.transcript.request", types=(EventType.START,)
|
||||
)
|
||||
RECORDING_TRANSCRIPT_REPORT = audit.Action(
|
||||
"recording.transcript.report", types=(EventType.END,)
|
||||
)
|
||||
RECORDING_SUMMARY_REPORT = audit.Action(
|
||||
"recording.summary.report", types=(EventType.END,)
|
||||
)
|
||||
|
||||
# Models
|
||||
|
||||
@@ -80,7 +93,7 @@ audit.register(
|
||||
)
|
||||
audit.register(
|
||||
models.Recording,
|
||||
fields=("room_id", "status", "mode"),
|
||||
fields=("room_id", "status", "mode", "requested_mode", "is_transcribed"),
|
||||
admin_values=("status", "mode"),
|
||||
)
|
||||
audit.register(models.File, admin_values=("title", "upload_state"))
|
||||
|
||||
@@ -702,6 +702,22 @@ class Recording(BaseModel):
|
||||
"""Check if the recording is in a saved state."""
|
||||
return self.status in RecordingStatusChoices.saved_statuses()
|
||||
|
||||
@property
|
||||
def requested_mode(self) -> str:
|
||||
"""Return the mode the user asked for.
|
||||
|
||||
A transcript started with a screen capture is stored as a screen
|
||||
recording, which keeps the transcript as its original mode.
|
||||
"""
|
||||
return (self.options or {}).get("original_mode") or self.mode
|
||||
|
||||
@property
|
||||
def is_transcribed(self) -> bool:
|
||||
"""Tell whether the recording is sent to the summary service."""
|
||||
return self.mode == RecordingModeChoices.TRANSCRIPT or bool(
|
||||
(self.options or {}).get("transcribe", False)
|
||||
)
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
"""Get recording extension based on its mode."""
|
||||
|
||||
@@ -17,7 +17,7 @@ import requests
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
|
||||
from core import models, utils
|
||||
from core import audit, auditing, models, utils
|
||||
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
||||
from core.utils import generate_download_s3_url
|
||||
|
||||
@@ -52,7 +52,7 @@ class NotificationService:
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
summary_success = True
|
||||
if recording.options.get("transcribe", False):
|
||||
if recording.is_transcribed:
|
||||
summary_success = self._notify_summary_service(recording)
|
||||
|
||||
email_success = self._notify_user_by_email(recording)
|
||||
@@ -213,14 +213,32 @@ class NotificationService:
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording: models.Recording):
|
||||
if settings.SUMMARY_SERVICE_VERSION == 1:
|
||||
return NotificationService._notify_summary_service_v1(recording)
|
||||
if settings.SUMMARY_SERVICE_VERSION == 2:
|
||||
return NotificationService._notify_summary_service_v2(recording)
|
||||
|
||||
raise NotImplementedError(
|
||||
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
||||
)
|
||||
succeeded = False
|
||||
audit_details = {}
|
||||
try:
|
||||
if settings.SUMMARY_SERVICE_VERSION == 1:
|
||||
succeeded = NotificationService._notify_summary_service_v1(recording)
|
||||
elif settings.SUMMARY_SERVICE_VERSION == 2:
|
||||
succeeded = NotificationService._notify_summary_service_v2(
|
||||
recording, audit_details
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
"Unknown summary service version: "
|
||||
f"{settings.SUMMARY_SERVICE_VERSION}"
|
||||
)
|
||||
return succeeded
|
||||
finally:
|
||||
audit.log(
|
||||
auditing.RECORDING_TRANSCRIPT_REQUEST,
|
||||
actor=None,
|
||||
actor_type=audit.ActorType.SYSTEM,
|
||||
target=recording,
|
||||
target_service="summary",
|
||||
outcome=audit.Outcome.SUCCESS if succeeded else audit.Outcome.FAILURE,
|
||||
job_id=recording.external_process_id,
|
||||
**audit_details,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v1(recording: models.Recording):
|
||||
@@ -298,8 +316,11 @@ class NotificationService:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v2(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
def _notify_summary_service_v2(recording: models.Recording, audit_details=None):
|
||||
"""Notify summary service about a new recording.
|
||||
|
||||
Whether a summary is asked for is added to ``audit_details``.
|
||||
"""
|
||||
|
||||
if (
|
||||
not settings.SUMMARY_SERVICE_ENDPOINT
|
||||
@@ -349,6 +370,12 @@ class NotificationService:
|
||||
"ended_at": ended_at.isoformat(),
|
||||
}
|
||||
|
||||
summary_requested = is_user_feature_flag_enabled(
|
||||
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
if audit_details is not None:
|
||||
audit_details["summary_requested"] = summary_requested
|
||||
|
||||
payload = {
|
||||
"user_sub": owner_access.user.sub,
|
||||
"user_email": owner_access.user.email,
|
||||
@@ -372,9 +399,7 @@ class NotificationService:
|
||||
),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"form_link": form_link,
|
||||
"auto_create_summary": is_user_feature_flag_enabled(
|
||||
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
),
|
||||
"auto_create_summary": summary_requested,
|
||||
},
|
||||
"metadata": metadata_payload,
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ class RecordingEventsService:
|
||||
@staticmethod
|
||||
def _notify_participants(recording: Recording, event: RecordingWorkerEvent):
|
||||
"""Notify the room's participants that a recording ended on the given event."""
|
||||
recording_mode = recording.options.get("original_mode", None) or recording.mode
|
||||
recording_mode = recording.requested_mode
|
||||
|
||||
notification_type = get_notification_type(recording_mode, event)
|
||||
if not notification_type:
|
||||
|
||||
@@ -68,7 +68,7 @@ class WorkerServiceMediator:
|
||||
finally:
|
||||
recording.save()
|
||||
|
||||
mode = recording.options.get("original_mode", None) or recording.mode
|
||||
mode = recording.requested_mode
|
||||
|
||||
try:
|
||||
RoomManagement.update_metadata(
|
||||
|
||||
@@ -12,8 +12,9 @@ from django.utils import timezone
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
from core import audit, auditing, models
|
||||
from core.recording.enums import UNSUCCESSFUL_EVENTS, RecordingWorkerEvent
|
||||
from core.recording.event.authentication import MachineUser
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
@@ -219,6 +220,7 @@ class LiveKitEventsService:
|
||||
) from err
|
||||
|
||||
event = to_recording_event(data.egress_info.status)
|
||||
self._audit_recording_end(recording, event, data.egress_info.error_code)
|
||||
|
||||
# Log if/why the recording failed
|
||||
self.recording_events.log_worker_error(
|
||||
@@ -254,6 +256,28 @@ class LiveKitEventsService:
|
||||
|
||||
self.recording_events.handle_terminal_event(recording, event)
|
||||
|
||||
@staticmethod
|
||||
def _audit_recording_end(recording, event, error_code=None):
|
||||
"""Audit the end of a recording, as LiveKit reports it.
|
||||
|
||||
An egress status the backend does not map has an unknown outcome.
|
||||
"""
|
||||
if event is None:
|
||||
outcome = audit.Outcome.UNKNOWN
|
||||
elif event in UNSUCCESSFUL_EVENTS:
|
||||
outcome = audit.Outcome.FAILURE
|
||||
else:
|
||||
outcome = audit.Outcome.SUCCESS
|
||||
audit.log(
|
||||
auditing.RECORDING_END,
|
||||
actor=MachineUser("livekit"),
|
||||
auth_method="shared_secret",
|
||||
target=recording,
|
||||
outcome=outcome,
|
||||
worker_event=event.value if event is not None else None,
|
||||
error_code=error_code or None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_connection_test_room(room_name: str) -> bool:
|
||||
"""Return True for ephemeral rooms created by the connection test endpoint."""
|
||||
|
||||
@@ -1,12 +1,81 @@
|
||||
"""Tests holding audit events to the Elastic Common Schema 9.5.0."""
|
||||
|
||||
from collections.abc import Iterator, Mapping
|
||||
from typing import Any
|
||||
|
||||
from django.apps import apps
|
||||
from django.test import RequestFactory
|
||||
|
||||
import pytest
|
||||
|
||||
from core import audit, auditing
|
||||
from core.audit import ecs
|
||||
from core.audit.admin import ADMIN_ACCESS_ACTION, AdminVerb, category_for, types_for
|
||||
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
||||
from core.recording.event.authentication import MachineUser
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# The ECS 9.5.0 fields audit events may carry, from
|
||||
# https://github.com/elastic/ecs/blob/v9.5.0/generated/ecs/ecs_flat.yml
|
||||
ECS_FIELDS = frozenset(
|
||||
{
|
||||
"@timestamp",
|
||||
"client.ip",
|
||||
"data_stream.dataset",
|
||||
"data_stream.namespace",
|
||||
"data_stream.type",
|
||||
"ecs.version",
|
||||
"entity.target.id",
|
||||
"entity.target.name",
|
||||
"entity.target.sub_type",
|
||||
"entity.target.type",
|
||||
"error.message",
|
||||
"error.stack_trace",
|
||||
"error.type",
|
||||
"event.action",
|
||||
"event.category",
|
||||
"event.dataset",
|
||||
"event.id",
|
||||
"event.kind",
|
||||
"event.outcome",
|
||||
"event.reason",
|
||||
"event.type",
|
||||
"http.request.id",
|
||||
"http.request.method",
|
||||
"http.response.status_code",
|
||||
"log.level",
|
||||
"log.logger",
|
||||
"message",
|
||||
"organization.id",
|
||||
"service.environment",
|
||||
"service.name",
|
||||
"service.node.name",
|
||||
"service.origin.name",
|
||||
"service.target.name",
|
||||
"service.version",
|
||||
"source.ip",
|
||||
"url.path",
|
||||
"user.domain",
|
||||
"user.id",
|
||||
"user.roles",
|
||||
"user.target.domain",
|
||||
"user.target.id",
|
||||
"user_agent.original",
|
||||
}
|
||||
)
|
||||
# What is not ECS lives in these namespaces: ``raw`` holds a target's own fields
|
||||
CUSTOM_NAMESPACES = ("lasuite.", "entity.target.raw.")
|
||||
|
||||
|
||||
def leaf_paths(document: Mapping[str, Any], prefix: str = "") -> Iterator[str]:
|
||||
"""Yield the dotted path of every value of a document."""
|
||||
for key, value in document.items():
|
||||
path = f"{prefix}{key}"
|
||||
if isinstance(value, Mapping):
|
||||
yield from leaf_paths(value, f"{path}.")
|
||||
else:
|
||||
yield path
|
||||
|
||||
|
||||
def declared_actions() -> list[audit.Action]:
|
||||
@@ -55,3 +124,48 @@ def test_admin_writes_are_classified_as_ecs_expects(verb):
|
||||
"""Writes made through the admin are classified as ECS expects, for any model."""
|
||||
for model in apps.get_models():
|
||||
ecs.check_classification([category_for(model)], types_for(model, verb))
|
||||
|
||||
|
||||
def _documents(audit_events) -> list[dict[str, Any]]:
|
||||
"""Emit the events covering every field the facility fills."""
|
||||
user = UserFactory(is_staff=True)
|
||||
request = RequestFactory().post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
REMOTE_ADDR="1.2.3.4",
|
||||
HTTP_USER_AGENT="Mozilla/5.0",
|
||||
)
|
||||
request.user = user
|
||||
request.auth = {"client_id": "app-1"}
|
||||
|
||||
audit.log(
|
||||
auditing.ROOM_UPDATE,
|
||||
request=request,
|
||||
target=RoomFactory(),
|
||||
status_code=500,
|
||||
outcome=audit.Outcome.FAILURE,
|
||||
reason=audit.Reason.INTERNAL_ERROR,
|
||||
error="boom",
|
||||
error_type="builtins.RuntimeError",
|
||||
message="anything",
|
||||
updated_fields=["name"],
|
||||
)
|
||||
audit.log(auditing.USER_PROVISION, target=user)
|
||||
audit.log(auditing.APPLICATION_TOKEN_ISSUE, target=ApplicationFactory())
|
||||
audit.log(
|
||||
auditing.RECORDING_TRANSCRIPT_REQUEST,
|
||||
actor=MachineUser("livekit"),
|
||||
target_service="summary",
|
||||
)
|
||||
return audit_events
|
||||
|
||||
|
||||
def test_documents_only_carry_ecs_fields_or_custom_namespaces(audit_events):
|
||||
"""No field outside ECS 9.5.0 lands anywhere but in a custom namespace."""
|
||||
for document in _documents(audit_events):
|
||||
stray = {
|
||||
path
|
||||
for path in leaf_paths(document)
|
||||
if path not in ECS_FIELDS and not path.startswith(CUSTOM_NAMESPACES)
|
||||
}
|
||||
|
||||
assert not stray, f"{document['event']['action']}: {sorted(stray)}"
|
||||
|
||||
@@ -15,6 +15,7 @@ import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.analytics import UserFeatureFlag
|
||||
from core.audit.testing import find_events
|
||||
from core.recording.event.notification import NotificationService, notification_service
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -479,3 +480,64 @@ def test_notify_summary_service_v2_payload_json_serializable_without_timestamps(
|
||||
assert isinstance(title, str)
|
||||
# ...so the payload serializes exactly the way ``requests`` serializes it.
|
||||
json.dumps(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("summary_requested", [True, False])
|
||||
@mock.patch("core.recording.event.notification.requests.post")
|
||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||
@mock.patch.object(
|
||||
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
||||
)
|
||||
def test_notify_summary_service_is_audited( # noqa: PLR0913, PLR0917
|
||||
mock_get_recording_timestamps,
|
||||
mock_generate_download_s3_url,
|
||||
mock_post,
|
||||
summary_requested,
|
||||
settings,
|
||||
audit_events,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Sending a recording to be transcribed says whether a summary is asked for."""
|
||||
settings.SUMMARY_SERVICE_VERSION = 2
|
||||
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
||||
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
||||
settings.METADATA_COLLECTOR_ENABLED = False
|
||||
|
||||
recording = factories.RecordingFactory(mode="transcript")
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
mock_get_recording_timestamps.return_value = (None, None)
|
||||
mock_generate_download_s3_url.return_value = "https://storage.test/recording.ogg"
|
||||
mock_post.return_value.json.return_value = {"job_id": "job-7"}
|
||||
|
||||
with mock.patch(
|
||||
"core.recording.event.notification.is_user_feature_flag_enabled",
|
||||
return_value=summary_requested,
|
||||
):
|
||||
assert NotificationService._notify_summary_service(recording) is True
|
||||
|
||||
[event] = find_events(audit_events, "recording.transcript.request")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "success"
|
||||
assert event["lasuite"]["actor"] == {"type": "system"}
|
||||
assert event["entity"]["target"]["id"] == str(recording.id)
|
||||
assert event["service"]["target"] == {"name": "summary"}
|
||||
assert event["lasuite"]["details"] == {
|
||||
"job_id": "job-7",
|
||||
"summary_requested": summary_requested,
|
||||
}
|
||||
|
||||
|
||||
def test_notify_summary_service_failure_is_audited(settings, audit_events):
|
||||
"""A recording the summary service never received is a failure."""
|
||||
settings.SUMMARY_SERVICE_VERSION = 2
|
||||
settings.SUMMARY_SERVICE_ENDPOINT = None
|
||||
|
||||
recording = factories.RecordingFactory(mode="transcript")
|
||||
|
||||
assert NotificationService._notify_summary_service(recording) is False
|
||||
|
||||
[event] = find_events(audit_events, "recording.transcript.request")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["entity"]["target"]["id"] == str(recording.id)
|
||||
|
||||
@@ -5,6 +5,7 @@ Test recordings API endpoints in the Meet core app: delete.
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...audit.testing import capture_audit, find_events
|
||||
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
|
||||
from ...models import Recording
|
||||
|
||||
@@ -112,3 +113,24 @@ def test_api_recordings_delete_final(role):
|
||||
|
||||
assert response.status_code == 204
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
def test_api_recordings_delete_is_audited():
|
||||
"""A deleted recording is still identified by the event."""
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory(status="saved", mode="transcript")
|
||||
UserRecordingAccessFactory(role="owner", user=user, recording=recording)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with capture_audit() as events:
|
||||
response = client.delete(f"/api/v1.0/recordings/{recording.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
[event] = find_events(events, "recording.delete")
|
||||
|
||||
assert event["event"]["type"] == ["deletion"]
|
||||
assert event["lasuite"]["outcome"] == "success"
|
||||
assert event["entity"]["target"]["id"] == str(recording.id)
|
||||
assert event["entity"]["target"]["raw"]["mode"] == "transcript"
|
||||
|
||||
@@ -6,6 +6,7 @@ Test recordings API endpoints: external process hook.
|
||||
|
||||
import pytest
|
||||
|
||||
from ...audit.testing import find_events
|
||||
from ...factories import RecordingFactory
|
||||
from ...models import RecordingStatusChoices
|
||||
|
||||
@@ -132,3 +133,83 @@ def test_external_process_event_non_transcript_event_does_not_change_status(
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == RecordingStatusChoices.SAVED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"process_type,action",
|
||||
[
|
||||
("transcript", "recording.transcript.report"),
|
||||
("summary", "recording.summary.report"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"process_status,outcome", [("success", "success"), ("failure", "failure")]
|
||||
)
|
||||
def test_external_process_event_is_audited( # noqa: PLR0913, PLR0917
|
||||
external_process_settings,
|
||||
client,
|
||||
audit_events,
|
||||
process_type,
|
||||
action,
|
||||
process_status,
|
||||
outcome,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""A transcript and a summary are audited apart, as reported by the service."""
|
||||
recording = RecordingFactory(
|
||||
status=RecordingStatusChoices.SAVED, external_process_id="job-123"
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-123", "type": process_type, "status": process_status},
|
||||
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
[event] = find_events(audit_events, action)
|
||||
|
||||
assert event["lasuite"]["outcome"] == outcome
|
||||
assert event["lasuite"]["actor"] == {"type": "service"}
|
||||
assert event["service"]["origin"] == {"name": "summary"}
|
||||
assert event["entity"]["target"]["id"] == str(recording.id)
|
||||
assert event["lasuite"]["details"] == {
|
||||
"job_id": "job-123",
|
||||
"status": process_status,
|
||||
}
|
||||
|
||||
|
||||
def test_external_process_event_unknown_recording_is_audited(
|
||||
external_process_settings, client, audit_events
|
||||
):
|
||||
"""An event about an unknown job keeps its job id, with no target."""
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-unknown", "type": "summary", "status": "success"},
|
||||
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
[event] = find_events(audit_events, "recording.summary.report")
|
||||
|
||||
assert "target" not in event["lasuite"]
|
||||
assert event["lasuite"]["details"]["job_id"] == "job-unknown"
|
||||
|
||||
|
||||
def test_external_process_event_refused_is_audited(
|
||||
external_process_settings, client, audit_events
|
||||
):
|
||||
"""A caller without the token is denied under the transcript action."""
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-1", "type": "summary", "status": "success"},
|
||||
HTTP_AUTHORIZATION="Bearer wrongToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
[event] = find_events(audit_events, "recording.transcript.report")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert not find_events(audit_events, "recording.summary.report")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test rooms API endpoints in the Meet core app: start recording.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,no-member
|
||||
# pylint: disable=redefined-outer-name,unused-argument,no-member,too-many-lines
|
||||
|
||||
from unittest import mock
|
||||
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
from livekit import api as livekit_api
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...audit.testing import capture_audit, find_events
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Recording
|
||||
from ...recording.worker.exceptions import RecordingStartError
|
||||
@@ -879,3 +880,144 @@ def test_start_recording_options_original_mode_invalid(settings, value):
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_start_recording_is_audited(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, audit_events
|
||||
):
|
||||
"""A started screen recording names the recording and its mode."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
recording = Recording.objects.get(room=room)
|
||||
[event] = find_events(audit_events, "recording.start")
|
||||
|
||||
assert event["event"]["type"] == ["start"]
|
||||
assert event["lasuite"]["outcome"] == "success"
|
||||
assert event["user"]["id"] == str(user.pk)
|
||||
assert event["entity"]["target"] == {
|
||||
"id": str(recording.pk),
|
||||
"sub_type": "recording",
|
||||
"raw": {
|
||||
"room_id": str(room.pk),
|
||||
"status": "initiated",
|
||||
"mode": "screen_recording",
|
||||
"requested_mode": "screen_recording",
|
||||
"is_transcribed": False,
|
||||
},
|
||||
}
|
||||
assert event["lasuite"]["details"] == {"collect_metadata": False}
|
||||
|
||||
|
||||
def test_start_recording_transcript_with_screen_capture_is_audited(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, audit_events
|
||||
):
|
||||
"""A transcript recorded with the screen is told apart from a screen recording."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{
|
||||
"mode": "screen_recording",
|
||||
"options": {"transcribe": True, "original_mode": "transcript"},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
[event] = find_events(audit_events, "recording.start")
|
||||
target = event["entity"]["target"]["raw"]
|
||||
|
||||
assert target["mode"] == "screen_recording"
|
||||
assert target["requested_mode"] == "transcript"
|
||||
assert target["is_transcribed"] is True
|
||||
|
||||
|
||||
def test_start_recording_conflict_is_audited_on_the_room(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, audit_events
|
||||
):
|
||||
"""A conflicting start is a failure aimed at the room, no recording existing."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
Recording.objects.create(room=room, mode="screen_recording", status="active")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "transcript"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
[event] = find_events(audit_events, "recording.start")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["event"]["reason"] == "conflict"
|
||||
assert event["entity"]["target"]["sub_type"] == "room"
|
||||
assert event["entity"]["target"]["id"] == str(room.pk)
|
||||
|
||||
|
||||
def test_start_recording_worker_error_is_audited(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, audit_events
|
||||
):
|
||||
"""A recording the worker could not start is a failure on that recording."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
mock_worker_manager.start = mock.Mock(side_effect=RecordingStartError("boom"))
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "transcript"},
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
|
||||
[event] = find_events(audit_events, "recording.start")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["http"]["response"]["status_code"] == 502
|
||||
assert event["entity"]["target"]["id"] == str(Recording.objects.get().pk)
|
||||
assert event["entity"]["target"]["raw"]["mode"] == "transcript"
|
||||
assert event["entity"]["target"]["raw"]["is_transcribed"] is True
|
||||
|
||||
|
||||
def test_start_recording_anonymous_is_audited():
|
||||
"""An anonymous attempt is denied."""
|
||||
room = RoomFactory()
|
||||
|
||||
with capture_audit() as events:
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
[event] = find_events(events, "recording.start")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert event["lasuite"]["actor"]["type"] == "anonymous"
|
||||
|
||||
@@ -9,6 +9,7 @@ from unittest import mock
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...audit.testing import find_events
|
||||
from ...factories import RecordingFactory, RoomFactory, UserFactory
|
||||
from ...models import Recording, RecordingStatusChoices
|
||||
from ...recording.worker.exceptions import RecordingStopError
|
||||
@@ -181,3 +182,48 @@ def test_stop_recording_success(
|
||||
|
||||
# Verify the recording still exists
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
def test_stop_recording_is_audited(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, audit_events
|
||||
):
|
||||
"""A stopped recording is the target of the event."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
recording = RecordingFactory(
|
||||
room=room, mode="transcript", status=RecordingStatusChoices.ACTIVE
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
[event] = find_events(audit_events, "recording.stop")
|
||||
|
||||
assert event["event"]["type"] == ["end"]
|
||||
assert event["lasuite"]["outcome"] == "success"
|
||||
assert event["entity"]["target"]["id"] == str(recording.pk)
|
||||
assert event["entity"]["target"]["raw"]["mode"] == "transcript"
|
||||
|
||||
|
||||
def test_stop_recording_without_active_recording_is_audited(settings, audit_events):
|
||||
"""Stopping a room that records nothing fails on the room."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
[event] = find_events(audit_events, "recording.stop")
|
||||
|
||||
assert event["event"]["reason"] == "not_found"
|
||||
assert event["entity"]["target"]["sub_type"] == "room"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Test LiveKitEvents service.
|
||||
"""
|
||||
# pylint: disable=W0621,W0613, W0212, E0611
|
||||
# pylint: disable=W0621,W0613, W0212, E0611, too-many-lines
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
@@ -13,6 +13,7 @@ from django.utils import timezone
|
||||
import pytest
|
||||
from livekit.api import EgressStatus
|
||||
|
||||
from core.audit.testing import find_events
|
||||
from core.factories import RecordingFactory, RoomFactory
|
||||
from core.models import Room
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
@@ -994,3 +995,76 @@ def test_participant_left_without_identity_is_ignored(mock_delete, service, sett
|
||||
|
||||
service._handle_participant_left(data) # pylint: disable=protected-access
|
||||
mock_delete.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("egress_status", "worker_event", "outcome"),
|
||||
(
|
||||
(EgressStatus.EGRESS_COMPLETE, "completed", "success"),
|
||||
(EgressStatus.EGRESS_LIMIT_REACHED, "limit reached", "success"),
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted", "failure"),
|
||||
(EgressStatus.EGRESS_FAILED, "failed", "failure"),
|
||||
),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_is_audited( # noqa: PLR0913, PLR0917
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
egress_status,
|
||||
worker_event,
|
||||
outcome,
|
||||
service,
|
||||
audit_events,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""The end of a recording is audited as LiveKit reports it."""
|
||||
|
||||
recording = RecordingFactory(
|
||||
worker_id="worker-1",
|
||||
status="active",
|
||||
mode="screen_recording",
|
||||
options={"transcribe": True},
|
||||
)
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
mock_data.egress_info.error_code = 0
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
[event] = find_events(audit_events, "recording.end")
|
||||
|
||||
assert event["event"]["type"] == ["end"]
|
||||
assert event["lasuite"]["outcome"] == outcome
|
||||
assert event["lasuite"]["actor"] == {"type": "service"}
|
||||
assert event["service"]["origin"] == {"name": "livekit"}
|
||||
assert event["lasuite"]["auth"] == {"method": "shared_secret"}
|
||||
assert event["entity"]["target"]["id"] == str(recording.id)
|
||||
assert event["entity"]["target"]["raw"]["is_transcribed"] is True
|
||||
assert event["lasuite"]["details"] == {"worker_event": worker_event}
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_with_an_unmapped_status_is_audited_as_unknown(
|
||||
mock_update_metadata, service, audit_events
|
||||
):
|
||||
"""An egress status the backend does not map has an unknown outcome."""
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = 999
|
||||
mock_data.egress_info.error_code = 0
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
[event] = find_events(audit_events, "recording.end")
|
||||
|
||||
assert event["event"]["outcome"] == "unknown"
|
||||
assert event["lasuite"]["outcome"] == "unknown"
|
||||
assert event["log"]["level"] == "warning"
|
||||
assert "details" not in event["lasuite"]
|
||||
|
||||
Reference in New Issue
Block a user