diff --git a/src/frontend/flask_app/blueprints/measure.py b/src/frontend/flask_app/blueprints/measure.py
index 9ca10aa..db8dee8 100644
--- a/src/frontend/flask_app/blueprints/measure.py
+++ b/src/frontend/flask_app/blueprints/measure.py
@@ -1,4 +1,6 @@
"""MeasurementTec blueprint - recipe selection and measurement execution."""
+import urllib.parse
+
from flask import (
Blueprint, abort, flash, jsonify, redirect, render_template,
request, session, url_for,
@@ -449,12 +451,21 @@ def task_complete(recipe_id: int):
def lookup_barcode():
"""Look up a recipe by barcode/code. Returns JSON for AJAX calls."""
data = request.get_json(silent=True) or {}
- code = data.get("code", "").strip()
+ code = data.get("code", "")
+ # #37.4: a non-string "code" (e.g. {"code": 5}) crashed .strip() into a
+ # 500. A slash in the code (or "../") used to walk straight into the URL
+ # path unescaped - "AB/123" split into segments the backend never
+ # matched, and "../" could reach another GET endpoint with this user's
+ # permissions.
+ if not isinstance(code, str):
+ return jsonify({"error": True, "detail": _("Codice non valido")}), 400
+
+ code = code.strip()
if not code:
return jsonify({"error": True, "detail": _("Codice non fornito")}), 400
- resp = api_client.get(f"/api/recipes/code/{code}")
+ resp = api_client.get(f"/api/recipes/code/{urllib.parse.quote(code, safe='')}")
if resp.get("error"):
return jsonify({
"error": True,
diff --git a/src/frontend/flask_app/blueprints/statistics.py b/src/frontend/flask_app/blueprints/statistics.py
index e411929..cfbf070 100644
--- a/src/frontend/flask_app/blueprints/statistics.py
+++ b/src/frontend/flask_app/blueprints/statistics.py
@@ -20,11 +20,22 @@ statistics_bp = Blueprint("statistics", __name__)
@role_required("Metrologist")
def dashboard():
"""SPC dashboard overview — loads recipes for filter dropdown."""
- resp = api_client.get("/api/recipes", params={"per_page": 100})
- if isinstance(resp, dict) and resp.get("error"):
- recipes = []
- else:
- recipes = resp.get("items", []) if isinstance(resp, dict) else []
+ # #37.6: a single per_page=100 call silently dropped every recipe past
+ # the 100th from the filter dropdown. 100 is the backend's max per_page
+ # (routers/recipes.py), so a full listing needs a page loop, not a
+ # bigger number.
+ recipes = []
+ page = 1
+ while True:
+ resp = api_client.get("/api/recipes", params={"per_page": 100, "page": page})
+ if isinstance(resp, dict) and resp.get("error"):
+ break
+ page_items = resp.get("items", []) if isinstance(resp, dict) else []
+ recipes.extend(page_items)
+ total_pages = resp.get("pages", 1) if isinstance(resp, dict) else 1
+ if page >= total_pages or not page_items:
+ break
+ page += 1
return render_template("statistics/dashboard.html", recipes=recipes)
diff --git a/src/frontend/flask_app/services/api_client.py b/src/frontend/flask_app/services/api_client.py
index 7742f50..a1dd429 100644
--- a/src/frontend/flask_app/services/api_client.py
+++ b/src/frontend/flask_app/services/api_client.py
@@ -113,9 +113,13 @@ class APIClient:
)
return self._handle_response(response)
except (requests.ConnectionError, requests.Timeout) as e:
+ # 502, not 0 (#37.1): a route that forwards status_code straight to
+ # jsonify(...), status_code was answering with a made-up HTTP status
+ # that Werkzeug doesn't know ("0 UNKNOWN"), instead of a real one a
+ # fetch caller can branch on.
return {
"error": True,
- "status_code": 0,
+ "status_code": 502,
"detail": f"Errore di connessione al server: {str(e)}"
}
@@ -146,7 +150,7 @@ class APIClient:
except (requests.ConnectionError, requests.Timeout) as e:
return {
"error": True,
- "status_code": 0,
+ "status_code": 502,
"detail": f"Errore di connessione al server: {str(e)}"
}
@@ -163,7 +167,7 @@ class APIClient:
except (requests.ConnectionError, requests.Timeout) as e:
return {
"error": True,
- "status_code": 0,
+ "status_code": 502,
"detail": f"Errore di connessione al server: {str(e)}"
}
@@ -179,7 +183,7 @@ class APIClient:
except (requests.ConnectionError, requests.Timeout) as e:
return {
"error": True,
- "status_code": 0,
+ "status_code": 502,
"detail": f"Errore di connessione al server: {str(e)}"
}
diff --git a/src/frontend/flask_app/templates/admin/stations.html b/src/frontend/flask_app/templates/admin/stations.html
index 744c220..b916739 100644
--- a/src/frontend/flask_app/templates/admin/stations.html
+++ b/src/frontend/flask_app/templates/admin/stations.html
@@ -86,13 +86,13 @@
:class="station.active
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'"
- x-text="station.active ? '{{ _('Attiva') }}' : '{{ _('Disattivata') }}'">
+ x-text="station.active ? {{ _('Attiva')|tojson_attr }} : {{ _('Disattivata')|tojson_attr }}">
|
-
+
@@ -106,7 +106,7 @@
:class="user.active
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
: 'text-[var(--text-secondary)] hover:text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20'"
- :title="user.active ? '{{ _('Disattiva') }}' : '{{ _('Riattiva') }}'">
+ :title="user.active ? {{ _('Disattiva')|tojson_attr }} : {{ _('Riattiva')|tojson_attr }}">
@@ -158,7 +158,7 @@
@@ -295,7 +295,7 @@
:disabled="saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50">
-
+
{{ _('Salvataggio...') }}
@@ -310,7 +310,7 @@
x-transition
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-sm p-6">
+ x-text="toggleUser?.active ? {{ _('Conferma Disattivazione')|tojson_attr }} : {{ _('Conferma Riattivazione')|tojson_attr }}">
-
+
diff --git a/src/frontend/flask_app/templates/components/caliper_status.html b/src/frontend/flask_app/templates/components/caliper_status.html
index 65721ad..e170f0c 100644
--- a/src/frontend/flask_app/templates/components/caliper_status.html
+++ b/src/frontend/flask_app/templates/components/caliper_status.html
@@ -12,8 +12,8 @@
? 'bg-green-50 dark:bg-green-900/20 border-green-300 dark:border-green-700 shadow-md shadow-green-100 dark:shadow-green-900/30'
: 'bg-[var(--bg-card)] border-[var(--border-color)]'"
:title="readingCount > 0
- ? '{{ _('Letture calibro') }}: ' + readingCount
- : '{{ _('Calibro USB') }}'">
+ ? {{ _('Letture calibro')|tojson_attr }} + ': ' + readingCount
+ : {{ _('Calibro USB')|tojson_attr }}">
@@ -43,7 +43,7 @@
+ x-text="active ? {{ _('Lettura calibro')|tojson_attr }} : {{ _('Calibro USB')|tojson_attr }}">
-
+
{% if not is_new %}
@@ -487,7 +487,7 @@
-
+
+ :placeholder="{{ _('Cerca per nome, codice o descrizione...')|tojson_attr }}">
@@ -141,7 +141,7 @@
@@ -183,7 +183,7 @@
+ x-text="recipe.active ? {{ _('Attiva')|tojson_attr }} : {{ _('Disattivata')|tojson_attr }}">
diff --git a/src/frontend/flask_app/templates/maker/task_drawing.html b/src/frontend/flask_app/templates/maker/task_drawing.html
index 2d76adb..7f93953 100644
--- a/src/frontend/flask_app/templates/maker/task_drawing.html
+++ b/src/frontend/flask_app/templates/maker/task_drawing.html
@@ -278,7 +278,7 @@
diff --git a/src/frontend/flask_app/tests/test_api_client_backend_down.py b/src/frontend/flask_app/tests/test_api_client_backend_down.py
new file mode 100644
index 0000000..a76a7c6
--- /dev/null
+++ b/src/frontend/flask_app/tests/test_api_client_backend_down.py
@@ -0,0 +1,43 @@
+"""#37.1: the backend being unreachable must surface as a real HTTP status.
+
+api_client used to answer a ConnectionError/Timeout with status_code: 0,
+which every proxy route forwards straight through
+(``jsonify(resp), resp.get("status_code", 500)``) as the response's actual
+HTTP status - and 0 is not a valid one (Werkzeug renders it "0 UNKNOWN").
+502 Bad Gateway is what it actually is: this server reached out to another
+one and got nothing back.
+"""
+import requests
+import pytest
+from unittest.mock import patch
+
+from services.api_client import api_client
+
+
+@pytest.mark.parametrize("exc", [requests.ConnectionError("refused"), requests.Timeout("timed out")])
+@pytest.mark.parametrize("method,mocked,call_kwargs", [
+ ("get", "services.api_client.requests.get", {}),
+ ("post", "services.api_client.requests.post", {"data": {}}),
+ ("put", "services.api_client.requests.put", {"data": {}}),
+ ("delete", "services.api_client.requests.delete", {}),
+])
+def test_backend_unreachable_returns_502(flask_app, method, mocked, call_kwargs, exc):
+ with flask_app.test_request_context("/"):
+ with patch(mocked, side_effect=exc):
+ result = getattr(api_client, method)("/api/whatever", **call_kwargs)
+
+ assert result["error"] is True
+ assert result["status_code"] == 502
+ assert result["status_code"] != 0
+
+
+def test_pending_authorisation_route_surfaces_502(logged_in_client):
+ with patch(
+ "services.api_client.requests.get",
+ side_effect=requests.ConnectionError("refused"),
+ ):
+ resp = logged_in_client.get(
+ "/measure/api/measurements/pending-authorisation?version_id=5",
+ )
+ assert resp.status_code == 502
+ assert resp.get_json()["error"] is True
diff --git a/src/frontend/flask_app/tests/test_measure.py b/src/frontend/flask_app/tests/test_measure.py
index 3539f35..e7512d0 100644
--- a/src/frontend/flask_app/tests/test_measure.py
+++ b/src/frontend/flask_app/tests/test_measure.py
@@ -472,3 +472,43 @@ class TestSaveMeasurement:
assert resp.status_code == 400
data = resp.get_json()
assert data["error"] is True
+
+
+class TestLookupBarcode:
+ """POST /measure/lookup-barcode tests (#37.4)."""
+
+ def test_non_string_code_returns_400_not_500(self, logged_in_client, mock_api_client):
+ """{"code": 5} used to reach code.strip() on an int and crash to a 500."""
+ resp = logged_in_client.post(
+ "/measure/lookup-barcode", json={"code": 5},
+ )
+ assert resp.status_code == 400
+ assert resp.get_json()["error"] is True
+ mock_api_client.get.assert_not_called()
+
+ def test_code_with_slash_is_url_quoted(
+ self, logged_in_client, mock_api_client, monkeypatch,
+ ):
+ """"AB/123" must reach the backend as one path segment, not two."""
+ monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
+ mock_api_client.get_station_recipes.return_value = [{"id": 1}]
+ mock_api_client.get.return_value = {"id": 1, "code": "AB/123"}
+
+ resp = logged_in_client.post(
+ "/measure/lookup-barcode", json={"code": "AB/123"},
+ )
+ assert resp.status_code == 200
+ endpoint = mock_api_client.get.call_args[0][0]
+ assert endpoint == "/api/recipes/code/AB%2F123"
+
+ def test_code_with_path_traversal_is_quoted_not_walked(
+ self, logged_in_client, mock_api_client,
+ ):
+ mock_api_client.get.return_value = {"error": True, "status_code": 404}
+
+ resp = logged_in_client.post(
+ "/measure/lookup-barcode", json={"code": "../users"},
+ )
+ assert resp.status_code == 404
+ endpoint = mock_api_client.get.call_args[0][0]
+ assert endpoint == "/api/recipes/code/..%2Fusers"
diff --git a/src/frontend/flask_app/tests/test_statistics.py b/src/frontend/flask_app/tests/test_statistics.py
index 3f34f00..f5b2049 100644
--- a/src/frontend/flask_app/tests/test_statistics.py
+++ b/src/frontend/flask_app/tests/test_statistics.py
@@ -27,6 +27,32 @@ class TestDashboard:
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
+ def test_dashboard_pages_past_the_first_hundred_recipes(
+ self, logged_in_client, mock_api_client,
+ ):
+ """#37.6: a single per_page=100 call used to drop every recipe past #100."""
+ page1 = {
+ "items": [{"id": i, "code": f"REC-{i:03d}"} for i in range(100)],
+ "total": 101,
+ "pages": 2,
+ }
+ page2 = {
+ "items": [{"id": 100, "code": "REC-100"}],
+ "total": 101,
+ "pages": 2,
+ }
+ mock_api_client.get.side_effect = [page1, page2]
+
+ resp = logged_in_client.get("/statistics/dashboard")
+ assert resp.status_code == 200
+ assert mock_api_client.get.call_count == 2
+ assert "REC-100" in resp.get_data(as_text=True)
+
+ first_call = mock_api_client.get.call_args_list[0]
+ second_call = mock_api_client.get.call_args_list[1]
+ assert first_call.kwargs["params"]["page"] == 1
+ assert second_call.kwargs["params"]["page"] == 2
+
class TestRoleRequired:
"""Metrologist role enforcement tests."""
diff --git a/src/frontend/flask_app/tests/test_template_js_syntax.py b/src/frontend/flask_app/tests/test_template_js_syntax.py
index e2ed975..e92a94e 100644
--- a/src/frontend/flask_app/tests/test_template_js_syntax.py
+++ b/src/frontend/flask_app/tests/test_template_js_syntax.py
@@ -197,6 +197,39 @@ def test_admin_stations_inline_js_is_valid(logged_in_client, mock_admin_api):
_check_alpine_attributes(html, "/admin/stations")
+def test_admin_stations_inline_js_is_valid_in_english(logged_in_client, mock_admin_api):
+ """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.
+ """
+ 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}],
+ ]
+
+ with logged_in_client.session_transaction() as sess:
+ sess["language"] = "en"
+ resp = logged_in_client.get("/admin/stations")
+ assert resp.status_code == 200, resp.data[:300]
+
+ html = resp.data.decode("utf-8")
+ assert "station" in html.lower(), "sanity check: the EN string did render"
+
+ 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 /admin/users (reuses the userManagement Alpine pattern)."""
mock_admin_api.get.return_value = [{
|