From befdce5e6ae078f62a722d9f1ba3acf0f30acccb Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Mon, 21 Sep 2026 22:47:30 +0200 Subject: [PATCH] fix(measure): filtro stazione anche su barcode, avvio sequenza e task_execute (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _recipe_allowed_at_station() riusa get_station_recipes() (stessa fonte di /measure/select) per lookup_barcode, start_sequence e task_execute: prima solo l'elenco filtrava, il barcode e i link diretti lasciavano misurare ricette non assegnate alla stazione. Ricetta non assegnata -> abort(403) con messaggio "ricetta non assegnata a questa stazione" (pagine, come già fa role_required) o JSON 403 (lookup-barcode). Aggiornati i test esistenti che ora richiedono get_station_recipes/STATION_CODE mockati. Backend (ruoli sui run, StationRecipeAssignment su open_run) resta al lotto A1. Co-Authored-By: Claude Sonnet 5 --- src/frontend/flask_app/blueprints/measure.py | 42 +++++++++++- src/frontend/flask_app/tests/test_measure.py | 23 +++++++ .../tests/test_measure_station_filter.py | 65 +++++++++++++++++++ .../flask_app/tests/test_operator_flow_ui.py | 24 ++++--- .../flask_app/tests/test_recipe_rules_ui.py | 22 ++++--- .../tests/test_template_js_syntax.py | 7 +- 6 files changed, 163 insertions(+), 20 deletions(-) diff --git a/src/frontend/flask_app/blueprints/measure.py b/src/frontend/flask_app/blueprints/measure.py index 575fa37..10a33f2 100644 --- a/src/frontend/flask_app/blueprints/measure.py +++ b/src/frontend/flask_app/blueprints/measure.py @@ -1,6 +1,6 @@ """MeasurementTec blueprint - recipe selection and measurement execution.""" from flask import ( - Blueprint, flash, jsonify, redirect, render_template, + Blueprint, abort, flash, jsonify, redirect, render_template, request, session, url_for, ) from flask_babel import gettext as _ @@ -182,6 +182,32 @@ def _current_run_id() -> int | None: return resp.get("id") +def _recipe_allowed_at_station(recipe_id: int) -> bool: + """Is this recipe among the ones the picker shows for the current station? (#24) + + The picker (/measure/select) already filters through + get_station_recipes(); barcode lookup, the guided-sequence shortcut and a + direct link into task_execute all bypass that page, so without this check + an operator could measure a recipe never assigned to this station just by + knowing (or scanning) its code. Same source of truth, same call - no + separate policy to keep in sync. Anything we cannot confirm (no station + configured, the call failing) is refused, not let through. + """ + station_code, _overridden = _current_station() + if not station_code: + return False + try: + resp = api_client.get_station_recipes(station_code) + except Exception: + return False + if isinstance(resp, dict) and resp.get("error"): + return False + recipes = resp.get("items", resp) if isinstance(resp, dict) else resp + if not isinstance(recipes, list): + return False + return any(isinstance(r, dict) and r.get("id") == recipe_id for r in recipes) + + # --------------------------------------------------------------------------- # Route: start the guided sequence # --------------------------------------------------------------------------- @@ -207,6 +233,9 @@ def start_sequence(recipe_id: int): ) return redirect(url_for("measure.select_recipe")) + if not _recipe_allowed_at_station(recipe_id): + abort(403, description=_("Ricetta non assegnata a questa stazione")) + missing = _missing_traceability(recipe_resp, lot_number, serial_number) if missing: flash( @@ -281,11 +310,14 @@ def task_execute(task_id: int): ) return redirect(url_for("measure.select_recipe")) + recipe_id = task_resp.get("recipe_id") + if recipe_id and not _recipe_allowed_at_station(recipe_id): + abort(403, description=_("Ricetta non assegnata a questa stazione")) + lot_number = session.get("lot_number", "") serial_number = session.get("serial_number", "") # Load all task IDs for this recipe (ordered) for auto-advance - recipe_id = task_resp.get("recipe_id") all_task_ids = [] measurement_interval_minutes = None # Allowed until the recipe says otherwise: a recipe that could not be read must @@ -405,6 +437,12 @@ def lookup_barcode(): "detail": resp.get("detail", _("Ricetta non trovata")), }), 404 + if not _recipe_allowed_at_station(resp.get("id")): + return jsonify({ + "error": True, + "detail": _("Ricetta non assegnata a questa stazione"), + }), 403 + return jsonify(resp) diff --git a/src/frontend/flask_app/tests/test_measure.py b/src/frontend/flask_app/tests/test_measure.py index 76c6f2a..4dc5545 100644 --- a/src/frontend/flask_app/tests/test_measure.py +++ b/src/frontend/flask_app/tests/test_measure.py @@ -170,7 +170,15 @@ class TestTaskList: class TestStartSequence: """GET /measure/start/ — the operator's way in.""" + @pytest.fixture(autouse=True) + def _station_configured(self, monkeypatch): + # start_sequence() checks recipe-station assignment (#24); STATION_CODE + # defaults to unset in the test env, so every test in this class needs it. + monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST") + def test_start_goes_to_the_first_task(self, logged_in_client, mock_api_client): + # start_sequence() only accepts a recipe assigned to this station (#24). + mock_api_client.get_station_recipes.return_value = [{"id": 1}] mock_api_client.get.side_effect = api_get_router({ "/api/recipes/1/tasks": [ {"id": 31, "title": "Nota", "order_index": 1}, @@ -186,6 +194,7 @@ class TestStartSequence: def test_start_remembers_the_traceability(self, logged_in_client, mock_api_client): """The list used to be where lot and serial were stored; the sequence skips it, so the storing has to happen on the way in.""" + mock_api_client.get_station_recipes.return_value = [{"id": 1}] mock_api_client.get.side_effect = api_get_router({ "/api/recipes/1/tasks": [{"id": 30, "title": "Prima", "order_index": 0}], "/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"}, @@ -200,6 +209,7 @@ class TestStartSequence: self, logged_in_client, mock_api_client, ): """Point 8 holds on this door too: it is a way into the measurement.""" + mock_api_client.get_station_recipes.return_value = [{"id": 1}] mock_api_client.get.side_effect = api_get_router({ "/api/recipes/1/tasks": [{"id": 30, "title": "Prima", "order_index": 0}], "/api/recipes/1": { @@ -215,6 +225,7 @@ class TestStartSequence: def test_start_without_tasks_falls_back_to_the_list( self, logged_in_client, mock_api_client, ): + mock_api_client.get_station_recipes.return_value = [{"id": 1}] mock_api_client.get.side_effect = api_get_router({ "/api/recipes/1/tasks": [], "/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"}, @@ -224,6 +235,18 @@ class TestStartSequence: assert resp.status_code == 302 assert resp.headers["Location"].endswith("/measure/tasks/1") + def test_start_refuses_recipe_not_assigned_to_station( + self, logged_in_client, mock_api_client, + ): + """#24: a recipe not on this station's list cannot be started via a direct link.""" + mock_api_client.get_station_recipes.return_value = [{"id": 99}] + mock_api_client.get.side_effect = api_get_router({ + "/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"}, + }) + + resp = logged_in_client.get("/measure/start/1") + assert resp.status_code == 403 + class TestTaskComplete: """GET /measure/complete/ tests.""" diff --git a/src/frontend/flask_app/tests/test_measure_station_filter.py b/src/frontend/flask_app/tests/test_measure_station_filter.py index 74f5213..89a1f81 100644 --- a/src/frontend/flask_app/tests/test_measure_station_filter.py +++ b/src/frontend/flask_app/tests/test_measure_station_filter.py @@ -4,6 +4,8 @@ from unittest.mock import patch, MagicMock import pytest +from tests.conftest import api_get_router + @pytest.fixture(autouse=True) def _restore_measure_module(): @@ -151,3 +153,66 @@ def test_station_switch_normalises_case(logged_in_client, monkeypatch): mock_api.get_station_recipes.return_value = [] logged_in_client.get("/measure/select?station=st-other") assert _station_calls(mock_api) == ["ST-OTHER", "ST-OTHER"] + + +# --------------------------------------------------------------------------- +# gitea#24: barcode lookup, the guided-sequence shortcut and a direct link +# into task_execute must not let an operator bypass the picker's station +# filter just by knowing (or scanning) a recipe's code/id. +# --------------------------------------------------------------------------- + + +def test_lookup_barcode_rejects_recipe_not_assigned_to_station(logged_in_client, monkeypatch): + _reload_measure(monkeypatch, station_code="ST-TEST") + from blueprints import measure as measure_mod + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.get.side_effect = api_get_router({ + "/api/recipes/code/R-OTHER": {"id": 99, "code": "R-OTHER", "name": "Other"}, + }) + mock_api.get_station_recipes.return_value = [{"id": 1, "code": "R1"}] + + resp = logged_in_client.post("/measure/lookup-barcode", json={"code": "R-OTHER"}) + assert resp.status_code == 403 + body = resp.get_json() + assert body["error"] is True + assert "assegnata" in body["detail"].lower() + + +def test_lookup_barcode_accepts_recipe_assigned_to_station(logged_in_client, monkeypatch): + _reload_measure(monkeypatch, station_code="ST-TEST") + from blueprints import measure as measure_mod + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.get.side_effect = api_get_router({ + "/api/recipes/code/R1": {"id": 1, "code": "R1", "name": "Assigned"}, + }) + mock_api.get_station_recipes.return_value = [{"id": 1, "code": "R1"}] + + resp = logged_in_client.post("/measure/lookup-barcode", json={"code": "R1"}) + assert resp.status_code == 200 + assert resp.get_json()["id"] == 1 + + +def test_start_sequence_rejects_recipe_not_assigned_to_station(logged_in_client, monkeypatch): + _reload_measure(monkeypatch, station_code="ST-TEST") + from blueprints import measure as measure_mod + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.get.side_effect = api_get_router({ + "/api/recipes/99": {"id": 99, "code": "R-OTHER", "name": "Other"}, + }) + mock_api.get_station_recipes.return_value = [{"id": 1, "code": "R1"}] + + resp = logged_in_client.get("/measure/start/99") + assert resp.status_code == 403 + + +def test_task_execute_rejects_recipe_not_assigned_to_station(logged_in_client, monkeypatch): + _reload_measure(monkeypatch, station_code="ST-TEST") + from blueprints import measure as measure_mod + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.get.side_effect = api_get_router({ + "/api/tasks/10": {"id": 10, "recipe_id": 99, "subtasks": []}, + }) + mock_api.get_station_recipes.return_value = [{"id": 1, "code": "R1"}] + + resp = logged_in_client.get("/measure/execute/10") + assert resp.status_code == 403 diff --git a/src/frontend/flask_app/tests/test_operator_flow_ui.py b/src/frontend/flask_app/tests/test_operator_flow_ui.py index 8e0c137..81f5607 100644 --- a/src/frontend/flask_app/tests/test_operator_flow_ui.py +++ b/src/frontend/flask_app/tests/test_operator_flow_ui.py @@ -47,8 +47,14 @@ def _operator_only(client): sess["user"] = user -def _execute_page(client, mock_api_client): +def _execute_page(client, mock_api_client, monkeypatch): _italian(client) + # task_execute() only renders a recipe assigned to this station (#24). + # STATION_CODE defaults to unset in the test env, so it has to be forced + # here for the same reason test_the_selection_screen_starts_the_sequence + # forces it below. + monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST") + mock_api_client.get_station_recipes.return_value = [{"id": 3}] mock_api_client.get.side_effect = api_get_router({ "/api/tasks/11": TASK, "/api/recipes/3/tasks": [{"id": 11, "order_index": 0}], @@ -147,9 +153,9 @@ def test_an_untouched_task_says_nothing(logged_in_client, mock_api_client): def test_the_header_offers_completato_not_the_summary( - logged_in_client, mock_api_client, + logged_in_client, mock_api_client, monkeypatch, ): - html = _execute_page(logged_in_client, mock_api_client) + html = _execute_page(logged_in_client, mock_api_client, monkeypatch) assert 'x-data="taskExecute()"' in html # The header control moves the operator on and is gated by the same rule as # the footer, rather than jumping out of the sequence to a recap. @@ -158,19 +164,19 @@ def test_the_header_offers_completato_not_the_summary( def test_fine_ciclo_is_shown_before_it_can_be_used( - logged_in_client, mock_api_client, + logged_in_client, mock_api_client, monkeypatch, ): """It used to appear only once the task was already finished.""" - html = _execute_page(logged_in_client, mock_api_client) + html = _execute_page(logged_in_client, mock_api_client, monkeypatch) assert 'x-show="!cycleConfirmed"' in html assert ':disabled="!isComplete"' in html assert "Fine ciclo misura" in html def test_the_missing_quotes_are_counted_out_loud( - logged_in_client, mock_api_client, + logged_in_client, mock_api_client, monkeypatch, ): - html = _execute_page(logged_in_client, mock_api_client) + html = _execute_page(logged_in_client, mock_api_client, monkeypatch) assert "missingQuotesLabel" in html assert "Mancano" in html @@ -181,12 +187,12 @@ def test_the_missing_quotes_are_counted_out_loud( def test_the_operator_flow_never_links_to_the_statistics( - logged_in_client, mock_api_client, + logged_in_client, mock_api_client, monkeypatch, ): _operator_only(logged_in_client) pages = [ _list_page(logged_in_client, mock_api_client), - _execute_page(logged_in_client, mock_api_client), + _execute_page(logged_in_client, mock_api_client, monkeypatch), ] for html in pages: assert "/statistics" not in html diff --git a/src/frontend/flask_app/tests/test_recipe_rules_ui.py b/src/frontend/flask_app/tests/test_recipe_rules_ui.py index 945be43..fad16af 100644 --- a/src/frontend/flask_app/tests/test_recipe_rules_ui.py +++ b/src/frontend/flask_app/tests/test_recipe_rules_ui.py @@ -71,7 +71,11 @@ TASK = { } -def _execute_page(client, mock_api_client, allow_manual): +def _execute_page(client, mock_api_client, monkeypatch, allow_manual): + # task_execute() only renders a recipe assigned to this station (#24). + # STATION_CODE defaults to unset in the test env, so it has to be forced. + monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST") + mock_api_client.get_station_recipes.return_value = [{"id": 3}] mock_api_client.get.side_effect = [ TASK, [{"id": 11, "order_index": 0}], @@ -86,9 +90,9 @@ def _execute_page(client, mock_api_client, allow_manual): def test_keypad_digits_are_absent_when_typing_is_forbidden( - logged_in_client, mock_api_client, + logged_in_client, mock_api_client, monkeypatch, ): - html = _execute_page(logged_in_client, mock_api_client, allow_manual=False) + html = _execute_page(logged_in_client, mock_api_client, monkeypatch, allow_manual=False) assert "allowManual: false" in html assert "addDigit('7')" not in html, "i tasti numerici non devono essere in pagina" # The corrections and the confirm stay: a wrong caliper reading has to be @@ -98,17 +102,19 @@ def test_keypad_digits_are_absent_when_typing_is_forbidden( def test_keypad_is_there_when_the_recipe_allows_typing( - logged_in_client, mock_api_client, + logged_in_client, mock_api_client, monkeypatch, ): - html = _execute_page(logged_in_client, mock_api_client, allow_manual=True) + html = _execute_page(logged_in_client, mock_api_client, monkeypatch, allow_manual=True) assert "allowManual: true" in html assert "addDigit('7')" in html def test_keypad_stays_when_the_recipe_cannot_be_read( - logged_in_client, mock_api_client, + logged_in_client, mock_api_client, monkeypatch, ): """A recipe that failed to load must not silently take the keypad away.""" + monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST") + mock_api_client.get_station_recipes.return_value = [{"id": 3}] mock_api_client.get.side_effect = [ TASK, [{"id": 11, "order_index": 0}], @@ -125,10 +131,10 @@ def test_keypad_stays_when_the_recipe_cannot_be_read( def test_the_operator_sees_the_description_formatted( - logged_in_client, mock_api_client, + logged_in_client, mock_api_client, monkeypatch, ): """It was written for the operator, and until now only the maker saw it.""" - html = _execute_page(logged_in_client, mock_api_client, allow_manual=True) + html = _execute_page(logged_in_client, mock_api_client, monkeypatch, allow_manual=True) assert "prima riga
grassetto" in html 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 370c180..e2ed975 100644 --- a/src/frontend/flask_app/tests/test_template_js_syntax.py +++ b/src/frontend/flask_app/tests/test_template_js_syntax.py @@ -227,7 +227,7 @@ def mock_measure_api(): yield mock -def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api): +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 - @@ -235,6 +235,9 @@ def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api): 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, @@ -250,6 +253,8 @@ def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api): "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