PRICE-01 round 2: the token on no page, fixed-tariff note, token length, F3 words, test gaps
checks / unit (pull_request) Successful in 39m0s
infra-pin / fresh (pull_request) Successful in 2s

F1: every password option is shown as ******** (set) or empty in /config/model
(options, running, ui) and in a stale refusal; the classic page draws it as a
password field; a save or validate that sends ******** or nothing keeps the
stored value. F2: a static_profile beside price_service is said once at start
(notification and log), not refused. F3 (owner, 2026-10-14): the no-price row,
Home line and notification only while maintenance_enabled is on. F4: tests for
the reviewer's R01 and R10. F5: price_service_token must be 16+ characters.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-14 18:56:51 +02:00
co-authored by Claude
parent 4eb88f7fda
commit 0801776645
12 changed files with 767 additions and 17 deletions
+26 -8
View File
@@ -48,12 +48,13 @@ from datetime import datetime, timezone
from aiohttp import web
from . import messages, selfconfig, uipage, uitext
from . import messages, price, selfconfig, uipage, uitext
from .haentities import EntityCache
from .homeview import region_of
from .validate import config_notes, setup_checks
from .config_page import CONFIG_PAGE
from .configui import changed_keys, field_model, problems_by_field
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
problems_by_field, secret_keys)
from .uimodel import DeriveError, options_to_ui
_LOG = logging.getLogger("goodwe.web")
@@ -209,6 +210,11 @@ def build_app(controller, selfcfg=None) -> web.Application:
raise web.HTTPBadRequest(text="expected an options object")
return body, options, None
def _secrets(info):
# PRICE-01 F1: every `password` option of the schema, and the price
# token by name in case a schema is missing.
return secret_keys((info or {}).get("schema"), price.SECRET_OPTIONS)
async def config_model(request):
"""Everything the page needs to draw itself, from ONE Supervisor call.
@@ -227,13 +233,14 @@ def build_app(controller, selfcfg=None) -> web.Application:
_LOG.error("cannot read the add-on's own options: %s", err)
return web.json_response({"error": str(err)}, status=502)
options = info.get("options") or {}
secrets = _secrets(info)
lang = str(request.query.get("lang", "en") or "en")[:8]
model = field_model(info.get("schema") or [],
info.get("translations") or {}, lang)
model.update({
"options": options,
"options": hide_secrets(options, secrets),
"fingerprint": hand_out(options),
"running": controller.o,
"running": hide_secrets(controller.o, secrets),
"running_differs": changed_keys(controller.o, options),
"lang": lang,
# UX-13 P1: the levies preset and its year, for the P2 Settings
@@ -242,7 +249,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
# UX-13 P2: the Installer view draws from these two.
"layout": uipage.layout(info.get("schema") or [],
info.get("translations") or {}, lang),
"ui": options_to_ui(options),
"ui": hide_secrets(options_to_ui(options), secrets),
# UX-13 P4: the setup wizard, and why it opens by itself.
"setup": uipage.setup_spec(),
"setup_needed": uipage.setup_needed(
@@ -281,6 +288,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
model, options = None, _body.get("options")
if not isinstance(options, dict):
raise web.HTTPBadRequest(text="expected an options object")
try:
info_now = await selfcfg.info()
options = keep_secrets(options, info_now.get("options"),
_secrets(info_now))
except Exception: # noqa: BLE001 - a check never fails for this
pass
notes = []
problems, unread = await selfconfig.problems_and_ranges(
options, controller.hass, notes=notes)
@@ -339,9 +352,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
stored_now = None
if isinstance(posted, dict) and isinstance(posted.get("model"), dict):
secrets = frozenset(price.SECRET_OPTIONS)
if isinstance(posted, dict):
try:
stored_now = (await selfcfg.info()).get("options") or {}
info_now = await selfcfg.info()
stored_now = info_now.get("options") or {}
secrets = _secrets(info_now)
except selfconfig.SupervisorUnavailable as err:
_LOG.error("saving options failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
@@ -357,6 +373,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
"unanchored": [], "messages": messages_,
"steps": uipage.step_problems(messages_, model)},
status=422)
# PRICE-01 F1: a secret the page did not change is the stored one.
options = keep_secrets(options, stored_now, secrets)
expect = str(body.get("fingerprint") or "")
try:
result = await selfconfig.save(selfcfg, options, expect,
@@ -374,7 +392,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
"moved": refused.moved,
"changed_fields": (uipage.fields_of(moved, model)
if model is not None else []),
"stored": stored,
"stored": hide_secrets(stored, secrets),
# The page needs the CURRENT token to be able to save
# again after the operator has read the difference. That
# is not a force door: it takes a second, deliberate press