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 }}">
@@ -299,7 +299,7 @@ :disabled="saving" class="ml-2 inline-flex items-center gap-1 px-2.5 py-1 bg-primary text-white text-xs font-medium rounded-md hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50" - :title="'{{ _('Assegna a questa stazione') }}'"> + :title="{{ _('Assegna a questa stazione')|tojson_attr }}"> @@ -332,7 +332,7 @@ @@ -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/templates/measure/select_recipe.html b/src/frontend/flask_app/templates/measure/select_recipe.html index 12decef..98c1ae9 100644 --- a/src/frontend/flask_app/templates/measure/select_recipe.html +++ b/src/frontend/flask_app/templates/measure/select_recipe.html @@ -27,8 +27,8 @@ when the production opens - this is only the earlier answer. */ missingTraceability(recipe) { const missing = []; - if (recipe.requires_lot && !this.lot_number.trim()) missing.push('{{ _('lotto') }}'); - if (recipe.requires_serial && !this.serial_number.trim()) missing.push('{{ _('seriale') }}'); + if (recipe.requires_lot && !this.lot_number.trim()) missing.push({{ _('lotto')|tojson_attr }}); + if (recipe.requires_serial && !this.serial_number.trim()) missing.push({{ _('seriale')|tojson_attr }}); return missing; }, /* Straight into the first task. Choosing a recipe used to open the full @@ -57,14 +57,14 @@ }); const data = await resp.json(); if (data.error) { - this.barcodeError = data.detail || '{{ _("Ricetta non trovata") }}'; + this.barcodeError = data.detail || {{ _("Ricetta non trovata")|tojson_attr }}; } else { // The scanner is a way in like any other: a recipe that demands a lot // demands it here too, or the rule would last exactly as long as it // took someone to reach for the barcode reader. const missing = this.missingTraceability(data); if (missing.length) { - this.barcodeError = '{{ _("Compila prima:") }} ' + missing.join(', '); + this.barcodeError = {{ _("Compila prima:")|tojson_attr }} + ' ' + missing.join(', '); return; } this.barcodeModal = false; @@ -72,7 +72,7 @@ window.location.href = this.buildTaskUrl(data.id); } } catch (e) { - this.barcodeError = '{{ _("Errore di connessione") }}'; + this.barcodeError = {{ _("Errore di connessione")|tojson_attr }}; } finally { this.barcodeLoading = false; } @@ -239,7 +239,7 @@

+ x-text="recipe.description || {{ _('Nessuna descrizione disponibile')|tojson_attr }}">

@@ -277,7 +277,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 = [{