From 86fece4f40950cd131f44f5a66f8ce0b4409fa99 Mon Sep 17 00:00:00 2001 From: QY-25123 Date: Mon, 17 Aug 2026 16:28:09 -0700 Subject: [PATCH] feat(dash): change the strictness mode from the dashboard (closes request for in-dashboard mode control) Adds GET/POST /api/mode so light/balanced/strict/paranoid can be changed without a terminal. Both routes through a new shared doberman.policy.drift.apply_mode_change - the exact gate doberman mode/doberman setup already used (extracted from cli/main.py's _apply_mode_change so the two callers can never drift out of sync): raising strictness stays frictionless, lowering it requires the same possession factor (2FA if enrolled, else the Doberman password) and is recorded in the same append-only policy-change ledger. Exactly like /api/resolve, the dash server never verifies the code itself - it only carries it through to the existing gate. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 7 ++ docs/SETUP.md | 10 ++ src/doberman/cli/main.py | 38 ++---- src/doberman/dash/app.py | 203 +++++++++++++++++++++++++++++++ src/doberman/policy/drift.py | 42 +++++++ tests/unit/test_dash_mode.py | 228 +++++++++++++++++++++++++++++++++++ 6 files changed, 498 insertions(+), 30 deletions(-) create mode 100644 tests/unit/test_dash_mode.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d15398..5e80b274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,13 @@ Shipped history for Doberman. Planned work lives on the [roadmap](README.md#road "no decisions yet" empty-state (it stayed visible even with rows); the wrapper is removed so the empty-state hides correctly again, and a test now guards the sibling structure the reveal needs. The README demo gif is refreshed to the new dark brand look. +- **New:** the dashboard can now change the strictness mode itself (`GET`/`POST /api/mode`, + a `change` control next to the mode badge) instead of requiring the terminal. It routes + through the exact same gate as `doberman mode`/`doberman setup` — a new shared + `doberman.policy.drift.apply_mode_change` — so raising strictness stays frictionless and + lowering it is denied without the same possession factor (2FA if enrolled, else the Doberman + password), recorded in the same append-only ledger. The dash server never verifies the code + itself, mirroring `/api/resolve`'s existing discipline. - **MCP tool-schema pinning** (#246): every proxied `tools/list` now records a keyed-HMAC trust-on-first-use pin for each tool's name, description, and input schema. A later mismatch raises live calls to AUTH in Light/Balanced or BLOCK in Strict/Paranoid until a human runs diff --git a/docs/SETUP.md b/docs/SETUP.md index b6477765..dc7a56d4 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -409,6 +409,16 @@ header bar showing the current mode + effective enforcement at a glance, and a d state before any decisions arrive - no build step, no external assets, works fully offline like the rest of the shell. +**Changing the mode from the dashboard.** The `change` button next to the mode badge opens a +small form (`GET`/`POST /api/mode`) to switch Light/Balanced/Strict/Paranoid without touching a +terminal. This goes through the exact same gate as `doberman mode`: raising strictness applies +immediately, and lowering it prompts for the same possession factor (a 2FA code if enrolled, +otherwise the Doberman password set via `doberman password set`) - with neither enrolled, a +lowering fails closed. Every attempt, approved or denied, is written to the same append-only +policy-change ledger (`doberman policy-history`). Exactly like `/api/resolve`, the dash server +never verifies the code itself - it only carries it through to the existing gate in +`doberman.policy.drift`. + #### Try the demo Want to see real verdicts light up the dashboard without wiring up an agent? `doberman demo` diff --git a/src/doberman/cli/main.py b/src/doberman/cli/main.py index 782703a4..aaffb87b 100644 --- a/src/doberman/cli/main.py +++ b/src/doberman/cli/main.py @@ -34,7 +34,6 @@ load_preferences, save_default_role_enabled, save_message_tone, - save_mode, save_policy, save_preferences, ) @@ -46,9 +45,9 @@ _verify_possession_factor, apply_change, apply_enforcement_change, + apply_mode_change, apply_preferences_change, apply_standing_elevation, - log_change, read_policy_changes, ) from doberman.policy.friction import build_friction_report, generate_proposals @@ -359,35 +358,14 @@ def review( def _apply_mode_change( name: str, path: str, reason: str, *, establish_ok: bool = False ) -> str | None: - """Resolve ``name``, gate any weakening behind a possession factor, then persist it. - - The mode dial is now gated at parity with the enforcement dial: - lowering strictness (a downgrade on the paranoid>strict>balanced>light - scale) is a ``weaken`` and must clear a possession factor — a 2FA code if - enrolled, otherwise the Doberman password set via ``doberman password set`` — - before it is persisted. Raising stays frictionless: ``apply_change`` auto-approves - a strengthen with no prompt. A no-op (unchanged mode) skips the gate/ledger entirely. - Every attempt (incl. denials) is recorded to the append-only ledger. Returns - ``None`` when the gate denies the change — fail closed, nothing persisted. - - ``establish_ok`` (first-run onboarding via ``doberman setup``) writes the - INITIAL posture freely when no policy is persisted yet — choosing a starting - mode is not weakening an existing one, and 2FA is enrolled later in the - wizard. The change is still recorded to the ledger. Once a policy exists, - even a setup re-run falls through to the gate, so neither ``setup`` nor - ``mode`` can bypass the possession factor on a lowering. + """Sync CLI wrapper around :func:`doberman.policy.drift.apply_mode_change`. + + The gate itself (possession-factor requirement on a lowering, frictionless + raising, ``establish_ok`` first-run bypass) lives there so the CLI and the + dashboard's ``/api/mode`` share one implementation instead of two that could + drift apart. """ - old = load_mode(path) - new = resolve_mode(name).value # raises ValueError for an unknown mode - if old == new: - return save_mode(name, path) - if establish_ok and load_policy(path) is None: - asyncio.run(log_change({"mode": old}, {"mode": new}, reason, repo_root=path)) - return save_mode(name, path) - outcome = asyncio.run(apply_change({"mode": old}, {"mode": new}, reason, repo_root=path)) - if not outcome.approved: - return None - return save_mode(name, path) + return asyncio.run(apply_mode_change(name, path, reason, establish_ok=establish_ok)) @app.command(rich_help_panel="Policy") diff --git a/src/doberman/dash/app.py b/src/doberman/dash/app.py index 5bff27f7..f39e654b 100644 --- a/src/doberman/dash/app.py +++ b/src/doberman/dash/app.py @@ -48,6 +48,17 @@ and CSS-only empty states onto this same inline shell - the dark-by-default palette is formalized as CSS custom properties. Still no build toolchain, no new endpoints, no change to auth/redaction/decision-path behavior. + +D6 lets the strictness mode itself be changed from the dashboard: ``GET +/api/mode`` reports the current mode + the four valid names; ``POST +/api/mode`` (body ``{"mode": , "code"?: }``) sets it. This goes +through the SAME chokepoint as ``doberman mode``/``doberman setup`` - +:func:`doberman.policy.drift.apply_mode_change` - so raising strictness stays +frictionless and lowering it is gated behind the same possession factor (TOTP +if enrolled, else the Doberman password), recorded in the same append-only +ledger. Exactly like ``/api/resolve``, the dash server NEVER verifies the code +itself - ``code`` rides through opaquely to the existing gate, which performs +the real verification. """ from __future__ import annotations @@ -62,7 +73,10 @@ from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse from starlette.routing import Route +from doberman.config import load_mode from doberman.dash.stats import build_stats, reason_codes +from doberman.policy.drift import apply_mode_change +from doberman.policy.modes import SecurityMode from doberman.storage import approvals from doberman.storage.log import read_decisions, read_decisions_since @@ -248,6 +262,31 @@ #feed li:last-child { border-bottom: none; } #feed li:hover { background: var(--ink-2); } #feed li .detail { color: var(--fg-3); overflow-wrap: anywhere; } + #mode-edit-btn { + font: inherit; font-size: .7rem; font-weight: 600; padding: .2rem .5rem; + border: 1px solid var(--rule); border-radius: 4px; background: transparent; + color: var(--fg-3); cursor: pointer; + } + #mode-edit-btn:hover { background: var(--neutral-bg); color: var(--fg); } + #mode-form { + display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; + margin: -.2rem 0 1.2rem; font-size: .82rem; + } + #mode-form select, #mode-form input { + font: inherit; font-size: .82rem; padding: .35rem .55rem; + background: var(--ink-2); color: var(--fg); border: 1px solid var(--rule); border-radius: 4px; + } + #mode-form input { width: 16rem; letter-spacing: .04em; } + #mode-form button { + font: inherit; font-size: .8rem; font-weight: 600; padding: .35rem .85rem; + border: 1px solid var(--rule); border-radius: 4px; background: transparent; + color: inherit; cursor: pointer; + } + #mode-save-btn { border-color: var(--pass); color: var(--pass); } + #mode-save-btn:hover { background: var(--pass-bg); } + #mode-save-btn:disabled { opacity: .55; cursor: default; } + #mode-cancel-btn:hover { background: var(--neutral-bg); } + #mode-error { color: var(--block); font-family: var(--mono); font-size: .78rem; } @@ -263,10 +302,19 @@
connecting... mode: - + enforcement: - ON GUARD
+
stats loading...

Pending approvals

    @@ -325,6 +373,14 @@ var statsEl = document.getElementById("stats"); var modeBadge = document.getElementById("mode-badge"); var enforcementBadge = document.getElementById("enforcement-badge"); + var modeEditBtn = document.getElementById("mode-edit-btn"); + var modeForm = document.getElementById("mode-form"); + var modeSelect = document.getElementById("mode-select"); + var modeCodeInput = document.getElementById("mode-code"); + var modeSaveBtn = document.getElementById("mode-save-btn"); + var modeCancelBtn = document.getElementById("mode-cancel-btn"); + var modeErrorEl = document.getElementById("mode-error"); + var modeEditing = false; var feedEl = document.getElementById("feed"); var MAX_FEED_ROWS = 200; @@ -400,6 +456,12 @@ modeBadge.textContent = "mode: " + s.mode; enforcementBadge.textContent = "enforcement: " + s.enforcement; enforcementBadge.className = ENFORCEMENT_BADGE_CLASS[s.enforcement] || "badge badge-neutral"; + // Keep the (closed) mode selector's value in sync with reality - but + // never while the user has the form open with an in-progress choice, + // or a poll landing mid-edit would silently discard what they picked. + if (!modeEditing && modeSelect.options.length) { + modeSelect.value = s.mode; + } } // Stats refresh on an interval, not just at page load - otherwise the @@ -419,6 +481,87 @@ refreshStats(); setInterval(refreshStats, STATS_REFRESH_MS); + // Mode control: fetch the valid mode names once to populate the + // selector, then let the user pick a new one. Raising strictness is + // frictionless server-side; lowering it needs a possession-factor code + // (2FA or password) in the same request - the server decides which is + // required and verifies it, this page just forwards whatever the user + // typed and shows the resulting error if any. + fetch("/api/mode", { headers: { "Authorization": "Bearer " + token } }) + .then(function (res) { + if (!res.ok) { throw new Error("status " + res.status); } + return res.json(); + }) + .then(function (m) { + modeSelect.textContent = ""; + (m.modes || []).forEach(function (name) { + var opt = document.createElement("option"); + opt.value = name; + opt.textContent = name; + modeSelect.appendChild(opt); + }); + modeSelect.value = m.mode; + }) + .catch(function () { + // No modes loaded -> leave the selector empty and the edit button + // inert rather than let the user submit a change we can't populate. + modeEditBtn.disabled = true; + }); + + function openModeForm() { + modeEditing = true; + modeErrorEl.textContent = ""; + modeCodeInput.value = ""; + modeForm.hidden = false; + } + + function closeModeForm() { + modeEditing = false; + modeForm.hidden = true; + modeCodeInput.value = ""; + modeErrorEl.textContent = ""; + } + + modeEditBtn.addEventListener("click", function () { + if (modeForm.hidden) { openModeForm(); } else { closeModeForm(); } + }); + modeCancelBtn.addEventListener("click", closeModeForm); + + modeSaveBtn.addEventListener("click", function () { + var chosen = modeSelect.value; + if (!chosen) { return; } + var body = { mode: chosen }; + if (modeCodeInput.value) { body.code = modeCodeInput.value; } + modeErrorEl.textContent = ""; + modeSaveBtn.disabled = true; + fetch("/api/mode", { + method: "POST", + headers: { + "Authorization": "Bearer " + token, + "Content-Type": "application/json" + }, + body: JSON.stringify(body) + }).then(function (res) { + return res.json().then(function (data) { + return { ok: res.ok, data: data }; + }); + }).then(function (result) { + modeSaveBtn.disabled = false; + if (result.ok) { + modeBadge.textContent = "mode: " + result.data.mode; + closeModeForm(); + refreshStats(); + } else { + // textContent only - never render a server error string as markup. + modeErrorEl.textContent = (result.data && result.data.error) || "mode change failed"; + modeCodeInput.value = ""; + } + }).catch(function () { + modeSaveBtn.disabled = false; + modeErrorEl.textContent = "network error - try again"; + }); + }); + var pendingList = document.getElementById("pending-list"); var PENDING_POLL_MS = 2000; @@ -811,6 +954,65 @@ async def resolve(request: Request) -> Response: return Route("/api/resolve/{approval_id}", resolve, methods=["POST"]) +class _ModeChangePrompter: + """Non-interactive :class:`~doberman.auth.challenge.Prompter` for ``POST /api/mode``. + + The POST request itself is the human's confirmation (they explicitly chose + a new mode in the UI), so ``confirm`` always succeeds; ``read_code`` returns + the possession-factor code the request body carried, or raises if none was + supplied - the ``Prompter`` protocol requires a raise on no-input so the gate + treats a missing code as a denial (fail closed), never as an empty-but-valid + answer. Mirrors ``/api/resolve``: this module never verifies the code, only + carries it opaquely to :func:`doberman.policy.drift.apply_mode_change`. + """ + + def __init__(self, code: str | None) -> None: + self._code = code + + def confirm(self, message: str) -> bool: + return True + + def read_code(self, message: str) -> str: + if not self._code: + raise ValueError("no possession-factor code supplied") + return self._code + + +def _make_mode_route(token: str, repo_root: str) -> Route: + async def mode_route(request: Request) -> Response: + if not _token_matches(request, token): + return _unauthorized() + + if request.method == "GET": + return JSONResponse( + {"mode": load_mode(repo_root), "modes": [m.value for m in SecurityMode]} + ) + + try: + body = await request.json() + except (json.JSONDecodeError, ValueError): + body = {} + name = body.get("mode") + if not isinstance(name, str) or not name: + return JSONResponse({"error": "mode is required"}, status_code=400) + code = body.get("code") + + try: + saved = await apply_mode_change( + name, + repo_root, + "doberman dashboard", + prompter=_ModeChangePrompter(code if isinstance(code, str) else None), + ) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + if saved is None: + return JSONResponse({"error": "mode change denied"}, status_code=403) + return JSONResponse({"mode": saved}) + + return Route("/api/mode", mode_route, methods=["GET", "POST"]) + + def create_app( token: str, repo_root: str = ".", @@ -837,5 +1039,6 @@ def create_app( ), _make_pending_route(token, repo_root), _make_resolve_route(token, repo_root), + _make_mode_route(token, repo_root), ] return Starlette(routes=routes) diff --git a/src/doberman/policy/drift.py b/src/doberman/policy/drift.py index cc0e971f..9d61c932 100644 --- a/src/doberman/policy/drift.py +++ b/src/doberman/policy/drift.py @@ -53,7 +53,9 @@ from doberman.auth import password, totp from doberman.auth.challenge import Prompter +from doberman.config import load_mode, load_policy, save_mode from doberman.models import Decision, ReasonCode, Verdict +from doberman.policy.modes import resolve_mode from doberman.storage.db import db_path, open_db logger = logging.getLogger("doberman.policy.drift") @@ -387,6 +389,46 @@ async def log_change( return ChangeOutcome(classification=classification, approved=True, method="logged") +async def apply_mode_change( + name: str, + repo_root: str, + reason: str, + *, + prompter: Prompter | None = None, + establish_ok: bool = False, +) -> str | None: + """Resolve ``name``, gate any weakening behind :func:`apply_change`, then persist it. + + The one entry point for changing the strictness-mode dial, shared by every + caller (CLI ``doberman mode``/``doberman setup``, the dashboard's ``/api/mode``) + so the gate can never drift out of sync between them. Lowering strictness (a + downgrade on the paranoid>strict>balanced>light scale) is a ``weaken`` and must + clear a possession factor via ``prompter`` (a 2FA code if enrolled, otherwise + the Doberman password) before it is persisted; raising stays frictionless. A + no-op (unchanged mode) skips the gate/ledger entirely. Returns ``None`` when + the gate denies the change — fail closed, nothing persisted. + + ``establish_ok`` (first-run onboarding) writes the INITIAL posture freely when + no policy is persisted yet — choosing a starting mode is not weakening an + existing one. Once a policy exists, even an ``establish_ok`` caller falls + through to the gate, so first-run onboarding can never be replayed to bypass + the possession factor on a later lowering. + """ + old = load_mode(repo_root) + new = resolve_mode(name).value # raises ValueError for an unknown mode + if old == new: + return save_mode(name, repo_root) + if establish_ok and load_policy(repo_root) is None: + await log_change({"mode": old}, {"mode": new}, reason, repo_root=repo_root) + return save_mode(name, repo_root) + outcome = await apply_change( + {"mode": old}, {"mode": new}, reason, repo_root=repo_root, prompter=prompter + ) + if not outcome.approved: + return None + return save_mode(name, repo_root) + + def _run_enforcement_gate( before: dict, after: dict, reason: str, prompter: Prompter ) -> tuple[bool, str]: diff --git a/tests/unit/test_dash_mode.py b/tests/unit/test_dash_mode.py new file mode 100644 index 00000000..23b58a32 --- /dev/null +++ b/tests/unit/test_dash_mode.py @@ -0,0 +1,228 @@ +"""Unit tests for D6 — changing the strictness mode from the dashboard. + +``GET /api/mode`` / ``POST /api/mode`` route through the SAME chokepoint as +``doberman mode``/``doberman setup`` (:func:`doberman.policy.drift. +apply_mode_change`), so these tests focus on the HTTP-layer contract (auth, +field validation, status codes) and on the two invariants that must hold no +matter which caller reaches the gate: + +* raising strictness is always frictionless (no code required, never denied); +* lowering strictness is denied without a valid possession factor, and the + dash server never verifies that factor itself (mirrors ``/api/resolve`` - + see ``test_dash_app_never_imports_totp`` in ``test_dash_approve_deny.py``). +""" + +import asyncio + +import pyotp +from starlette.testclient import TestClient + +from doberman.auth import password, totp +from doberman.config import load_mode +from doberman.dash.app import create_app +from doberman.policy.drift import read_policy_changes + +_TOKEN = "test-dash-token-0123456789" # noqa: S105 - fixture value, not a real secret +_PASSWORD = "correct horse battery staple" # noqa: S105 - synthetic test credential + + +def _client(root: str) -> TestClient: + return TestClient(create_app(_TOKEN, root)) + + +def _enrolled_totp_code() -> str: + totp.enroll() + secret = totp._read_secret() + assert secret is not None + return pyotp.TOTP(secret).now() + + +def _headers() -> dict: + return {"Authorization": f"Bearer {_TOKEN}"} + + +def _post_mode(client: TestClient, mode: str, code: str | None = None): + body = {"mode": mode} + if code is not None: + body["code"] = code + return client.post("/api/mode", headers=_headers(), json=body) + + +# --- auth matrix ------------------------------------------------------------- + + +def test_get_mode_without_token_is_401(tmp_path): + resp = _client(str(tmp_path)).get("/api/mode") + assert resp.status_code == 401 + + +def test_post_mode_without_token_is_401(tmp_path): + resp = _client(str(tmp_path)).post("/api/mode", json={"mode": "strict"}) + assert resp.status_code == 401 + + +def test_post_mode_with_wrong_token_is_401(tmp_path): + resp = _client(str(tmp_path)).post( + "/api/mode", + headers={"Authorization": "Bearer wrong"}, + json={"mode": "strict"}, + ) + assert resp.status_code == 401 + + +# --- GET: current mode + valid names ----------------------------------------- + + +def test_get_mode_reports_current_mode_and_valid_names(tmp_path): + root = str(tmp_path) + resp = _client(root).get("/api/mode", headers=_headers()) + + assert resp.status_code == 200 + body = resp.json() + assert body["mode"] == "balanced" # default, fresh repo + assert set(body["modes"]) == {"light", "balanced", "strict", "paranoid"} + + +# --- raising: always frictionless -------------------------------------------- + + +def test_raising_mode_applies_with_no_code_and_never_denied(tmp_path): + root = str(tmp_path) + client = _client(root) + + resp = _post_mode(client, "strict") + + assert resp.status_code == 200, resp.text + assert resp.json() == {"mode": "strict"} + assert load_mode(root) == "strict" + rows = asyncio.run(read_policy_changes(root)) + assert len(rows) == 1 + assert rows[0]["approval_method"] == "auto" + assert rows[0]["approved"] == 1 + + +def test_noop_mode_change_applies_with_no_gate_and_no_ledger_row(tmp_path): + root = str(tmp_path) + client = _client(root) + + resp = _post_mode(client, "balanced") # already the default + + assert resp.status_code == 200, resp.text + assert load_mode(root) == "balanced" + assert asyncio.run(read_policy_changes(root)) == [] + + +# --- lowering: gated behind a possession factor ------------------------------ + + +def test_lowering_with_no_factor_enrolled_is_denied_and_unchanged(tmp_path): + root = str(tmp_path) + client = _client(root) + + resp = _post_mode(client, "light") + + assert resp.status_code == 403 + assert resp.json() == {"error": "mode change denied"} + assert load_mode(root) == "balanced" # unchanged + rows = asyncio.run(read_policy_changes(root)) + assert len(rows) == 1 + assert rows[0]["approval_method"] == "no_factor_enrolled" + assert rows[0]["approved"] == 0 + + +def test_lowering_with_no_code_supplied_is_denied(tmp_path): + root = str(tmp_path) + password.enroll(_PASSWORD) + client = _client(root) + + resp = _post_mode(client, "light") # no "code" in the body at all + + assert resp.status_code == 403 + assert load_mode(root) == "balanced" + + +def test_lowering_with_valid_totp_code_applies_and_persists(tmp_path): + root = str(tmp_path) + code = _enrolled_totp_code() + client = _client(root) + + resp = _post_mode(client, "light", code=code) + + assert resp.status_code == 200, resp.text + assert resp.json() == {"mode": "light"} + assert load_mode(root) == "light" + rows = asyncio.run(read_policy_changes(root)) + assert len(rows) == 1 + assert rows[0]["approval_method"] == "two_factor" + assert rows[0]["approved"] == 1 + + +def test_lowering_with_wrong_totp_code_is_denied(tmp_path): + root = str(tmp_path) + _enrolled_totp_code() + client = _client(root) + + resp = _post_mode(client, "light", code="000000") + + assert resp.status_code == 403 + assert load_mode(root) == "balanced" + + +def test_lowering_with_valid_password_applies_when_totp_not_enrolled(tmp_path): + root = str(tmp_path) + password.enroll(_PASSWORD) + client = _client(root) + + resp = _post_mode(client, "light", code=_PASSWORD) + + assert resp.status_code == 200, resp.text + assert load_mode(root) == "light" + rows = asyncio.run(read_policy_changes(root)) + assert rows[0]["approval_method"] == "password" + + +def test_lowering_with_wrong_password_is_denied(tmp_path): + root = str(tmp_path) + password.enroll(_PASSWORD) + client = _client(root) + + resp = _post_mode(client, "light", code="not the password") + + assert resp.status_code == 403 + assert load_mode(root) == "balanced" + + +# --- validation ---------------------------------------------------------------- + + +def test_unknown_mode_name_is_400(tmp_path): + root = str(tmp_path) + resp = _post_mode(_client(root), "extreme") + + assert resp.status_code == 400 + assert "unknown security mode" in resp.json()["error"] + assert load_mode(root) == "balanced" + + +def test_missing_mode_field_is_400(tmp_path): + resp = _client(str(tmp_path)).post("/api/mode", headers=_headers(), json={}) + assert resp.status_code == 400 + + +# --- redaction / structural guarantees ----------------------------------------- + + +def test_dash_app_still_never_imports_totp(): + """The mode route must not weaken this existing D3 guarantee.""" + import doberman.dash.app as dash_app_module + + assert "totp" not in vars(dash_app_module) + + +def test_password_never_appears_in_the_response(tmp_path): + root = str(tmp_path) + password.enroll(_PASSWORD) + resp = _post_mode(_client(root), "light", code=_PASSWORD) + + assert resp.status_code == 200 + assert _PASSWORD not in resp.text