mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-10 07:33:00 +03:00
* Add on-demand Frigate+ API key validation The Frigate+ settings page reported the API key as validated whenever it matched the expected format, without ever checking it with the server. - Clarify the status text to say the key matches the expected format - Add PlusApi.validate_key(), which requests a token from Frigate+ and reports the key as valid, invalid, unreachable, or not set; server errors and timeouts are not reported as an invalid key - Add an admin-only POST /plus/validate endpoint and regenerate the API spec - Add a "Validate with Frigate+" button that shows the result inline Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde * Validate Frigate+ key asynchronously and keep cached token on bad replies - Use aiohttp for the validation request so the endpoint awaits it instead of blocking a worker thread - Only replace the cached token when the reply has a string accessToken and numeric expires; otherwise report unreachable so an unexpected response can't break later Frigate+ requests - Test against a local aiohttp server, covering timeouts, connection failures, non-JSON replies and unusable token shapes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde * Honor proxy settings when validating the Frigate+ key aiohttp ignores HTTP(S)_PROXY unless trust_env is set, so validation reported Frigate+ as unreachable on installs that need a proxy while other Frigate+ requests still worked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
7.9 KiB
Python
217 lines
7.9 KiB
Python
"""Tests for Frigate+ model metadata."""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import aiohttp
|
|
from aiohttp import web
|
|
from aiohttp.test_utils import TestServer
|
|
|
|
from frigate.plus import PlusApi, PlusKeyStatus, load_plus_model_info
|
|
|
|
|
|
class TestHailoAlias(unittest.TestCase):
|
|
"""Frigate+ reports Hailo models by the detector's pre-rename key.
|
|
|
|
Both the model list and a cached info file have to carry the current key,
|
|
or every Hailo model reads as unsupported.
|
|
"""
|
|
|
|
def _get_models(self, models: list[dict]) -> list[dict]:
|
|
api = PlusApi.__new__(PlusApi)
|
|
response = MagicMock(ok=True, json=lambda: {"list": models})
|
|
|
|
with patch.object(PlusApi, "_get", return_value=response):
|
|
return api.get_models()["list"]
|
|
|
|
def test_the_model_list_gains_the_current_key(self):
|
|
models = self._get_models([{"supportedDetectors": ["hailo8l"]}])
|
|
|
|
self.assertEqual(models[0]["supportedDetectors"], ["hailo8l", "hailo"])
|
|
|
|
def test_the_old_key_is_kept_for_older_versions(self):
|
|
models = self._get_models([{"supportedDetectors": ["hailo8l"]}])
|
|
|
|
self.assertIn("hailo8l", models[0]["supportedDetectors"])
|
|
|
|
def test_other_detectors_are_untouched(self):
|
|
models = self._get_models([{"supportedDetectors": ["openvino", "onnx"]}])
|
|
|
|
self.assertEqual(models[0]["supportedDetectors"], ["openvino", "onnx"])
|
|
|
|
def test_a_model_already_naming_both_is_unchanged(self):
|
|
models = self._get_models([{"supportedDetectors": ["hailo8l", "hailo"]}])
|
|
|
|
self.assertEqual(models[0]["supportedDetectors"], ["hailo8l", "hailo"])
|
|
|
|
def test_an_empty_list(self):
|
|
self.assertEqual(self._get_models([]), [])
|
|
|
|
|
|
class TestLoadPlusModelInfo(unittest.TestCase):
|
|
def setUp(self):
|
|
self.cache = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.cache.cleanup)
|
|
patcher = patch("frigate.plus.MODEL_CACHE_DIR", self.cache.name)
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def _write(self, model_id: str, content: str) -> None:
|
|
with open(os.path.join(self.cache.name, f"{model_id}.json"), "w") as f:
|
|
f.write(content)
|
|
|
|
def test_a_cached_hailo_model_gains_the_current_key(self):
|
|
self._write("abc", json.dumps({"supportedDetectors": ["hailo8l"]}))
|
|
|
|
self.assertEqual(
|
|
load_plus_model_info("abc")["supportedDetectors"], ["hailo8l", "hailo"]
|
|
)
|
|
|
|
def test_a_missing_file(self):
|
|
self.assertIsNone(load_plus_model_info("nope"))
|
|
|
|
def test_an_unreadable_file(self):
|
|
self._write("bad", "{not json")
|
|
|
|
self.assertIsNone(load_plus_model_info("bad"))
|
|
|
|
|
|
class TestValidateKey(unittest.IsolatedAsyncioTestCase):
|
|
"""The key format is checked locally, but only the server knows if it is valid."""
|
|
|
|
CACHED_TOKEN = {"accessToken": "cached", "expires": 2000000000}
|
|
|
|
async def _serve(self, handler) -> str:
|
|
"""Serve the token endpoint locally and return its base url."""
|
|
app = web.Application()
|
|
app.router.add_get("/v1/auth/token", handler)
|
|
server = TestServer(app)
|
|
await server.start_server()
|
|
self.addAsyncCleanup(server.close)
|
|
return str(server.make_url("")).rstrip("/")
|
|
|
|
def _api(self, host: str, key: str | None = "id:secret") -> PlusApi:
|
|
api = PlusApi.__new__(PlusApi)
|
|
api.host = host
|
|
api.key = key
|
|
api._token_data = dict(self.CACHED_TOKEN)
|
|
return api
|
|
|
|
async def _validate(self, handler) -> tuple[PlusKeyStatus, PlusApi]:
|
|
api = self._api(await self._serve(handler))
|
|
return await api.validate_key(), api
|
|
|
|
def _replies(self, status: int = 200, body: object = None, text: str | None = None):
|
|
async def handler(request: web.Request) -> web.Response:
|
|
self.auth = request.headers.get("Authorization")
|
|
if text is not None:
|
|
return web.Response(status=status, text=text)
|
|
return web.json_response(body, status=status)
|
|
|
|
return handler
|
|
|
|
async def test_an_accepted_key_is_valid(self):
|
|
token = {"accessToken": "fresh", "expires": 2000000500.5}
|
|
status, api = await self._validate(self._replies(body=token))
|
|
|
|
self.assertEqual(status, PlusKeyStatus.valid)
|
|
self.assertEqual(api._token_data, token)
|
|
self.assertEqual(self.auth, aiohttp.BasicAuth("id", "secret").encode())
|
|
|
|
async def test_a_rejected_key_is_invalid(self):
|
|
for code in (401, 403):
|
|
status, api = await self._validate(self._replies(code, {"error": "no"}))
|
|
|
|
self.assertEqual(status, PlusKeyStatus.invalid)
|
|
self.assertEqual(api._token_data, self.CACHED_TOKEN)
|
|
|
|
async def test_a_server_error_is_not_reported_as_invalid(self):
|
|
status, _ = await self._validate(self._replies(500, {"error": "down"}))
|
|
|
|
self.assertEqual(status, PlusKeyStatus.unreachable)
|
|
|
|
async def test_unusable_replies_keep_the_cached_token(self):
|
|
# a successful reply without a usable token must not replace one that
|
|
# later Frigate+ requests depend on
|
|
for body in (
|
|
None,
|
|
[],
|
|
{},
|
|
{"accessToken": "t"},
|
|
{"expires": 2000000000},
|
|
{"accessToken": 1, "expires": 2000000000},
|
|
{"accessToken": "t", "expires": "soon"},
|
|
{"accessToken": "t", "expires": True},
|
|
):
|
|
with self.subTest(body=body):
|
|
status, api = await self._validate(self._replies(body=body))
|
|
|
|
self.assertEqual(status, PlusKeyStatus.unreachable)
|
|
self.assertEqual(api._token_data, self.CACHED_TOKEN)
|
|
|
|
async def test_a_non_json_reply_keeps_the_cached_token(self):
|
|
status, api = await self._validate(self._replies(text="<html>"))
|
|
|
|
self.assertEqual(status, PlusKeyStatus.unreachable)
|
|
self.assertEqual(api._token_data, self.CACHED_TOKEN)
|
|
|
|
async def test_a_connection_failure_is_unreachable(self):
|
|
# serve and stop, so the port refuses connections
|
|
server = TestServer(web.Application())
|
|
await server.start_server()
|
|
host = str(server.make_url("")).rstrip("/")
|
|
await server.close()
|
|
api = self._api(host)
|
|
|
|
self.assertEqual(await api.validate_key(), PlusKeyStatus.unreachable)
|
|
|
|
async def test_a_slow_server_times_out_as_unreachable(self):
|
|
async def slow(request: web.Request) -> web.Response:
|
|
await asyncio.sleep(5)
|
|
return web.json_response(self.CACHED_TOKEN)
|
|
|
|
with patch("frigate.plus.PLUS_VALIDATE_TIMEOUT", 0.1):
|
|
status, _ = await self._validate(slow)
|
|
|
|
self.assertEqual(status, PlusKeyStatus.unreachable)
|
|
|
|
async def test_the_request_goes_through_a_configured_proxy(self):
|
|
# installs that reach Frigate+ through a proxy must validate the same way
|
|
token = {"accessToken": "proxied", "expires": 2000000000}
|
|
proxied = []
|
|
|
|
async def proxy(request: web.Request) -> web.Response:
|
|
proxied.append(str(request.url))
|
|
return web.json_response(token)
|
|
|
|
proxy_url = await self._serve(proxy)
|
|
api = self._api("http://plus.invalid")
|
|
|
|
with patch.dict(
|
|
os.environ, {"HTTP_PROXY": proxy_url, "NO_PROXY": ""}, clear=False
|
|
):
|
|
status = await api.validate_key()
|
|
|
|
self.assertEqual(status, PlusKeyStatus.valid)
|
|
self.assertEqual(proxied, ["http://plus.invalid/v1/auth/token"])
|
|
|
|
async def test_no_key_is_not_sent_to_the_server(self):
|
|
requests_seen = []
|
|
|
|
async def handler(request: web.Request) -> web.Response:
|
|
requests_seen.append(request)
|
|
return web.json_response(self.CACHED_TOKEN)
|
|
|
|
api = self._api(await self._serve(handler), key=None)
|
|
|
|
self.assertEqual(await api.validate_key(), PlusKeyStatus.not_set)
|
|
self.assertEqual(requests_seen, [])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|