23bfc93f1c
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
440 lines
16 KiB
Python
440 lines
16 KiB
Python
"""Verify that inline <script> blocks in admin templates parse as valid JS.
|
|
|
|
We had a regression where an Italian translation containing an apostrophe
|
|
(e.g. "Errore nell'eliminazione") broke a single-quoted JS string literal,
|
|
killing every Alpine binding on the page (the "Nuova Stazione" button no
|
|
longer triggered openCreateModal).
|
|
|
|
Strategy: render each admin page through the Flask test client, extract every
|
|
inline <script> body and feed it to `node --check` for a syntax verdict. We
|
|
force the IT locale so the worst-case translations (which contain apostrophes)
|
|
are the ones evaluated.
|
|
|
|
The test is skipped if `node` is not on PATH; CI images that exclude Node
|
|
get a soft skip rather than a false failure.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from tests.conftest import api_get_router
|
|
|
|
|
|
# Match every inline <script>…</script> (i.e. without `src=`) so we don't try
|
|
# to syntax-check Alpine.js / Plotly bundles served from a CDN.
|
|
_INLINE_SCRIPT_RX = re.compile(
|
|
r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>",
|
|
re.DOTALL | re.IGNORECASE,
|
|
)
|
|
|
|
# Match every Alpine.js / event-binding attribute on a tag — the bug class we
|
|
# care about (apostrophe in IT translation closing a JS literal) hides equally
|
|
# well inside x-text="…" as inside <script>…</script>.
|
|
#
|
|
# Covers: x-data, x-init, x-text, x-html, x-show, x-if, x-for, x-bind:foo,
|
|
# x-on:foo, x-model, x-effect, x-transition:*, plus the @evt and :attr
|
|
# shortcuts.
|
|
_ALPINE_ATTR_RX = re.compile(
|
|
r"""(?P<name>(?:x-[a-zA-Z][a-zA-Z0-9:-]*|@[a-zA-Z][a-zA-Z0-9.:-]*|:[a-zA-Z][a-zA-Z0-9.:-]*))="(?P<value>[^"]*)\"""",
|
|
re.DOTALL,
|
|
)
|
|
|
|
# Skip these — they're plain identifiers / object shapes, not JS expressions
|
|
# that node --check should reject for things like a stray apostrophe.
|
|
_ALPINE_NON_EXPR_NAMES = {
|
|
# transition modifiers like x-transition:enter-start get bare class names
|
|
"x-transition:enter",
|
|
"x-transition:enter-start",
|
|
"x-transition:enter-end",
|
|
"x-transition:leave",
|
|
"x-transition:leave-start",
|
|
"x-transition:leave-end",
|
|
# x-cloak has no value, x-data is parsed below as an expression but Alpine
|
|
# also accepts an object literal.
|
|
}
|
|
|
|
|
|
def _alpine_attribute_expressions(html: str):
|
|
"""Yield (attr_name, value) tuples for every Alpine expression attribute."""
|
|
for m in _ALPINE_ATTR_RX.finditer(html):
|
|
name = m.group("name")
|
|
if name in _ALPINE_NON_EXPR_NAMES:
|
|
continue
|
|
value = m.group("value")
|
|
if not value.strip():
|
|
continue
|
|
# HTML entities used to embed quotes inside the attribute value
|
|
# (most common: " for ").
|
|
value = (
|
|
value.replace(""", '"')
|
|
.replace(""", '"')
|
|
.replace("'", "'")
|
|
.replace("&", "&")
|
|
)
|
|
yield name, value
|
|
|
|
|
|
def _node_syntax_error(source: str) -> str | None:
|
|
"""Return node's complaint about `source`, or None if it parses."""
|
|
node = shutil.which("node")
|
|
if node is None:
|
|
pytest.skip("node binary not found on PATH; cannot validate JS syntax")
|
|
|
|
fd, path = tempfile.mkstemp(suffix=".js")
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
f.write(source)
|
|
result = subprocess.run(
|
|
[node, "--check", path],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
return None if result.returncode == 0 else result.stderr.strip()
|
|
|
|
|
|
def _fail(label: str, source: str, stderr: str) -> None:
|
|
snippet = source.strip()
|
|
if len(snippet) > 600:
|
|
snippet = snippet[:600] + "\n…(truncated)…"
|
|
pytest.fail(
|
|
f"{label}: node rejected this code as invalid JS.\n"
|
|
f"--- node stderr ---\n{stderr}\n"
|
|
f"--- source (first 600 chars) ---\n{snippet}"
|
|
)
|
|
|
|
|
|
def _node_check(source: str, label: str) -> None:
|
|
"""Fail the test if `node --check` rejects `source`."""
|
|
if not source.strip():
|
|
return
|
|
stderr = _node_syntax_error(source)
|
|
if stderr is not None:
|
|
_fail(label, source, stderr)
|
|
|
|
|
|
def _check_alpine_attributes(html: str, page_label: str) -> None:
|
|
"""Validate every Alpine expression attribute on the page.
|
|
|
|
Alpine accepts an attribute either as a single expression or as a sequence of
|
|
statements, so the check accepts a value that parses as either and only fails
|
|
when both readings are rejected. Neither form alone is enough:
|
|
|
|
:class="{ 'dark': $store.theme.dark }" only as an expression
|
|
@click="showOverlay = false; confirmed = true" only as statements
|
|
|
|
An unterminated string literal - the bug this file exists for - is a syntax
|
|
error under both, so the guard is unchanged.
|
|
"""
|
|
for name, value in _alpine_attribute_expressions(html):
|
|
as_expression = f"void (\n{value}\n);\n"
|
|
as_statements = f"(() => {{\n{value}\n}});\n"
|
|
|
|
expression_error = _node_syntax_error(as_expression)
|
|
if expression_error is None:
|
|
continue
|
|
if _node_syntax_error(as_statements) is None:
|
|
continue
|
|
_fail(
|
|
f"{page_label} attribute {name}=\"…\" parsed neither as an expression "
|
|
"nor as statements",
|
|
as_expression,
|
|
expression_error,
|
|
)
|
|
|
|
|
|
def _force_italian(client) -> None:
|
|
"""Make the next request render with the IT locale (worst case for JS)."""
|
|
with client.session_transaction() as sess:
|
|
sess["language"] = "it"
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_admin_api():
|
|
"""Patch api_client used inside the admin blueprint."""
|
|
mock = MagicMock()
|
|
with patch("blueprints.admin.api_client", mock):
|
|
yield mock
|
|
|
|
|
|
def test_admin_stations_inline_js_is_valid(logged_in_client, mock_admin_api):
|
|
"""The /admin/stations page must emit syntactically valid inline JS in IT.
|
|
|
|
Regression guard for the apostrophe-in-single-quote bug.
|
|
"""
|
|
mock_admin_api.get.side_effect = [
|
|
# GET /api/stations
|
|
[{
|
|
"id": 1, "code": "ST-DEFAULT", "name": "Default Station",
|
|
"location": None, "notes": None, "active": True,
|
|
"created_by": 1, "created_at": "2026-04-25T10:00:00",
|
|
}],
|
|
# GET /api/recipes
|
|
[{"id": 1, "code": "DEMO-001", "name": "Demo Recipe", "active": True}],
|
|
]
|
|
|
|
_force_italian(logged_in_client)
|
|
resp = logged_in_client.get("/admin/stations")
|
|
assert resp.status_code == 200, resp.data[:300]
|
|
|
|
html = resp.data.decode("utf-8")
|
|
scripts = _INLINE_SCRIPT_RX.findall(html)
|
|
assert scripts, "expected at least one inline <script> on /admin/stations"
|
|
|
|
for i, body in enumerate(scripts):
|
|
_node_check(body, f"/admin/stations script[{i}]")
|
|
|
|
_check_alpine_attributes(html, "/admin/stations")
|
|
|
|
|
|
def test_admin_stations_inline_js_is_valid_in_english(
|
|
logged_in_client, mock_admin_api, flask_app,
|
|
):
|
|
"""gitea#37.5: the EN catalog is where this page's apostrophe actually is.
|
|
|
|
translations/en/LC_MESSAGES/messages.po: msgid "Azzera le ricette della
|
|
stazione" -> msgstr "Clear the station's recipes" (admin/stations.html:105).
|
|
The IT source string has no apostrophe, so test_admin_stations_inline_js_is_valid
|
|
(IT-only) would never have caught this - #37.5's own TEST note calls out
|
|
the EN render specifically.
|
|
|
|
The .mo catalogs are gitignored and compiled only at Docker build time
|
|
(compile_translations.py) - not part of this test run - so setting
|
|
session["language"] = "en" alone has no effect here: flask_babel falls
|
|
back to the (Italian) msgid with no compiled catalog to translate from.
|
|
The Jinja "_" global is patched directly instead, standing in for what a
|
|
compiled EN catalog would return for this one string; everything else
|
|
round-trips unchanged, same as gettext's own fallback.
|
|
"""
|
|
EN_TRANSLATIONS = {
|
|
"Azzera le ricette della stazione": "Clear the station's recipes",
|
|
}
|
|
|
|
def fake_gettext(msgid, **kwargs):
|
|
text = EN_TRANSLATIONS.get(msgid, msgid)
|
|
return text % kwargs if kwargs else text
|
|
|
|
flask_app.jinja_env.globals["_"] = fake_gettext
|
|
|
|
mock_admin_api.get.side_effect = [
|
|
[{
|
|
"id": 1, "code": "ST-DEFAULT", "name": "Default Station",
|
|
"location": None, "notes": None, "active": True,
|
|
"created_by": 1, "created_at": "2026-04-25T10:00:00",
|
|
}],
|
|
[{"id": 1, "code": "DEMO-001", "name": "Demo Recipe", "active": True}],
|
|
]
|
|
|
|
resp = logged_in_client.get("/admin/stations")
|
|
assert resp.status_code == 200, resp.data[:300]
|
|
|
|
html = resp.data.decode("utf-8")
|
|
# The fix (#37.5) is exactly why the apostrophe shows up escaped
|
|
# (tojson_attr) rather than literal - that is what keeps it from
|
|
# breaking the surrounding JS string.
|
|
assert "station" in html.lower(), "sanity check: the EN string did render"
|
|
assert "station\\u0027s recipes" in html or "station's recipes" in html, (
|
|
"expected the EN string's apostrophe to reach the page only as an "
|
|
"escaped token (tojson_attr), not literally"
|
|
)
|
|
|
|
scripts = _INLINE_SCRIPT_RX.findall(html)
|
|
for i, body in enumerate(scripts):
|
|
_node_check(body, f"/admin/stations (en) script[{i}]")
|
|
|
|
_check_alpine_attributes(html, "/admin/stations (en)")
|
|
|
|
|
|
def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
|
|
"""Same guard for the Users tab, now inside /admin/settings (#50)."""
|
|
mock_admin_api.get.return_value = [{
|
|
"id": 1, "username": "admin", "display_name": "Admin",
|
|
"email": None, "roles": ["Maker"], "is_admin": True,
|
|
"language_pref": "it", "theme_pref": "light", "active": True,
|
|
}]
|
|
|
|
_force_italian(logged_in_client)
|
|
resp = logged_in_client.get("/admin/settings")
|
|
assert resp.status_code == 200
|
|
|
|
html = resp.data.decode("utf-8")
|
|
scripts = _INLINE_SCRIPT_RX.findall(html)
|
|
assert scripts
|
|
|
|
for i, body in enumerate(scripts):
|
|
_node_check(body, f"/admin/settings script[{i}]")
|
|
|
|
_check_alpine_attributes(html, "/admin/settings")
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_measure_api():
|
|
"""Patch api_client used inside the measure blueprint."""
|
|
mock = MagicMock()
|
|
with patch("blueprints.measure.api_client", mock):
|
|
yield mock
|
|
|
|
|
|
def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api, monkeypatch):
|
|
"""The measurement screen carries the most inline JS in the app.
|
|
|
|
It drives the numpad, the caliper burst detection, the supervisor modal and -
|
|
since the production run moved server-side - the countdown. A broken literal
|
|
here kills every binding on the screen the operator actually works in.
|
|
"""
|
|
_force_italian(logged_in_client)
|
|
# STATION_CODE defaults to unset in the test env, so it has to be forced
|
|
# for task_execute()'s station-assignment check (#24).
|
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
|
task = {
|
|
"id": 11,
|
|
"recipe_id": 3,
|
|
"version_id": 5,
|
|
"title": "Quota d'ingresso",
|
|
"description": "Misura l'altezza",
|
|
"file_path": None,
|
|
"file_type": None,
|
|
"annotations_json": None,
|
|
"subtasks": [{
|
|
"id": 21, "marker_number": 1, "name": "Altezza", "order_index": 0,
|
|
"nominal": 10.0, "utl": 10.5, "uwl": 10.2, "lwl": 9.8, "ltl": 9.5,
|
|
"unit": "mm",
|
|
}],
|
|
}
|
|
# task_execute() only renders a recipe assigned to this station (#24).
|
|
mock_measure_api.get_station_recipes.return_value = [{"id": 3}]
|
|
mock_measure_api.get.side_effect = [
|
|
task, # /api/tasks/11
|
|
[{"id": 11, "order_index": 0}], # /api/recipes/3/tasks
|
|
{"id": 3, "measurement_interval_minutes": 30}, # /api/recipes/3
|
|
]
|
|
|
|
resp = logged_in_client.get("/measure/execute/11")
|
|
assert resp.status_code == 200
|
|
html = resp.get_data(as_text=True)
|
|
|
|
scripts = _INLINE_SCRIPT_RX.findall(html)
|
|
assert scripts, "expected at least one inline <script> on /measure/execute"
|
|
for i, body in enumerate(scripts):
|
|
_node_check(body, f"/measure/execute script[{i}]")
|
|
|
|
_check_alpine_attributes(html, "/measure/execute")
|
|
|
|
|
|
def test_select_recipe_inline_js_is_valid(logged_in_client, mock_measure_api, monkeypatch):
|
|
"""The selection screen decides whether Avvia starts at all.
|
|
|
|
Its expressions are inline in x-data, so a broken one there takes the search,
|
|
the barcode scanner and the traceability check down with it.
|
|
"""
|
|
monkeypatch.setenv("STATION_CODE", "ST-TEST")
|
|
import importlib
|
|
|
|
import config
|
|
importlib.reload(config)
|
|
import blueprints.measure
|
|
importlib.reload(blueprints.measure)
|
|
|
|
_force_italian(logged_in_client)
|
|
mock_measure_api.get_station_recipes.return_value = [
|
|
{
|
|
"id": 1, "code": "REC-001", "name": "Ricetta con l'apostrofo",
|
|
"description": "Con l'apostrofo anche qui",
|
|
"requires_lot": True, "requires_serial": True,
|
|
},
|
|
]
|
|
|
|
resp = logged_in_client.get("/measure/select")
|
|
assert resp.status_code == 200
|
|
html = resp.get_data(as_text=True)
|
|
|
|
for i, body in enumerate(_INLINE_SCRIPT_RX.findall(html)):
|
|
_node_check(body, f"/measure/select script[{i}]")
|
|
|
|
_check_alpine_attributes(html, "/measure/select")
|
|
|
|
|
|
def test_task_list_inline_js_is_valid(logged_in_client, mock_measure_api):
|
|
"""The task list carries the shared measurement clock.
|
|
|
|
Its bindings are the ones that turn the countdown red and announce the return
|
|
to the measurement; a broken expression here would take the whole page's Alpine
|
|
with it, and the operator would simply never be called back.
|
|
"""
|
|
_force_italian(logged_in_client)
|
|
mock_measure_api.get.side_effect = api_get_router({
|
|
"/api/recipes/3/tasks": [{
|
|
"id": 11, "order_index": 0, "title": "Quota d'ingresso",
|
|
"task_type": "measure", "subtasks": [], "file_path": None,
|
|
}],
|
|
"/api/recipes/3": {
|
|
"id": 3, "code": "REC-3", "name": "Ricetta", "description": None,
|
|
"current_version": {"id": 5},
|
|
},
|
|
"/api/production-runs/current": None,
|
|
"/api/measurements/task-progress": {
|
|
"tasks": [{"task_id": 11, "quotes": 2, "measured": 1, "state": "partial"}],
|
|
},
|
|
})
|
|
|
|
resp = logged_in_client.get("/measure/tasks/3")
|
|
assert resp.status_code == 200
|
|
html = resp.get_data(as_text=True)
|
|
|
|
for i, body in enumerate(_INLINE_SCRIPT_RX.findall(html)):
|
|
_node_check(body, f"/measure/tasks script[{i}]")
|
|
|
|
_check_alpine_attributes(html, "/measure/tasks")
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_maker_api():
|
|
"""Patch api_client used inside the maker blueprint."""
|
|
mock = MagicMock()
|
|
with patch("blueprints.maker.api_client", mock):
|
|
yield mock
|
|
|
|
|
|
def test_task_editor_inline_js_is_valid(logged_in_client, mock_maker_api):
|
|
"""gitea#4: the version-conflict reload added _reloadForNewVersion() and
|
|
new `if (…status === 409)` / `data.task_id !== taskId` checks in every
|
|
save handler. Regression guard that the inline script still parses."""
|
|
_force_italian(logged_in_client)
|
|
mock_maker_api.get.side_effect = api_get_router({
|
|
"/api/recipes/3/tasks": [{
|
|
"id": 11, "order_index": 0, "title": "Quota d'ingresso",
|
|
"task_type": "measure", "file_path": None, "file_type": None,
|
|
"annotations_json": None,
|
|
"subtasks": [{
|
|
"id": 21, "marker_number": 1, "description": "Diametro",
|
|
"nominal": 10.0, "utl": 10.5, "uwl": 10.2, "lwl": 9.8, "ltl": 9.5,
|
|
"unit": "mm",
|
|
}],
|
|
}],
|
|
"/api/recipes/3": {
|
|
"id": 3, "code": "REC-3", "name": "Ricetta con l'apostrofo",
|
|
"description": "Con l'apostrofo anche qui",
|
|
},
|
|
})
|
|
|
|
resp = logged_in_client.get("/maker/recipes/3/tasks")
|
|
assert resp.status_code == 200
|
|
html = resp.get_data(as_text=True)
|
|
|
|
scripts = _INLINE_SCRIPT_RX.findall(html)
|
|
assert scripts, "expected at least one inline <script> on /maker/recipes/3/tasks"
|
|
for i, body in enumerate(scripts):
|
|
_node_check(body, f"/maker/recipes/3/tasks script[{i}]")
|
|
|
|
_check_alpine_attributes(html, "/maker/recipes/3/tasks")
|