fix(admin): apostrophe-in-translation broke /admin/stations Alpine bindings
Two error messages on /admin/stations rendered Italian translations
that contain an apostrophe inside a single-quoted JS literal:
alert(result.detail || '{{ _("Errore nell\'eliminazione") }}');
Jinja outputs the apostrophe verbatim, so the JS string closed
prematurely:
alert(result.detail || 'Errore nell'eliminazione');
That syntax error blew up the entire <script> block, which means
stationManagement() was never defined and EVERY Alpine binding on the
page failed silently. Symptom: clicking "Nuova Stazione" did nothing,
DevTools showed "Alpine Expression Error: openCreateModal is not
defined".
Fix: swap outer JS quotes to double quotes and reword the IT string so
the apostrophe disappears anyway ("Errore nella eliminazione",
"Errore nella assegnazione"). Same for the assignment-error path.
Regression guard: src/frontend/flask_app/tests/test_template_js_syntax.py
renders /admin/stations and /admin/users with the IT locale forced,
extracts every inline <script>, and runs `node --check` on each. The
test is skipped if `node` is not on PATH so CI without Node still
passes. Verified the test catches the original bug (revert + run +
fail) before re-applying the fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -477,7 +477,7 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const result = await resp.json().catch(() => ({}));
|
const result = await resp.json().catch(() => ({}));
|
||||||
alert(result.detail || '{{ _("Errore nell\'eliminazione") }}');
|
alert(result.detail || "{{ _('Errore nella eliminazione') }}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.stations = this.stations.filter(s => s.id !== this.deleteTarget.id);
|
this.stations = this.stations.filter(s => s.id !== this.deleteTarget.id);
|
||||||
@@ -528,7 +528,7 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
});
|
});
|
||||||
const result = await resp.json();
|
const result = await resp.json();
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
this.errorMsg = result.detail || '{{ _("Errore nell\'assegnazione") }}';
|
this.errorMsg = result.detail || "{{ _('Errore nella assegnazione') }}";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const recipe = this.allRecipes.find(r => r.id === parseInt(this.recipeToAdd, 10));
|
const recipe = this.allRecipes.find(r => r.id === parseInt(this.recipeToAdd, 10));
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _node_check(script: str, label: str) -> None:
|
||||||
|
"""Fail the test if `node --check` rejects `script`."""
|
||||||
|
if not script.strip():
|
||||||
|
return
|
||||||
|
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(script)
|
||||||
|
result = subprocess.run(
|
||||||
|
[node, "--check", path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
snippet = script.strip()
|
||||||
|
if len(snippet) > 600:
|
||||||
|
snippet = snippet[:600] + "\n…(truncated)…"
|
||||||
|
pytest.fail(
|
||||||
|
f"{label}: node rejected the inline <script> as invalid JS.\n"
|
||||||
|
f"--- node stderr ---\n{result.stderr.strip()}\n"
|
||||||
|
f"--- script (first 600 chars) ---\n{snippet}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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}]")
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
|
||||||
|
"""Same guard for /admin/users (reuses the userManagement Alpine pattern)."""
|
||||||
|
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/users")
|
||||||
|
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/users script[{i}]")
|
||||||
Reference in New Issue
Block a user