Files
frigate/frigate/test/test_plus.py
T
81cf7be89a Add on-demand Frigate+ API key validation (#24918)
* 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>
2026-11-09 06:30:35 -07:00

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)