mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-11 08:03:01 +03:00
Merge remote-tracking branch 'origin/master' into dev
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
Brings the go2rtc security fixes from #24658 to dev. Dev-specific changes in the merge: - frigate/config/env.py stays deleted; the environment_vars protection for GO2RTC_ALLOW_ARBITRARY_EXEC moves to apply_config_env_vars in frigate/util/env.py, so the value never reaches os.environ - services.py imports substitute_frigate_vars from frigate.util.env and maps UnknownVariableError to the unknown variable message - go2rtc stream tests patch dev's import-time exec override snapshot and its FRIGATE_* variable sources - test_http_media.py keeps dev's _insert_recording helper; the one added on master replaced it and broke the dev tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -417,6 +417,77 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
|
||||
assert resp.status_code == 400
|
||||
assert resp.json().get("success") is False
|
||||
|
||||
def test_add_stream_rejects_ffmpeg_argument_injection(self):
|
||||
"""PUT /go2rtc/streams must reject ffmpeg: sources whose parameters can
|
||||
inject arbitrary ffmpeg arguments, since go2rtc expands them to exec:"""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
with patch("frigate.api.camera.requests.put") as mock_put:
|
||||
with AuthTestClient(app) as client:
|
||||
for src in (
|
||||
# shape of the source in GHSA-qjjw-3v66-279f
|
||||
"ffmpeg:#input='-f''h264''-i''data:placeholder''-c''copy'",
|
||||
"ffmpeg:#input=-re",
|
||||
"ffmpeg:front_door#raw=-vn",
|
||||
"ffmpeg:front_door#video=-vn",
|
||||
"ffmpeg:front_door#video=out.mp4",
|
||||
"ffmpeg:front_door#drawtext=x",
|
||||
"ffmpeg:front_door#width=1x",
|
||||
"ffmpeg:front_door#audio=/16000",
|
||||
"ffmpeg:front_door#audio=opus/",
|
||||
"ffmpeg:front_door#audio=opus/16000/1",
|
||||
"ffmpeg:'front_door'",
|
||||
" ffmpeg:#input=-re",
|
||||
):
|
||||
resp = client.put("/go2rtc/streams/injected", params={"src": src})
|
||||
assert resp.status_code == 400, (
|
||||
f"Expected 400 for ffmpeg src {src!r}; got {resp.status_code}"
|
||||
)
|
||||
assert resp.json().get("success") is False
|
||||
mock_put.assert_not_called()
|
||||
|
||||
def test_add_stream_allows_standard_ffmpeg_sources(self):
|
||||
"""ffmpeg: sources built by the UI and shown in the docs must still be
|
||||
forwarded to go2rtc"""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})()
|
||||
with patch(
|
||||
"frigate.api.camera.requests.put", return_value=mock_response
|
||||
) as mock_put:
|
||||
with AuthTestClient(app) as client:
|
||||
for src in (
|
||||
"ffmpeg:rtsp://10.0.0.1:554/video",
|
||||
"ffmpeg:rtsp://10.0.0.1:554/video#video=copy#audio=copy",
|
||||
"ffmpeg:rtsp://10.0.0.1:554/video#video=h264#hardware",
|
||||
"ffmpeg:front_door#audio=opus",
|
||||
"ffmpeg:front_door#video=h264#hardware=vaapi",
|
||||
"ffmpeg:front_door#video=h264#width=1280#rotate=-90",
|
||||
# documented HomeKit source
|
||||
"ffmpeg:front_door#video=h264#width=1280#height=720#audio=opus/16000",
|
||||
"ffmpeg:http://10.0.0.1/flv?port=1935&app=bcs#video=copy",
|
||||
):
|
||||
resp = client.put("/go2rtc/streams/legit", params={"src": src})
|
||||
assert resp.status_code == 200, (
|
||||
f"Expected 200 for ffmpeg src {src!r}; got {resp.status_code}"
|
||||
)
|
||||
forwarded_src = mock_put.call_args.kwargs["params"]["src"]
|
||||
assert forwarded_src == src
|
||||
|
||||
def test_add_stream_allows_ffmpeg_injection_when_override_set(self):
|
||||
"""GO2RTC_ALLOW_ARBITRARY_EXEC also lifts the ffmpeg: parameter check"""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})()
|
||||
with patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "true"):
|
||||
with patch(
|
||||
"frigate.api.camera.requests.put", return_value=mock_response
|
||||
) as mock_put:
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/go2rtc/streams/legit",
|
||||
params={"src": "ffmpeg:front_door#raw=-vn"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
mock_put.assert_called_once()
|
||||
|
||||
def test_stream_alias_blocked_when_owning_camera_disallowed(self):
|
||||
"""limited_user cannot access a stream alias that belongs to a camera they
|
||||
are not allowed to see."""
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Tests for go2rtc stream source restrictions on config/save and config/set."""
|
||||
|
||||
import contextlib
|
||||
import copy
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
import ruamel.yaml
|
||||
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
from frigate.util.env import _CONTAINER_ENV, FRIGATE_ENV_VARS, reload_sources
|
||||
|
||||
# shape of the source in GHSA-qjjw-3v66-279f, with the payload replaced
|
||||
_INJECTED_SRC = "ffmpeg:#input='-f''h264''-i''data:placeholder''-c''copy'"
|
||||
|
||||
# an unrestricted ffmpeg: source the operator already has in the file on disk
|
||||
_EXISTING_SRC = "ffmpeg:rtsp://10.0.0.1:554/video#raw=-vn"
|
||||
|
||||
_CONFIG = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"go2rtc": {"streams": {"front_door": [_EXISTING_SRC]}},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _frigate_vars(values: dict[str, str]):
|
||||
# config parsing rebuilds FRIGATE_ENV_VARS from its sources
|
||||
with patch.dict(_CONTAINER_ENV, values), patch.dict(FRIGATE_ENV_VARS, values):
|
||||
yield
|
||||
reload_sources(warn=False)
|
||||
|
||||
|
||||
def _dump(config: dict) -> str:
|
||||
yaml = ruamel.yaml.YAML()
|
||||
stream = tempfile.SpooledTemporaryFile(mode="w+")
|
||||
yaml.dump(config, stream)
|
||||
stream.seek(0)
|
||||
return stream.read()
|
||||
|
||||
|
||||
class TestConfigGo2rtcStreamRestrictions(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([Event, Recordings, ReviewSegment])
|
||||
self.minimal_config = _CONFIG
|
||||
self.app = super().create_app()
|
||||
|
||||
fd, self.config_path = tempfile.mkstemp(suffix=".yml")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(_dump(_CONFIG))
|
||||
self.addCleanup(os.unlink, self.config_path)
|
||||
|
||||
find_patch = patch(
|
||||
"frigate.api.app.find_config_file", return_value=self.config_path
|
||||
)
|
||||
find_patch.start()
|
||||
self.addCleanup(find_patch.stop)
|
||||
|
||||
# the override is read once at import; make sure it is off here
|
||||
exec_patch = patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", None)
|
||||
exec_patch.start()
|
||||
self.addCleanup(exec_patch.stop)
|
||||
|
||||
def _read_config(self) -> str:
|
||||
with open(self.config_path) as f:
|
||||
return f.read()
|
||||
|
||||
def _set_streams(self, streams: dict):
|
||||
with AuthTestClient(self.app) as client:
|
||||
return client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {"go2rtc": {"streams": streams}},
|
||||
"requires_restart": 1,
|
||||
},
|
||||
)
|
||||
|
||||
def _save_streams(self, streams: dict, prefix: str = ""):
|
||||
config = copy.deepcopy(_CONFIG)
|
||||
config["go2rtc"]["streams"].update(streams)
|
||||
with AuthTestClient(self.app) as client:
|
||||
return client.post(
|
||||
"/config/save?save_option=saveonly",
|
||||
content=prefix + _dump(config),
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
|
||||
# --- config/set ---
|
||||
|
||||
def test_set_rejects_injected_ffmpeg_source(self):
|
||||
before = self._read_config()
|
||||
resp = self._set_streams({"new_stream": [_INJECTED_SRC]})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["success"] is False
|
||||
assert self._read_config() == before
|
||||
|
||||
def test_set_rejects_injected_string_source(self):
|
||||
before = self._read_config()
|
||||
resp = self._set_streams({"new_stream": "ffmpeg:front_door#raw=-vn"})
|
||||
assert resp.status_code == 400
|
||||
assert self._read_config() == before
|
||||
|
||||
def test_set_rejects_injected_source_added_to_existing_stream(self):
|
||||
before = self._read_config()
|
||||
resp = self._set_streams({"front_door": [_EXISTING_SRC, _INJECTED_SRC]})
|
||||
assert resp.status_code == 400
|
||||
assert self._read_config() == before
|
||||
|
||||
def test_set_allows_standard_sources(self):
|
||||
resp = self._set_streams(
|
||||
{
|
||||
"new_stream": [
|
||||
"rtsp://10.0.0.2:554/video",
|
||||
"ffmpeg:new_stream#audio=opus",
|
||||
]
|
||||
}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "ffmpeg:new_stream#audio=opus" in self._read_config()
|
||||
|
||||
def test_set_keeps_existing_source_on_disk(self):
|
||||
resp = self._set_streams(
|
||||
{"front_door": [_EXISTING_SRC, "ffmpeg:front_door#audio=opus"]}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _EXISTING_SRC in self._read_config()
|
||||
|
||||
def test_set_allows_injected_source_when_override_set(self):
|
||||
with patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "true"):
|
||||
resp = self._set_streams({"new_stream": [_INJECTED_SRC]})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
# --- config/save ---
|
||||
|
||||
def test_save_rejects_injected_ffmpeg_source(self):
|
||||
before = self._read_config()
|
||||
resp = self._save_streams({"new_stream": [_INJECTED_SRC]})
|
||||
assert resp.status_code == 400
|
||||
assert self._read_config() == before
|
||||
|
||||
def test_save_rejects_exec_source(self):
|
||||
before = self._read_config()
|
||||
resp = self._save_streams({"new_stream": "exec:/tmp/something"})
|
||||
assert resp.status_code == 400
|
||||
assert self._read_config() == before
|
||||
|
||||
def test_save_rejects_map_source(self):
|
||||
before = self._read_config()
|
||||
resp = self._save_streams({"new_stream": {"url": "rtsp://10.0.0.2/video"}})
|
||||
assert resp.status_code == 400
|
||||
assert self._read_config() == before
|
||||
|
||||
def test_save_resubmitting_existing_config_allowed(self):
|
||||
# the UI config editor always sends back the whole file
|
||||
resp = self._save_streams({"new_stream": "rtsp://10.0.0.2:554/video"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
config = self._read_config()
|
||||
assert _EXISTING_SRC in config
|
||||
assert "rtsp://10.0.0.2:554/video" in config
|
||||
|
||||
def test_save_tagged_config_with_standard_source_allowed(self):
|
||||
# a tag on the root map still passes validation and startup parsing
|
||||
resp = self._save_streams(
|
||||
{"new_stream": "rtsp://10.0.0.2:554/video"}, prefix="!config\n"
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_save_tagged_config_with_injected_source_rejected(self):
|
||||
before = self._read_config()
|
||||
resp = self._save_streams({"new_stream": [_INJECTED_SRC]}, prefix="!config\n")
|
||||
assert resp.status_code == 400
|
||||
assert "Restricted go2rtc stream source" in resp.json()["message"]
|
||||
assert self._read_config() == before
|
||||
|
||||
# --- {FRIGATE_*} placeholders resolve the same way as at go2rtc startup ---
|
||||
|
||||
def test_set_rejects_unresolvable_placeholder(self):
|
||||
before = self._read_config()
|
||||
with _frigate_vars({"FRIGATE_RTSP_USER": "user"}):
|
||||
for src, message in (
|
||||
(
|
||||
"{FRIGATE_RTSP_USER!r}fmpeg:front_door#raw=-vn",
|
||||
"Invalid variable placeholder in go2rtc stream: new_stream",
|
||||
),
|
||||
(
|
||||
"{FRIGATE_RTSP_USER:>5}",
|
||||
"Invalid variable placeholder in go2rtc stream: new_stream",
|
||||
),
|
||||
(
|
||||
"rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video",
|
||||
"Unknown variable in go2rtc stream: new_stream",
|
||||
),
|
||||
):
|
||||
resp = self._set_streams({"new_stream": [src]})
|
||||
assert resp.status_code == 400, src
|
||||
assert resp.json()["message"] == message, src
|
||||
assert self._read_config() == before
|
||||
|
||||
def test_set_reports_each_issue(self):
|
||||
resp = self._set_streams(
|
||||
{
|
||||
"typo_stream": ["rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video"],
|
||||
"injected_stream": [_INJECTED_SRC],
|
||||
}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["message"] == (
|
||||
"Restricted go2rtc stream source in: injected_stream; "
|
||||
"Unknown variable in go2rtc stream: typo_stream"
|
||||
)
|
||||
|
||||
def test_set_allows_resolvable_placeholder(self):
|
||||
src = "rtsp://{FRIGATE_RTSP_USER}@10.0.0.2:554/video"
|
||||
with _frigate_vars({"FRIGATE_RTSP_USER": "user"}):
|
||||
resp = self._set_streams({"new_stream": [src]})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert src in self._read_config()
|
||||
@@ -4,6 +4,7 @@ import tempfile
|
||||
import zipfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.const import CLIPS_DIR
|
||||
from frigate.jobs.export import (
|
||||
ExportJob,
|
||||
get_export_job_manager,
|
||||
@@ -1051,6 +1052,65 @@ class TestHttpExport(BaseTestHttp):
|
||||
assert response.status_code == 400
|
||||
assert ExportCase.select().count() == 0
|
||||
|
||||
def _batch_export_with_image(self, image_path: str, role: str):
|
||||
with patch(
|
||||
"frigate.api.export.start_export_job",
|
||||
side_effect=lambda _config, job: job.id,
|
||||
) as start_export_job:
|
||||
with AuthTestClient(self.app) as client:
|
||||
response = client.post(
|
||||
"/exports/batch",
|
||||
headers={"remote-user": role, "remote-role": role},
|
||||
json={
|
||||
"items": [
|
||||
{
|
||||
"camera": "front_door",
|
||||
"start_time": 110,
|
||||
"end_time": 150,
|
||||
"image_path": image_path,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
return response, start_export_job
|
||||
|
||||
def test_batch_export_restricted_role_rejects_unreadable_image_path(self):
|
||||
self._insert_recording("rec-front", "front_door", 100, 400)
|
||||
self.app.frigate_config.auth.roles["limited_user"] = ["front_door"]
|
||||
|
||||
for image_path in (
|
||||
f"{CLIPS_DIR}/review/thumb-backyard-123.456-abc.webp",
|
||||
f"{CLIPS_DIR}/thumbs/backyard/123.webp",
|
||||
f"{CLIPS_DIR}/faces/someone/1.webp",
|
||||
f"{CLIPS_DIR}/custom/thumb.jpg",
|
||||
):
|
||||
response, _ = self._batch_export_with_image(image_path, "limited_user")
|
||||
|
||||
assert response.status_code == 400, image_path
|
||||
|
||||
def test_batch_export_restricted_role_accepts_own_camera_image_path(self):
|
||||
self._insert_recording("rec-front", "front_door", 100, 400)
|
||||
self.app.frigate_config.auth.roles["limited_user"] = ["front_door"]
|
||||
image_path = f"{CLIPS_DIR}/review/thumb-front_door-123.456-abc.webp"
|
||||
|
||||
response, start_export_job = self._batch_export_with_image(
|
||||
image_path, "limited_user"
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert start_export_job.call_args.args[1].image_path == image_path
|
||||
|
||||
def test_batch_export_unrestricted_roles_accept_custom_image_path(self):
|
||||
self._insert_recording("rec-front", "front_door", 100, 400)
|
||||
image_path = f"{CLIPS_DIR}/custom/thumb.jpg"
|
||||
|
||||
for role in ("admin", "viewer"):
|
||||
response, start_export_job = self._batch_export_with_image(image_path, role)
|
||||
|
||||
assert response.status_code == 202, role
|
||||
assert start_export_job.call_args.args[1].image_path == image_path
|
||||
|
||||
def test_batch_export_non_admin_can_queue(self):
|
||||
self._insert_recording("rec-front", "front_door", 100, 400)
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Tests for the /auth subrequest on nginx-proxied go2rtc live-stream paths."""
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import requests
|
||||
from fastapi.testclient import TestClient
|
||||
from joserfc.jwk import OctKey
|
||||
|
||||
from frigate.api.auth import create_encoded_jwt
|
||||
from frigate.models import Event, Recordings, ReviewSegment, User
|
||||
from frigate.test.http_api.base_http_test import BaseTestHttp
|
||||
|
||||
_CONFIG = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# shape of the source in GHSA-93xj-hg88-ffg7, with the payload replaced
|
||||
_AD_HOC_SRC = "ffmpeg:%23input%3D'-f''h264''-i''data:placeholder''-c''copy'"
|
||||
|
||||
|
||||
class TestGo2rtcProxyAuth(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([Event, ReviewSegment, Recordings, User])
|
||||
self.minimal_config = _CONFIG
|
||||
self.app = super().create_app()
|
||||
self.app.jwt_token = OctKey.import_key(b"0" * 32)
|
||||
|
||||
get_patch = patch(
|
||||
"frigate.api.auth.requests.get",
|
||||
side_effect=requests.ConnectionError("go2rtc unavailable"),
|
||||
)
|
||||
get_patch.start()
|
||||
self.addCleanup(get_patch.stop)
|
||||
|
||||
def _auth(self, role: str, original_url: str):
|
||||
token = create_encoded_jwt(
|
||||
role, role, int(time.time()) + 3600, self.app.jwt_token
|
||||
)
|
||||
with TestClient(self.app) as client:
|
||||
return client.get(
|
||||
"/auth",
|
||||
headers={
|
||||
"authorization": f"Bearer {token}",
|
||||
"x-original-url": original_url,
|
||||
},
|
||||
)
|
||||
|
||||
def test_viewer_ad_hoc_mse_source_denied(self):
|
||||
resp = self._auth("viewer", f"/live/mse/api/ws?src={_AD_HOC_SRC}")
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_viewer_ad_hoc_webrtc_sources_denied(self):
|
||||
for path in ("/live/webrtc/api/ws", "/api/go2rtc/webrtc"):
|
||||
resp = self._auth("viewer", f"{path}?src={_AD_HOC_SRC}")
|
||||
assert resp.status_code == 403, path
|
||||
|
||||
def test_admin_ad_hoc_mse_source_denied(self):
|
||||
resp = self._auth("admin", f"/live/mse/api/ws?src={_AD_HOC_SRC}")
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_viewer_camera_stream_allowed(self):
|
||||
resp = self._auth("viewer", "/live/mse/api/ws?src=front_door")
|
||||
assert resp.status_code == 202
|
||||
assert resp.headers["remote-role"] == "viewer"
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Unit tests for recordings/media API endpoints."""
|
||||
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
@@ -2019,3 +2021,79 @@ class TestHttpMedia(BaseTestHttp):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [{"start_time": 1010, "end_time": 1030}]
|
||||
|
||||
def test_recording_snapshot_does_not_block_event_loop(self):
|
||||
self._insert_recording("snapshot", 1000, 1010)
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
finished = threading.Event()
|
||||
|
||||
def slow_image(*args):
|
||||
started.set()
|
||||
release.wait(timeout=5)
|
||||
finished.set()
|
||||
return b"jpeg"
|
||||
|
||||
with (
|
||||
AuthTestClient(self.app) as client,
|
||||
patch("frigate.api.media.get_image_from_recording", slow_image),
|
||||
ThreadPoolExecutor(max_workers=1) as executor,
|
||||
):
|
||||
snapshot = executor.submit(
|
||||
client.get, "/front_door/recordings/1005/snapshot.jpg"
|
||||
)
|
||||
self.assertTrue(started.wait(timeout=2))
|
||||
version = client.get("/version")
|
||||
blocked = finished.is_set()
|
||||
release.set()
|
||||
|
||||
self.assertEqual(version.status_code, 200)
|
||||
self.assertFalse(blocked)
|
||||
self.assertEqual(snapshot.result().status_code, 200)
|
||||
|
||||
def test_recording_snapshot_hit_returns_image(self):
|
||||
self._insert_recording("snapshot", 1000, 1010)
|
||||
|
||||
with (
|
||||
AuthTestClient(self.app) as client,
|
||||
patch("frigate.api.media.get_image_from_recording", return_value=b"jpeg"),
|
||||
):
|
||||
response = client.get("/front_door/recordings/1005/snapshot.jpg")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], "image/jpeg")
|
||||
self.assertEqual(response.content, b"jpeg")
|
||||
|
||||
def test_recording_snapshot_misses_return_404(self):
|
||||
self._insert_recording("snapshot", 1000, 1010)
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
absent = client.get("/front_door/recordings/2000/snapshot.jpg")
|
||||
rounded_miss = client.get("/front_door/recordings/1010.5/snapshot.jpg")
|
||||
|
||||
self.assertEqual(absent.status_code, 404)
|
||||
self.assertEqual(rounded_miss.status_code, 404)
|
||||
|
||||
def test_plus_snapshot_miss_returns_404_without_image_lookup(self):
|
||||
with (
|
||||
AuthTestClient(self.app) as client,
|
||||
patch("frigate.api.media.get_image_from_recording") as image,
|
||||
):
|
||||
response = client.post("/front_door/plus/1005")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
image.assert_not_called()
|
||||
|
||||
def test_plus_snapshot_hit_uploads_image(self):
|
||||
self._insert_recording("snapshot", 1000, 1010)
|
||||
|
||||
with (
|
||||
AuthTestClient(self.app) as client,
|
||||
patch("frigate.api.media.get_image_from_recording", return_value=b"png"),
|
||||
patch("frigate.api.media.cv2.imdecode", return_value="frame"),
|
||||
patch.object(self.app.frigate_config.plus_api, "upload_image") as upload,
|
||||
):
|
||||
response = client.post("/front_door/plus/1005")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
upload.assert_called_once_with("frame", "front_door")
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Tests for user creation."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from frigate.models import User
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
PASSWORD = "a-valid-password-123"
|
||||
|
||||
|
||||
class TestCreateUser(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([User])
|
||||
self.app = super().create_app()
|
||||
self.app.config_publisher = MagicMock()
|
||||
|
||||
def _create(self, username: str):
|
||||
with AuthTestClient(self.app) as client:
|
||||
return client.post(
|
||||
"/users",
|
||||
json={"username": username, "password": PASSWORD, "role": "viewer"},
|
||||
)
|
||||
|
||||
def test_rejects_dot_only_usernames(self):
|
||||
# browsers resolve these as URL path segments, so the per-user
|
||||
# endpoints can never be reached for them
|
||||
for username in (".", "..", "..."):
|
||||
assert self._create(username).status_code == 400
|
||||
assert User.get_or_none(User.username == username) is None
|
||||
|
||||
def test_accepts_username_containing_dots(self):
|
||||
assert self._create("john.doe_1").status_code == 200
|
||||
assert User.get_or_none(User.username == "john.doe_1") is not None
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Tests for sharing one zmq socket wrapper across threads."""
|
||||
|
||||
import os
|
||||
import random
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import zmq
|
||||
|
||||
from frigate.comms import config_updater, embeddings_updater, inter_process
|
||||
from frigate.comms.config_updater import ConfigPublisher, ConfigSubscriber
|
||||
from frigate.comms.embeddings_updater import EmbeddingsRequestor
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.comms.webpush import WebPushClient
|
||||
|
||||
THREADS = 8
|
||||
CALLS = 100
|
||||
|
||||
# nothing reads until the end, so the total stays under the default zmq HWM
|
||||
PUBLISH_CALLS = 100
|
||||
|
||||
|
||||
class TestSharedRequestor(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.address = f"ipc://{os.path.join(self.tmp.name, 'comms')}"
|
||||
self.stop = threading.Event()
|
||||
self.context = zmq.Context()
|
||||
self.responder = self.context.socket(zmq.REP)
|
||||
self.responder.bind(self.address)
|
||||
self.responder_thread = threading.Thread(target=self._respond)
|
||||
self.responder_thread.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.stop.set()
|
||||
self.responder_thread.join()
|
||||
self.responder.close(linger=0)
|
||||
self.context.destroy(linger=0)
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _respond(self) -> None:
|
||||
while not self.stop.is_set():
|
||||
ready, _, _ = zmq.select([self.responder], [], [], 0.1)
|
||||
|
||||
if ready:
|
||||
self.responder.recv_json()
|
||||
time.sleep(random.uniform(0, 0.002))
|
||||
self.responder.send_json(["ok"])
|
||||
|
||||
def _call_from_threads(self, requestor) -> list:
|
||||
results: list = []
|
||||
|
||||
def call() -> None:
|
||||
for _ in range(CALLS):
|
||||
# jitter lands some calls on the instant another reply arrives
|
||||
time.sleep(random.uniform(0, 0.002))
|
||||
results.append(requestor.send_data("topic", {"key": "value"}))
|
||||
|
||||
threads = [threading.Thread(target=call, daemon=True) for _ in range(THREADS)]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
|
||||
for thread in threads:
|
||||
thread.join(30)
|
||||
|
||||
self.assertFalse(any(thread.is_alive() for thread in threads))
|
||||
return results
|
||||
|
||||
def test_inter_process_requestor_waits_for_overlapping_calls(self) -> None:
|
||||
with patch.object(inter_process, "SOCKET_REP_REQ", self.address):
|
||||
requestor = InterProcessRequestor()
|
||||
|
||||
results = self._call_from_threads(requestor)
|
||||
self.assertEqual(results, [["ok"]] * THREADS * CALLS)
|
||||
requestor.stop()
|
||||
|
||||
def test_embeddings_requestor_survives_overlapping_calls(self) -> None:
|
||||
with patch.object(embeddings_updater, "SOCKET_REP_REQ", self.address):
|
||||
requestor = EmbeddingsRequestor()
|
||||
|
||||
# an overlapping call may fail fast, but the socket has to stay usable
|
||||
results = self._call_from_threads(requestor)
|
||||
self.assertEqual(len(results), THREADS * CALLS)
|
||||
self.assertEqual(requestor.send_data("topic", {}), ["ok"])
|
||||
requestor.stop()
|
||||
|
||||
|
||||
class TestSharedConfigPublisher(unittest.TestCase):
|
||||
def test_frames_stay_paired_across_threads(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
address = f"ipc://{os.path.join(tmp, 'config')}"
|
||||
|
||||
with patch.object(config_updater, "SOCKET_PUB_SUB", address):
|
||||
publisher = ConfigPublisher()
|
||||
subscriber = ConfigSubscriber("config/")
|
||||
|
||||
# a subscription that is still connecting drops messages
|
||||
subscriber.socket.setsockopt(zmq.RCVTIMEO, 5000)
|
||||
|
||||
while True:
|
||||
publisher.publish("config/ready", None)
|
||||
|
||||
if zmq.select([subscriber.socket], [], [], 0.05)[0]:
|
||||
break
|
||||
|
||||
def publish(index: int) -> None:
|
||||
topic = f"config/cameras/camera_{index}"
|
||||
|
||||
for _ in range(PUBLISH_CALLS):
|
||||
publisher.publish(topic, topic)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=publish, args=(i,)) for i in range(THREADS)
|
||||
]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
publisher.publish("config/done", None)
|
||||
received = 0
|
||||
|
||||
while True:
|
||||
topic = subscriber.socket.recv_string()
|
||||
payload = subscriber.socket.recv_pyobj()
|
||||
|
||||
if topic == "config/done":
|
||||
break
|
||||
|
||||
if topic != "config/ready":
|
||||
self.assertEqual(topic, payload)
|
||||
received += 1
|
||||
|
||||
self.assertEqual(received, THREADS * PUBLISH_CALLS)
|
||||
subscriber.stop()
|
||||
publisher.stop()
|
||||
|
||||
|
||||
class FakeConfigSubscriber:
|
||||
"""Records whether two threads read at the same time."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.reading = threading.Lock()
|
||||
self.overlapped = False
|
||||
|
||||
def _read(self) -> None:
|
||||
if not self.reading.acquire(blocking=False):
|
||||
self.overlapped = True
|
||||
return
|
||||
|
||||
time.sleep(0.001)
|
||||
self.reading.release()
|
||||
|
||||
def check_for_update(self) -> tuple[None, None]:
|
||||
self._read()
|
||||
return (None, None)
|
||||
|
||||
def check_for_updates(self) -> dict:
|
||||
self._read()
|
||||
return {}
|
||||
|
||||
|
||||
class TestSharedWebPushClient(unittest.TestCase):
|
||||
def test_config_subscribers_read_by_one_thread_at_a_time(self) -> None:
|
||||
client = WebPushClient.__new__(WebPushClient)
|
||||
client.config_lock = threading.Lock()
|
||||
client.global_config_subscriber = FakeConfigSubscriber()
|
||||
client.config_subscriber = FakeConfigSubscriber()
|
||||
|
||||
def publish() -> None:
|
||||
for _ in range(20):
|
||||
client.publish("topic", "payload")
|
||||
|
||||
threads = [threading.Thread(target=publish) for _ in range(THREADS)]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
self.assertFalse(client.global_config_subscriber.overlapped)
|
||||
self.assertFalse(client.config_subscriber.overlapped)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -54,14 +54,10 @@ class TestGo2RtcAddStreamSubstitution(unittest.TestCase):
|
||||
def test_mixed_localtime_and_frigate_var(self):
|
||||
"""%{localtime\\:...} alongside {FRIGATE_USER} substitutes only the var."""
|
||||
FRIGATE_ENV_VARS["FRIGATE_USER"] = "admin"
|
||||
src = (
|
||||
"ffmpeg:rtsp://host/s#raw=-vf "
|
||||
"drawtext=text=%{localtime\\:%Y-%m-%d}:user={FRIGATE_USER}"
|
||||
)
|
||||
src = "rtsp://host/s?text=%{localtime\\:%Y-%m-%d}&user={FRIGATE_USER}"
|
||||
self.assertEqual(
|
||||
self._call_route(src),
|
||||
"ffmpeg:rtsp://host/s#raw=-vf "
|
||||
"drawtext=text=%{localtime\\:%Y-%m-%d}:user=admin",
|
||||
"rtsp://host/s?text=%{localtime\\:%Y-%m-%d}&user=admin",
|
||||
)
|
||||
|
||||
def test_unknown_var_falls_back_to_raw_src(self):
|
||||
@@ -251,6 +247,29 @@ class TestEnvVars(unittest.TestCase):
|
||||
result = validate_env_string("{FRIGATE_BROKER}")
|
||||
self.assertEqual(result, "mqtt.local")
|
||||
|
||||
def test_install_ignores_go2rtc_exec_override(self):
|
||||
"""The go2rtc exec override cannot be enabled from the config."""
|
||||
from frigate.util.services import is_go2rtc_arbitrary_exec_allowed
|
||||
|
||||
env_patch = patch.dict(os.environ)
|
||||
env_patch.start()
|
||||
self.addCleanup(env_patch.stop)
|
||||
os.environ.pop("GO2RTC_ALLOW_ARBITRARY_EXEC", None)
|
||||
ctx = self._make_context(install=True)
|
||||
with patch("frigate.util.services.os.path.isfile", return_value=False):
|
||||
for key in ("GO2RTC_ALLOW_ARBITRARY_EXEC", "go2rtc_allow_arbitrary_exec"):
|
||||
validate_env_vars({key: "true", "OTHER_VAR": "value"}, ctx)
|
||||
self.assertNotIn(key, os.environ)
|
||||
self.assertFalse(is_go2rtc_arbitrary_exec_allowed())
|
||||
self.assertEqual(os.environ.get("OTHER_VAR"), "value")
|
||||
|
||||
def test_install_keeps_container_go2rtc_exec_override(self):
|
||||
"""An override set in the container environment is left untouched."""
|
||||
ctx = self._make_context(install=True)
|
||||
with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
|
||||
validate_env_vars({"GO2RTC_ALLOW_ARBITRARY_EXEC": "false"}, ctx)
|
||||
self.assertEqual(os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC"), "true")
|
||||
|
||||
|
||||
class TestVariableSources(unittest.TestCase):
|
||||
"""Precedence between the sources that feed FRIGATE_ENV_VARS."""
|
||||
|
||||
@@ -8,6 +8,9 @@ param, which the static-media auth in `media_auth` does not inspect.
|
||||
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from frigate.api.auth import deny_response_for_go2rtc_stream
|
||||
from frigate.config import FrigateConfig
|
||||
@@ -20,6 +23,7 @@ _CONFIG = {
|
||||
"dual_user": ["front_door", "back_door"],
|
||||
}
|
||||
},
|
||||
"go2rtc": {"streams": {"doorbell_audio": ["rtsp://10.0.0.4:554/audio"]}},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
@@ -46,7 +50,9 @@ _CONFIG = {
|
||||
|
||||
|
||||
def _request(config: FrigateConfig) -> types.SimpleNamespace:
|
||||
return types.SimpleNamespace(app=types.SimpleNamespace(frigate_config=config))
|
||||
return types.SimpleNamespace(
|
||||
app=types.SimpleNamespace(frigate_config=config), headers={}
|
||||
)
|
||||
|
||||
|
||||
class TestDenyResponseForGo2rtcStream(unittest.TestCase):
|
||||
@@ -54,6 +60,20 @@ class TestDenyResponseForGo2rtcStream(unittest.TestCase):
|
||||
self.config = FrigateConfig(**_CONFIG)
|
||||
self.request = _request(self.config)
|
||||
|
||||
# go2rtc is not running in tests; individual tests override this
|
||||
get_patch = patch(
|
||||
"frigate.api.auth.requests.get",
|
||||
side_effect=requests.ConnectionError("go2rtc unavailable"),
|
||||
)
|
||||
self.mock_get = get_patch.start()
|
||||
self.addCleanup(get_patch.stop)
|
||||
|
||||
def _go2rtc_streams(self, *names: str) -> None:
|
||||
response = MagicMock(ok=True)
|
||||
response.json.return_value = {name: {} for name in names}
|
||||
self.mock_get.side_effect = None
|
||||
self.mock_get.return_value = response
|
||||
|
||||
def _deny(self, url: str, role: str):
|
||||
return deny_response_for_go2rtc_stream(url, role, self.request)
|
||||
|
||||
@@ -170,6 +190,107 @@ class TestDenyResponseForGo2rtcStream(unittest.TestCase):
|
||||
def test_missing_role_bypasses(self):
|
||||
self.assertIsNone(self._deny("http://host/live/mse/api/ws?src=back_door", None))
|
||||
|
||||
# --- ad hoc sources are denied for every role ---
|
||||
|
||||
def test_ad_hoc_ffmpeg_source_denied_for_all_roles(self):
|
||||
# shape of the source in GHSA-93xj-hg88-ffg7
|
||||
src = "ffmpeg:%23input%3D'-f''h264''-i''data:placeholder''-c''copy'"
|
||||
for path in (
|
||||
"/live/mse/api/ws",
|
||||
"/live/webrtc/api/ws",
|
||||
"/api/go2rtc/webrtc",
|
||||
):
|
||||
for role in ("viewer", "admin", "limited_user", None):
|
||||
self.assertEqual(
|
||||
self._deny(f"http://host{path}?src={src}", role),
|
||||
403,
|
||||
f"{path} {role}",
|
||||
)
|
||||
|
||||
def test_ad_hoc_source_with_name_param_denied(self):
|
||||
# name= would otherwise let go2rtc replace an existing stream's source
|
||||
self.assertEqual(
|
||||
self._deny(
|
||||
"http://host/live/mse/api/ws?src=ffmpeg:x%23video%3Dh264"
|
||||
"&name=front_door",
|
||||
"admin",
|
||||
),
|
||||
403,
|
||||
)
|
||||
|
||||
def test_ad_hoc_source_after_existing_src_denied(self):
|
||||
self.assertEqual(
|
||||
self._deny(
|
||||
"http://host/live/mse/api/ws?src=front_door&src=exec:x", "viewer"
|
||||
),
|
||||
403,
|
||||
)
|
||||
|
||||
def test_unknown_stream_denied_for_admin(self):
|
||||
self.assertEqual(
|
||||
self._deny("http://host/live/mse/api/ws?src=nonexistent", "admin"), 403
|
||||
)
|
||||
|
||||
def test_blank_src_denied_for_admin(self):
|
||||
self.assertEqual(self._deny("http://host/live/mse/api/ws?src=", "admin"), 403)
|
||||
|
||||
def test_go2rtc_config_stream_allowed(self):
|
||||
self.assertIsNone(
|
||||
self._deny("http://host/live/mse/api/ws?src=doorbell_audio", "viewer")
|
||||
)
|
||||
self.mock_get.assert_not_called()
|
||||
|
||||
def test_runtime_go2rtc_stream_allowed(self):
|
||||
# streams added through PUT /go2rtc/streams only exist in go2rtc
|
||||
self._go2rtc_streams("front_door", "wizard_test")
|
||||
self.assertIsNone(
|
||||
self._deny("http://host/live/mse/api/ws?src=wizard_test", "admin")
|
||||
)
|
||||
|
||||
def test_runtime_lookup_does_not_admit_ad_hoc_source(self):
|
||||
self._go2rtc_streams("front_door", "wizard_test")
|
||||
self.assertEqual(
|
||||
self._deny("http://host/live/mse/api/ws?src=ffmpeg:wizard_test", "admin"),
|
||||
403,
|
||||
)
|
||||
|
||||
def test_go2rtc_error_fails_closed(self):
|
||||
self.mock_get.side_effect = None
|
||||
self.mock_get.return_value = MagicMock(ok=False)
|
||||
self.assertEqual(
|
||||
self._deny("http://host/live/mse/api/ws?src=wizard_test", "admin"), 403
|
||||
)
|
||||
|
||||
# --- path spellings nginx normalizes before matching its location ---
|
||||
|
||||
def test_path_variants_are_checked(self):
|
||||
for path in (
|
||||
"/live/mse/api/%77s",
|
||||
"/live/mse/api%2Fws",
|
||||
"//live/mse/api/ws",
|
||||
"/live//mse/api/ws",
|
||||
"/live/mse/api/./ws",
|
||||
"/live/other/../mse/api/ws",
|
||||
"/live/mse/api/%2e/ws",
|
||||
"/%6Cive/webrtc/api/ws",
|
||||
"/api/go2rtc/%77ebrtc",
|
||||
"/live/mse/api/ws/",
|
||||
"/live/mse/api/ws/extra",
|
||||
"http://host//live/mse/api/ws",
|
||||
):
|
||||
self.assertEqual(self._deny(f"{path}?src=nonexistent", "viewer"), 403, path)
|
||||
|
||||
def test_path_variants_allow_existing_stream(self):
|
||||
self.assertIsNone(self._deny("/live/mse/api/%77s?src=front_door", "viewer"))
|
||||
|
||||
def test_nginx_marker_applies_check(self):
|
||||
self.request.headers = {"x-go2rtc-stream-proxy": "1"}
|
||||
self.assertEqual(self._deny("/unexpected?src=nonexistent", "viewer"), 403)
|
||||
|
||||
def test_unrelated_paths_not_checked(self):
|
||||
for path in ("/live/mse/api", "/api/go2rtc/streams", "/live/jsmpeg/x"):
|
||||
self.assertIsNone(self._deny(f"{path}?src=nonexistent", "viewer"), path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import frigate.__main__ as frigate_main
|
||||
import frigate.log
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.util.process import FrigateProcess
|
||||
|
||||
ENV_KEY = "FRIGATE_TEST_PROCESS_ENV"
|
||||
|
||||
|
||||
class EnvReportingProcess(FrigateProcess):
|
||||
def __init__(self, stop_event, result_queue):
|
||||
super().__init__(stop_event, 0, name="env_reporter", daemon=True)
|
||||
self.result_queue = result_queue
|
||||
|
||||
def run(self) -> None:
|
||||
self.pre_run_setup()
|
||||
self.result_queue.put(os.environ.get(ENV_KEY))
|
||||
|
||||
|
||||
def _run_main_and_report(result_queue) -> None:
|
||||
"""Run frigate's main() with the config load and app faked out.
|
||||
|
||||
Runs in a fresh interpreter so no earlier test has started the forkserver.
|
||||
"""
|
||||
mp.set_start_method("forkserver", force=True)
|
||||
|
||||
def load(**kwargs):
|
||||
os.environ[ENV_KEY] = "applied"
|
||||
return SimpleNamespace()
|
||||
|
||||
def setup_logging(manager):
|
||||
frigate.log.log_listener = SimpleNamespace(queue=manager.Queue())
|
||||
|
||||
class FakeApp:
|
||||
def __init__(self, config, manager, stop_event):
|
||||
self.stop_event = stop_event
|
||||
|
||||
def start(self):
|
||||
child_queue = mp.Queue()
|
||||
process = EnvReportingProcess(self.stop_event, child_queue)
|
||||
process.start()
|
||||
result_queue.put(child_queue.get(timeout=30))
|
||||
process.join(timeout=10)
|
||||
|
||||
try:
|
||||
with (
|
||||
patch.object(sys, "argv", ["frigate"]),
|
||||
patch.object(FrigateConfig, "load", load),
|
||||
patch("frigate.__main__.setup_logging", setup_logging),
|
||||
patch("frigate.__main__.FrigateApp", FakeApp),
|
||||
):
|
||||
frigate_main.main()
|
||||
except Exception as err:
|
||||
result_queue.put(f"error: {err!r}")
|
||||
|
||||
|
||||
class TestWorkerEnvironment(unittest.TestCase):
|
||||
def test_env_applied_by_config_load_reaches_worker_processes(self):
|
||||
ctx = mp.get_context("spawn")
|
||||
result_queue = ctx.Queue()
|
||||
runner = ctx.Process(target=_run_main_and_report, args=(result_queue,))
|
||||
runner.start()
|
||||
|
||||
try:
|
||||
self.assertEqual(result_queue.get(timeout=60), "applied")
|
||||
finally:
|
||||
runner.join(timeout=10)
|
||||
|
||||
if runner.is_alive():
|
||||
runner.terminate()
|
||||
runner.join()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,7 +6,7 @@ import unittest
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.util.builtin import update_yaml_file_bulk
|
||||
from frigate.util.builtin import clear_orphaned_comments, update_yaml_file_bulk
|
||||
|
||||
|
||||
class TestUpdateYaml(unittest.TestCase):
|
||||
@@ -178,6 +178,27 @@ class TestUpdateYaml(unittest.TestCase):
|
||||
assert data["cameras"]["cam1"]["detect"]["fps"] == 5
|
||||
assert "# tuned for the pi" in self._read()
|
||||
|
||||
def test_delete_only_camera_with_comment_on_cameras_key(self):
|
||||
"""Deleting the only camera stays parseable when the cameras key
|
||||
carries the default config's trailing comment."""
|
||||
self._write(
|
||||
"cameras: # No cameras defined, UI wizard should be used\n"
|
||||
" cam1:\n"
|
||||
" enabled: true\n"
|
||||
"version: 0.18-0\n"
|
||||
)
|
||||
yaml = YAML()
|
||||
with open(self.config_path) as f:
|
||||
data = yaml.load(f)
|
||||
del data["cameras"]["cam1"]
|
||||
clear_orphaned_comments(data["cameras"], data, "cameras")
|
||||
with open(self.config_path, "w") as f:
|
||||
yaml.dump(data, f)
|
||||
|
||||
data = self._load()
|
||||
assert data["cameras"] == {}
|
||||
assert data["version"] == "0.18-0"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Tests for push notification subscription validation."""
|
||||
|
||||
import unittest
|
||||
from base64 import urlsafe_b64encode
|
||||
|
||||
from frigate.api.notification import _validate_push_endpoint, _validate_subscription
|
||||
from frigate.comms.webpush import _build_web_pushers
|
||||
|
||||
VALID_ENDPOINTS = [
|
||||
"https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu",
|
||||
@@ -148,3 +150,16 @@ class TestValidateSubscription(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestBuildWebPushers(unittest.TestCase):
|
||||
def test_skips_subscriptions_with_malformed_keys(self):
|
||||
bad = _subscription(VALID_ENDPOINTS[0])
|
||||
good = _subscription(VALID_ENDPOINTS[0])
|
||||
good["keys"]["p256dh"] = urlsafe_b64encode(b"\x04" + bytes(64)).decode()
|
||||
|
||||
with self.assertLogs("frigate.comms.webpush", level="WARNING"):
|
||||
pushers = _build_web_pushers("viewer", [bad, good])
|
||||
|
||||
self.assertEqual(len(pushers), 1)
|
||||
self.assertEqual(pushers[0].receiver_key, b"\x04" + bytes(64))
|
||||
|
||||
Reference in New Issue
Block a user