diff --git a/.env.example b/.env.example index 7ca6e2c..35b57b0 100644 --- a/.env.example +++ b/.env.example @@ -46,8 +46,11 @@ HERMES_GATEWAY_URL= FCM_CREDENTIALS= FCM_PROJECT_ID= -# Optional calendar adapter. The command must be a JSON array, never a shell -# command. Example: ["python","/integration/google_api.py"] +# Optional Google Calendar adapter. Prefer a read-only mount of an authorized +# user token created by Hermes' google-workspace setup. The legacy command must +# be a JSON array, never a shell command, and is used only when credentials are +# not configured. Example: ["python","/integration/google_api.py"] +GOOGLE_CALENDAR_CREDENTIALS= GOOGLE_CALENDAR_COMMAND= GOOGLE_CALENDAR_HOME_ID= GOOGLE_CALENDAR_HOME_NAME=Home diff --git a/integrations/hermes/README.md b/integrations/hermes/README.md index 9ab2cc6..5ff77bf 100644 --- a/integrations/hermes/README.md +++ b/integrations/hermes/README.md @@ -81,6 +81,30 @@ Optional settings include `HERMES_DASHBOARD_HOST`, `HERMES_CONTROL_PLANE_HOST`, `HERMES_SSH_USER`, and `HERMES_SSH_KEY`. Home Assistant variables are optional. +## Google Calendar + +Dante can reuse a Calendar-only authorized-user token created through Hermes' +built-in `google-workspace` setup without exposing it to clients or the model. +Do not mount a broader Gmail/Drive/Workspace token. Copy the Calendar-only +`google_token.json` to an operator-owned secrets directory, keep it mode +`0600`, and mount the copy read-only into the Dante container as shown in +`ops/compose.integrations.example.yml`. Then configure: + +```text +GOOGLE_CALENDAR_CREDENTIALS=/run/secrets/dante/google-calendar-token.json +GOOGLE_CALENDAR_HOME_ID= +GOOGLE_CALENDAR_HOME_NAME=Home +``` + +The token should contain only the Google Calendar scope. Dante refreshes +short-lived access credentials in memory and never rewrites the mounted token. +When the mounted token changes after reauthorization, Dante reloads it on the +next request. Only the configured household calendar ID is used for reads and +mutations. The older +`GOOGLE_CALENDAR_COMMAND` transport remains available for existing installs, +but Hermes' current `google_api.py` does not implement event updates; use the +native credentials transport for the full list/create/update/delete contract. + Host SSH is opt-in. `configure-primary.py` preserves the current terminal backend unless `HERMES_ENABLE_HOST_SSH=true` is set. diff --git a/ops/compose.integrations.example.yml b/ops/compose.integrations.example.yml index 67a163d..d8d4f49 100644 --- a/ops/compose.integrations.example.yml +++ b/ops/compose.integrations.example.yml @@ -11,6 +11,7 @@ services: FCM_CREDENTIALS: /run/secrets/dante/firebase-service-account.json FIT_IMAP_PASS_FILE: /run/secrets/dante/fit-imap-password FIT_IMPORT_POLICY_FILE: /run/secrets/dante/fit-import-policy.json + GOOGLE_CALENDAR_CREDENTIALS: /run/secrets/dante/google-calendar-token.json TRENING_DIR: /data/training volumes: - type: bind @@ -25,6 +26,12 @@ services: source: /path/to/private/fit-import-policy.json target: /run/secrets/dante/fit-import-policy.json read_only: true + # Copy a Calendar-only authorized-user token produced by Hermes setup. + # Dante refreshes access credentials in memory, so this mount stays read-only. + - type: bind + source: /path/to/private/google-calendar-token.json + target: /run/secrets/dante/google-calendar-token.json + read_only: true - type: bind source: /path/to/read-only/training-data target: /data/training diff --git a/server/dante/calendar_adapter.py b/server/dante/calendar_adapter.py index 7ceebe8..3f5e8e2 100644 --- a/server/dante/calendar_adapter.py +++ b/server/dante/calendar_adapter.py @@ -1,17 +1,20 @@ -"""Thin, token-free adapter to Hermes' existing google-workspace CLI.""" +"""Google Calendar adapter with native OAuth and legacy Hermes CLI transports.""" from __future__ import annotations import asyncio import json import re import shlex +import threading import time from datetime import date, datetime, timedelta +from pathlib import Path from typing import Any from zoneinfo import ZoneInfo from . import db, i18n, profile_directory -from .config import (GOOGLE_CALENDAR_COMMAND, GOOGLE_CALENDAR_HOME_ID, +from .config import (GOOGLE_CALENDAR_COMMAND, GOOGLE_CALENDAR_CREDENTIALS, + GOOGLE_CALENDAR_HOME_ID, GOOGLE_CALENDAR_HOME_NAME, GOOGLE_CALENDAR_TIMEOUT, DANTE_DEFAULT_TIMEZONE) @@ -20,6 +23,8 @@ _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") _cache: dict[tuple[str, int, str], tuple[float, list[dict]]] = {} CACHE_SECONDS = 120 +_credentials_lock = threading.Lock() +_credentials_cache: tuple[str, tuple[int, int], Any] | None = None class CalendarError(RuntimeError): @@ -43,7 +48,8 @@ class CalendarValidationError(CalendarError): def configured() -> bool: - return bool(GOOGLE_CALENDAR_COMMAND.strip() and GOOGLE_CALENDAR_HOME_ID.strip()) + transport = GOOGLE_CALENDAR_COMMAND.strip() or GOOGLE_CALENDAR_CREDENTIALS.strip() + return bool(transport and GOOGLE_CALENDAR_HOME_ID.strip()) def _command_prefix() -> list[str]: @@ -84,6 +90,19 @@ def status(profile: str) -> dict: async def _execute(args: list[str]) -> Any: + if GOOGLE_CALENDAR_CREDENTIALS.strip(): + try: + return await asyncio.wait_for( + asyncio.to_thread(_execute_google, args), + timeout=GOOGLE_CALENDAR_TIMEOUT, + ) + except asyncio.TimeoutError as exc: + raise CalendarError(i18n.translate("errors.calendar_timeout")) from exc + except CalendarError: + raise + except Exception as exc: + raise _google_error(exc) from exc + command = [*_command_prefix(), *args] try: process = await asyncio.create_subprocess_exec( @@ -105,8 +124,17 @@ async def _execute(args: list[str]) -> Any: if process.returncode != 0: safe_error = stderr.decode("utf-8", "replace").lower() if any(marker in safe_error for marker in ( - "invalid_grant", "not authenticated", "run the setup script", "reauth", - "token has been expired", "token has been revoked", + "invalid_grant", + "not authenticated", + "not_authenticated", + "refresh_failed", + "run the setup script", + "token is invalid", + "token has been expired", + "token has been revoked", + "insufficient authentication scopes", + "insufficient permission", + "reauth", )): raise CalendarReauthRequired(i18n.translate("errors.calendar_reauth_required")) raise CalendarError(i18n.translate("errors.calendar_no_data")) @@ -116,6 +144,167 @@ async def _execute(args: list[str]) -> Any: raise CalendarError(i18n.translate("errors.calendar_invalid_response")) from exc +def _google_error(exc: Exception) -> CalendarError: + """Map provider failures without exposing OAuth or API response details.""" + + safe_type = type(exc).__name__.lower() + safe_message = str(exc).lower() + response_status = getattr(getattr(exc, "resp", None), "status", None) + reauth_markers = ( + "invalid_grant", + "insufficient authentication scopes", + "insufficient permission", + "token has been expired", + "token has been revoked", + "reauth", + ) + if ( + safe_type == "refresherror" + or response_status == 401 + or any(marker in safe_message for marker in reauth_markers) + ): + return CalendarReauthRequired(i18n.translate("errors.calendar_reauth_required")) + return CalendarError(i18n.translate("errors.calendar_no_data")) + + +def _credentials() -> Any: + """Load one read-only authorized-user token and refresh it only in memory.""" + + global _credentials_cache + configured_path = GOOGLE_CALENDAR_CREDENTIALS.strip() + if not configured_path: + raise CalendarDisabled(i18n.translate("errors.calendar_disabled")) + path = Path(configured_path).expanduser() + credentials_path = str(path) + metadata = path.stat() + fingerprint = (metadata.st_mtime_ns, metadata.st_size) + with _credentials_lock: + if ( + _credentials_cache is None + or _credentials_cache[:2] != (credentials_path, fingerprint) + ): + from google.oauth2.credentials import Credentials + + _credentials_cache = ( + credentials_path, + fingerprint, + Credentials.from_authorized_user_file(credentials_path), + ) + credentials = _credentials_cache[2] + if not credentials.valid: + if not credentials.refresh_token: + from google.auth.exceptions import RefreshError + + raise RefreshError("Google Calendar authorization must be renewed") + from google.auth.transport.requests import Request + + credentials.refresh(Request()) + return credentials + + +def _option(args: list[str], name: str, default: str = "") -> str: + try: + return args[args.index(name) + 1] + except (ValueError, IndexError): + return default + + +def _execute_google(args: list[str]) -> Any: + """Execute the small allowlisted Calendar contract directly against Google.""" + + if len(args) < 2 or args[0] != "calendar": + raise CalendarError(i18n.translate("errors.calendar_no_data")) + import httplib2 + from google_auth_httplib2 import AuthorizedHttp + from googleapiclient.discovery import build + + service = build( + "calendar", + "v3", + http=AuthorizedHttp( + _credentials(), + http=httplib2.Http(timeout=GOOGLE_CALENDAR_TIMEOUT), + ), + cache_discovery=False, + ) + action = args[1] + # Never accept a provider calendar ID from a caller. Public APIs and model + # tools are always confined to the operator-configured household calendar. + calendar_id = GOOGLE_CALENDAR_HOME_ID + + if action == "list": + result = service.events().list( + calendarId=calendar_id, + timeMin=_option(args, "--start"), + timeMax=_option(args, "--end"), + maxResults=int(_option(args, "--max", str(MAX_EVENTS))), + singleEvents=True, + orderBy="startTime", + ).execute() + return [ + { + "id": event.get("id", ""), + "summary": event.get("summary", ""), + "start": event.get("start", {}).get( + "dateTime", event.get("start", {}).get("date", "") + ), + "end": event.get("end", {}).get( + "dateTime", event.get("end", {}).get("date", "") + ), + "location": event.get("location", ""), + "description": event.get("description", ""), + "status": event.get("status", ""), + "htmlLink": event.get("htmlLink", ""), + } + for event in result.get("items", []) + ] + + if action in {"create", "update"}: + event = { + "summary": _option(args, "--summary"), + "start": {"dateTime": _option(args, "--start")}, + "end": {"dateTime": _option(args, "--end")}, + "location": _option(args, "--location"), + "description": _option(args, "--description"), + } + if action == "create": + result = service.events().insert( + calendarId=calendar_id, + body=event, + ).execute() + provider_status = "created" + else: + if len(args) < 3 or not args[2]: + raise CalendarValidationError( + i18n.translate("errors.calendar_event_id_required") + ) + result = service.events().patch( + calendarId=calendar_id, + eventId=args[2], + body=event, + ).execute() + provider_status = "updated" + return { + "status": provider_status, + "id": result.get("id", ""), + "summary": result.get("summary", ""), + "htmlLink": result.get("htmlLink", ""), + } + + if action == "delete": + if len(args) < 3 or not args[2]: + raise CalendarValidationError( + i18n.translate("errors.calendar_event_id_required") + ) + service.events().delete( + calendarId=calendar_id, + eventId=args[2], + ).execute() + return {"status": "deleted", "eventId": args[2]} + + raise CalendarError(i18n.translate("errors.calendar_no_data")) + + def _zone(timezone_name: str | None) -> ZoneInfo: try: return ZoneInfo(str(timezone_name or DANTE_DEFAULT_TIMEZONE)) diff --git a/server/dante/config.py b/server/dante/config.py index e9fb78f..55013eb 100644 --- a/server/dante/config.py +++ b/server/dante/config.py @@ -42,9 +42,10 @@ def _env(name: str, default: str = "") -> str: HERMES_GATEWAY_URL = _env("HERMES_GATEWAY_URL").rstrip("/") HERMES_CHAT_TRANSPORT = _env("HERMES_CHAT_TRANSPORT", "runs_api").lower() -# Cienki adapter do istniejącego skilla Hermes google-workspace. Polecenie jest prefiksem -# JSON-array (bez shella), do którego backend dopisuje `calendar list/create ...`. +# Natywny transport Google Calendar używa montowanego tokenu authorized-user. +# Starszy transport CLI pozostaje prefiksem JSON-array (bez shella). GOOGLE_CALENDAR_COMMAND = _env("GOOGLE_CALENDAR_COMMAND") +GOOGLE_CALENDAR_CREDENTIALS = _env("GOOGLE_CALENDAR_CREDENTIALS") GOOGLE_CALENDAR_HOME_ID = _env("GOOGLE_CALENDAR_HOME_ID") GOOGLE_CALENDAR_HOME_NAME = _env("GOOGLE_CALENDAR_HOME_NAME", "Home") try: @@ -155,6 +156,25 @@ def validate_runtime_config(*, strict: bool | None = None) -> None: problems.append( "DEVICE_AUTH_MODE=optional requires DANTE_ALLOW_LEGACY_AUTH=true" ) + calendar_transport = bool( + GOOGLE_CALENDAR_CREDENTIALS or GOOGLE_CALENDAR_COMMAND + ) + if calendar_transport and not GOOGLE_CALENDAR_HOME_ID: + problems.append( + "Google Calendar transport requires GOOGLE_CALENDAR_HOME_ID" + ) + if GOOGLE_CALENDAR_HOME_ID and not calendar_transport: + problems.append( + "GOOGLE_CALENDAR_HOME_ID requires a Google Calendar transport" + ) + if GOOGLE_CALENDAR_CREDENTIALS: + if not os.path.isabs(GOOGLE_CALENDAR_CREDENTIALS): + problems.append("GOOGLE_CALENDAR_CREDENTIALS must be an absolute path") + elif not ( + os.path.isfile(GOOGLE_CALENDAR_CREDENTIALS) + and os.access(GOOGLE_CALENDAR_CREDENTIALS, os.R_OK) + ): + problems.append("GOOGLE_CALENDAR_CREDENTIALS is not a readable file") if problems: raise RuntimeError("Strict runtime configuration failed: " + "; ".join(problems)) diff --git a/server/tests/test_calendar_adapter.py b/server/tests/test_calendar_adapter.py index abe14cc..224f8b6 100644 --- a/server/tests/test_calendar_adapter.py +++ b/server/tests/test_calendar_adapter.py @@ -14,12 +14,59 @@ @pytest.fixture(autouse=True) def calendar_config(isolated_db, monkeypatch): monkeypatch.setattr(calendar_adapter, "GOOGLE_CALENDAR_COMMAND", '["hermes-google-api"]') + monkeypatch.setattr(calendar_adapter, "GOOGLE_CALENDAR_CREDENTIALS", "") monkeypatch.setattr(calendar_adapter, "GOOGLE_CALENDAR_HOME_ID", "home-calendar@example.com") monkeypatch.setattr(calendar_adapter, "GOOGLE_CALENDAR_HOME_NAME", "Home") isolated_db.run("UPDATE profiles SET timezone='Europe/Warsaw'") calendar_adapter._cache.clear() + calendar_adapter._credentials_cache = None yield calendar_adapter._cache.clear() + calendar_adapter._credentials_cache = None + + +class FakeGoogleRequest: + def __init__(self, result=None): + self.result = result + + def execute(self): + return self.result + + +class FakeGoogleEvents: + def __init__(self): + self.calls = [] + + def list(self, **kwargs): + self.calls.append(("list", kwargs)) + return FakeGoogleRequest({"items": [{ + "id": "event-1", + "summary": "Dentysta", + "start": {"dateTime": "2026-07-13T09:00:00+02:00"}, + "end": {"dateTime": "2026-07-13T10:00:00+02:00"}, + "status": "confirmed", + "htmlLink": "https://calendar.google.com/event?eid=event-1", + }]}) + + def insert(self, **kwargs): + self.calls.append(("insert", kwargs)) + return FakeGoogleRequest({"id": "event-created", "summary": "Dentysta"}) + + def patch(self, **kwargs): + self.calls.append(("patch", kwargs)) + return FakeGoogleRequest({"id": kwargs["eventId"], "summary": "Dentysta 2"}) + + def delete(self, **kwargs): + self.calls.append(("delete", kwargs)) + return FakeGoogleRequest(None) + + +class FakeGoogleService: + def __init__(self): + self.resource = FakeGoogleEvents() + + def events(self): + return self.resource def dante_app(monkeypatch) -> FastAPI: @@ -62,6 +109,126 @@ async def test_safe_profile_status_contains_no_credentials(isolated_db, monkeypa assert "credential" not in serialized +def test_native_google_transport_supports_full_calendar_contract(monkeypatch): + from googleapiclient import discovery + + service = FakeGoogleService() + monkeypatch.setattr(calendar_adapter, "GOOGLE_CALENDAR_COMMAND", "") + monkeypatch.setattr(calendar_adapter, "GOOGLE_CALENDAR_CREDENTIALS", "/run/token.json") + monkeypatch.setattr(calendar_adapter, "_credentials", lambda: object()) + monkeypatch.setattr(discovery, "build", lambda *_args, **_kwargs: service) + + listed = calendar_adapter._execute_google([ + "calendar", "list", + "--start", "2026-07-13T00:00:00+02:00", + "--end", "2026-07-14T00:00:00+02:00", + "--max", "50", + "--calendar", "attacker-controlled", + ]) + created = calendar_adapter._execute_google([ + "calendar", "create", + "--summary", "Dentysta", + "--start", "2026-07-13T09:00:00+02:00", + "--end", "2026-07-13T10:00:00+02:00", + "--calendar", "attacker-controlled", + ]) + updated = calendar_adapter._execute_google([ + "calendar", "update", "event-1", + "--summary", "Dentysta 2", + "--start", "2026-07-13T10:00:00+02:00", + "--end", "2026-07-13T11:00:00+02:00", + "--location", "Gabinet", + "--description", "Kontrola", + "--calendar", "attacker-controlled", + ]) + deleted = calendar_adapter._execute_google([ + "calendar", "delete", "event-1", + "--calendar", "attacker-controlled", + ]) + + assert listed[0]["id"] == "event-1" + assert created == { + "status": "created", "id": "event-created", "summary": "Dentysta", "htmlLink": "" + } + assert updated["status"] == "updated" + assert deleted == {"status": "deleted", "eventId": "event-1"} + assert [name for name, _kwargs in service.resource.calls] == [ + "list", "insert", "patch", "delete" + ] + assert all( + kwargs["calendarId"] == "home-calendar@example.com" + for _name, kwargs in service.resource.calls + ) + patch_body = service.resource.calls[2][1]["body"] + assert patch_body == { + "summary": "Dentysta 2", + "start": {"dateTime": "2026-07-13T10:00:00+02:00"}, + "end": {"dateTime": "2026-07-13T11:00:00+02:00"}, + "location": "Gabinet", + "description": "Kontrola", + } + + +@pytest.mark.asyncio +async def test_native_google_refresh_failure_requires_reauth_without_disclosure(monkeypatch): + from google.auth.exceptions import RefreshError + + monkeypatch.setattr(calendar_adapter, "GOOGLE_CALENDAR_COMMAND", "") + monkeypatch.setattr(calendar_adapter, "GOOGLE_CALENDAR_CREDENTIALS", "/run/token.json") + + def fail(_args): + raise RefreshError("invalid_grant client_secret=never-disclose") + + monkeypatch.setattr(calendar_adapter, "_execute_google", fail) + with pytest.raises(calendar_adapter.CalendarReauthRequired) as error: + await calendar_adapter._execute(["calendar", "list"]) + + assert "never-disclose" not in str(error.value) + + +@pytest.mark.asyncio +async def test_native_credentials_take_precedence_over_legacy_command(monkeypatch): + monkeypatch.setattr(calendar_adapter, "GOOGLE_CALENDAR_COMMAND", '["legacy-client"]') + monkeypatch.setattr(calendar_adapter, "GOOGLE_CALENDAR_CREDENTIALS", "/run/token.json") + monkeypatch.setattr(calendar_adapter, "_execute_google", lambda args: {"args": args}) + + async def subprocess_must_not_run(*_args, **_kwargs): + raise AssertionError("legacy command must not run when native credentials are configured") + + monkeypatch.setattr(asyncio, "create_subprocess_exec", subprocess_must_not_run) + result = await calendar_adapter._execute(["calendar", "list"]) + + assert result == {"args": ["calendar", "list"]} + + +def test_native_credentials_reload_when_reauthorized_token_changes(tmp_path, monkeypatch): + from google.oauth2.credentials import Credentials + + token = tmp_path / "google-token.json" + token.write_text("{}", encoding="utf-8") + loaded = [] + + class ValidCredentials: + valid = True + refresh_token = "refresh" + + def load(path): + loaded.append(path) + return ValidCredentials() + + monkeypatch.setattr(calendar_adapter, "GOOGLE_CALENDAR_CREDENTIALS", str(token)) + monkeypatch.setattr(Credentials, "from_authorized_user_file", load) + calendar_adapter._credentials_cache = None + + first = calendar_adapter._credentials() + assert calendar_adapter._credentials() is first + token.write_text('{"reauthorized":true}', encoding="utf-8") + replacement = calendar_adapter._credentials() + + assert replacement is not first + assert loaded == [str(token), str(token)] + + @pytest.mark.asyncio async def test_list_uses_only_configured_home_calendar_and_normalizes_timezone( isolated_db, monkeypatch @@ -316,6 +483,35 @@ async def subprocess(*args, **kwargs): assert state["error"] == "invalid_grant" +@pytest.mark.asyncio +@pytest.mark.parametrize( + "provider_error", + [ + "REFRESH_FAILED", + "NOT_AUTHENTICATED", + "Token is invalid. Re-run setup.", + "HttpError 403: Insufficient Permission", + ], +) +async def test_current_hermes_auth_errors_require_reauthorization( + isolated_db, monkeypatch, provider_error +): + class FakeProcess: + returncode = 1 + + async def communicate(self): + return b"", provider_error.encode() + + async def subprocess(*args, **kwargs): + return FakeProcess() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", subprocess) + with pytest.raises(calendar_adapter.CalendarReauthRequired): + await calendar_adapter.list_events("wojtek", use_cache=False) + + assert calendar_adapter.status("wojtek")["status"] == "reauth_required" + + @pytest.mark.asyncio async def test_missing_configuration_is_disabled_without_subprocess(isolated_db, monkeypatch): monkeypatch.setattr(calendar_adapter, "GOOGLE_CALENDAR_COMMAND", "") diff --git a/server/tests/test_config.py b/server/tests/test_config.py index a5df552..c0b8564 100644 --- a/server/tests/test_config.py +++ b/server/tests/test_config.py @@ -80,6 +80,33 @@ def test_strict_runtime_config_accepts_independent_high_entropy_values(monkeypat config.validate_runtime_config() +def test_strict_runtime_config_validates_google_calendar_pairing(tmp_path, monkeypatch): + monkeypatch.setattr(config, "DANTE_STRICT_CONFIG", True) + monkeypatch.setattr(config, "DANTE_SECRET", "dante-4bd0e519d17f4a95b171daed1a63a2f5") + monkeypatch.setattr(config, "NOTIFY_TOKEN", "notify-c08a6b655ed947e1a692581a0f382b95") + monkeypatch.setattr(config, "DEVICE_AUTH_MODE", "required") + monkeypatch.setattr(config, "GOOGLE_CALENDAR_COMMAND", "") + monkeypatch.setattr(config, "GOOGLE_CALENDAR_CREDENTIALS", "/missing/token.json") + monkeypatch.setattr(config, "GOOGLE_CALENDAR_HOME_ID", "") + + with pytest.raises(RuntimeError, match="GOOGLE_CALENDAR_HOME_ID"): + config.validate_runtime_config() + + monkeypatch.setattr(config, "GOOGLE_CALENDAR_HOME_ID", "home@example.com") + with pytest.raises(RuntimeError, match="not a readable file"): + config.validate_runtime_config() + + token = tmp_path / "google-token.json" + token.write_text("{}", encoding="utf-8") + monkeypatch.setattr(config, "GOOGLE_CALENDAR_CREDENTIALS", str(token)) + config.validate_runtime_config() + + monkeypatch.setattr(config, "GOOGLE_CALENDAR_CREDENTIALS", "") + monkeypatch.setattr(config, "GOOGLE_CALENDAR_COMMAND", "") + with pytest.raises(RuntimeError, match="requires a Google Calendar transport"): + config.validate_runtime_config() + + def test_runtime_config_validation_is_opt_in_for_development(monkeypatch): monkeypatch.setattr(config, "DANTE_STRICT_CONFIG", False) monkeypatch.setattr(config, "DANTE_SECRET", "")