feat: FASE 7 - Polish & Testing (security, i18n, test suite, docs)

Security hardening: CORS lockdown, rate limiting middleware con sliding
window e eviction IP stale, security headers (CSP, HSTS, X-Frame-Options),
session cookie hardening, filename sanitization upload.

i18n completion: internazionalizzati barcode.js e csv-export.js con bridge
window.BARCODE_I18N/CSV_I18N, aggiornati .po IT/EN con 27 nuove stringhe.

Tablet UX: touch target 44px per dispositivi coarse pointer.

Test suite: 101 test totali (76 server + 25 client), copertura completa
di tutti i router API, autenticazione, ruoli, CRUD, SPC, file upload,
security integration. Infrastruttura SQLite async in-memory con fixtures.

Fix critici: MissingGreenlet in recipe_service (selectinload eager),
route ordering tasks.py, auth_service bcrypt diretto, Measurement.id
Integer per SQLite.

Documentazione: API.md (riferimento completo 40+ endpoint),
DEPLOYMENT.md (guida produzione con Docker/Nginx/SSL),
USER_GUIDE.md (manuale utente per ruolo).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-07 17:10:24 +01:00
parent 26e5b9343d
commit dd2ebf863a
46 changed files with 6322 additions and 90 deletions
View File
+104
View File
@@ -0,0 +1,104 @@
"""Shared test fixtures for Flask client tests.
Creates a Flask test client and mocks the API client (requests to FastAPI server).
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Ensure the client package is importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app import create_app
# All blueprint modules that import api_client at module level.
# We must patch at the *use-site* (blueprint namespace) so the local name
# ``api_client`` in each module points to the mock, not the real singleton.
_API_CLIENT_PATCH_TARGETS = [
"blueprints.auth.api_client",
"blueprints.maker.api_client",
"blueprints.measure.api_client",
"blueprints.statistics.api_client",
]
@pytest.fixture
def flask_app():
"""Create a Flask application configured for testing."""
app = create_app()
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
app.config["SECRET_KEY"] = "test-secret-key"
# Provide default template variables that are normally set inside
# specific Jinja2 blocks and therefore not visible to sibling blocks.
@app.context_processor
def _inject_template_defaults():
return {
"versions": [],
"current_version": None,
}
return app
@pytest.fixture
def client(flask_app):
"""Yield a Flask test client."""
with flask_app.test_client() as test_client:
yield test_client
@pytest.fixture
def logged_in_client(flask_app):
"""Yield a Flask test client with a logged-in session."""
with flask_app.test_client() as test_client:
with test_client.session_transaction() as sess:
sess["api_key"] = "test-api-key-12345"
sess["user"] = {
"id": 1,
"username": "testuser",
"display_name": "Test User",
"roles": ["Maker", "MeasurementTec", "Metrologist"],
"is_admin": True,
"language_pref": "en",
"theme_pref": "light",
"active": True,
}
sess["user_id"] = 1
sess["language"] = "en"
sess["theme"] = "light"
yield test_client
@pytest.fixture
def mock_api_client():
"""Patch the api_client singleton used in blueprints.
Patches every blueprint module that does
``from services.api_client import api_client`` so the local name
in each module resolves to the same MagicMock instance.
Yields the mocked APIClient instance so tests can configure responses:
mock_api_client.get.return_value = {"items": [...], "total": 1}
mock_api_client.post.return_value = {"user": {...}, "api_key": "..."}
"""
mock = MagicMock()
# Default: all methods return empty success dict
mock.get.return_value = {}
mock.post.return_value = {}
mock.put.return_value = {}
mock.delete.return_value = {}
# Stack patches for every blueprint that imports api_client
patchers = [patch(target, mock) for target in _API_CLIENT_PATCH_TARGETS]
for p in patchers:
p.start()
yield mock
for p in patchers:
p.stop()
+130
View File
@@ -0,0 +1,130 @@
"""Tests for auth blueprint (client/blueprints/auth.py).
Covers login GET/POST, logout, unauthenticated redirect, and language/theme switch.
"""
import pytest
from unittest.mock import patch
class TestLoginPage:
"""GET /auth/login tests."""
def test_login_page_renders(self, client):
"""Login page renders successfully with 200 status."""
resp = client.get("/auth/login")
assert resp.status_code == 200
assert b"login" in resp.data.lower() or b"Login" in resp.data
def test_login_page_redirects_when_already_logged_in(self, logged_in_client):
"""Already authenticated user is redirected away from login."""
resp = logged_in_client.get("/auth/login")
assert resp.status_code == 302
class TestLoginPost:
"""POST /auth/login tests."""
def test_login_post_success_redirects(self, client, mock_api_client):
"""Successful login POST sets session and redirects."""
mock_api_client.post.return_value = {
"api_key": "new-api-key-abc",
"user": {
"id": 1,
"username": "testuser",
"display_name": "Test User",
"roles": ["MeasurementTec"],
"is_admin": False,
"language_pref": "en",
"theme_pref": "light",
"active": True,
},
}
# Mock settings call made after login
mock_api_client.get.return_value = {}
resp = client.post(
"/auth/login",
data={"username": "testuser", "password": "pass123"},
follow_redirects=False,
)
assert resp.status_code == 302
mock_api_client.post.assert_called_once()
# Verify session was populated
with client.session_transaction() as sess:
assert sess.get("api_key") == "new-api-key-abc"
assert sess["user"]["username"] == "testuser"
def test_login_post_error_shows_error(self, client, mock_api_client):
"""Failed login returns the login page with error flash."""
mock_api_client.post.return_value = {
"error": True,
"detail": "Credenziali non valide",
}
resp = client.post(
"/auth/login",
data={"username": "bad", "password": "wrong"},
follow_redirects=True,
)
assert resp.status_code == 200
# Error message should appear in the response (flashed)
assert b"Credenziali non valide" in resp.data or b"login" in resp.data.lower()
def test_login_post_empty_fields(self, client, mock_api_client):
"""Login with empty username/password shows validation error."""
resp = client.post(
"/auth/login",
data={"username": "", "password": ""},
follow_redirects=True,
)
assert resp.status_code == 200
# Should render login again (not crash)
class TestLogout:
"""GET/POST /auth/logout tests."""
def test_logout_clears_session(self, logged_in_client, mock_api_client):
"""Logout clears session and redirects to login."""
mock_api_client.post.return_value = {}
resp = logged_in_client.get("/auth/logout", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
# Session should be cleared
with logged_in_client.session_transaction() as sess:
assert "api_key" not in sess
assert "user" not in sess
class TestUnauthenticatedRedirect:
"""Protected routes redirect unauthenticated users."""
def test_profile_requires_login(self, client):
"""Profile page redirects to login when not authenticated."""
resp = client.get("/auth/profile", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
class TestLanguageSwitch:
"""Language and theme switching via app routes."""
def test_set_language_stores_in_session(self, client):
"""Setting language updates session and redirects back."""
resp = client.get("/set-language/en", follow_redirects=False)
assert resp.status_code == 302
with client.session_transaction() as sess:
assert sess.get("language") == "en"
def test_set_language_invalid_ignored(self, client):
"""Setting an unsupported language does not crash and still redirects."""
resp = client.get("/set-language/xx", follow_redirects=False)
assert resp.status_code == 302
with client.session_transaction() as sess:
# Invalid language should NOT be stored
assert sess.get("language") != "xx"
+89
View File
@@ -0,0 +1,89 @@
"""Tests for maker blueprint (client/blueprints/maker.py).
Covers recipe list, recipe creation page, role enforcement, and recipe edit.
"""
import pytest
class TestRecipeList:
"""GET /maker/recipes tests."""
def test_recipe_list_renders(self, logged_in_client, mock_api_client):
"""Recipe list page renders for Maker role."""
mock_api_client.get.return_value = {
"items": [
{"id": 1, "code": "REC-001", "name": "Recipe A"},
],
"total": 1,
"pages": 1,
}
resp = logged_in_client.get("/maker/recipes")
assert resp.status_code == 200
def test_recipe_list_requires_login(self, client):
"""Unauthenticated user is redirected to login."""
resp = client.get("/maker/recipes", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
class TestRecipeNew:
"""GET /maker/recipes/new tests."""
def test_recipe_new_renders(self, logged_in_client, mock_api_client):
"""Recipe creation page renders for Maker role."""
resp = logged_in_client.get("/maker/recipes/new")
assert resp.status_code == 200
class TestRoleRequired:
"""Maker role enforcement tests."""
def test_requires_maker_role(self, flask_app):
"""User without Maker role gets 403 on maker endpoints."""
with flask_app.test_client() as c:
with c.session_transaction() as sess:
sess["api_key"] = "test-key"
sess["user"] = {
"id": 2,
"username": "tec_only",
"display_name": "Tec Only",
"roles": ["MeasurementTec"],
"is_admin": False,
"language_pref": "en",
"theme_pref": "light",
"active": True,
}
sess["user_id"] = 2
sess["language"] = "en"
sess["theme"] = "light"
resp = c.get("/maker/recipes")
assert resp.status_code == 403
class TestRecipeEdit:
"""GET /maker/recipes/<id>/edit tests."""
def test_recipe_edit_renders(self, logged_in_client, mock_api_client):
"""Recipe edit page renders with recipe data."""
# First call: recipe detail, second call: versions list
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Recipe A"},
[{"version_number": 1, "is_current": True}],
]
resp = logged_in_client.get("/maker/recipes/1/edit")
assert resp.status_code == 200
def test_recipe_edit_not_found(self, logged_in_client, mock_api_client):
"""Recipe edit with API error redirects to recipe list."""
mock_api_client.get.return_value = {
"error": True,
"detail": "Not found",
}
resp = logged_in_client.get("/maker/recipes/999/edit", follow_redirects=False)
assert resp.status_code == 302
assert "/maker/recipes" in resp.headers["Location"]
+91
View File
@@ -0,0 +1,91 @@
"""Tests for measure blueprint (client/blueprints/measure.py).
Covers recipe selection, task list, login requirement, and measurement submission.
"""
import pytest
class TestSelectRecipe:
"""GET /measure/select tests."""
def test_select_recipe_renders(self, logged_in_client, mock_api_client):
"""Recipe selection page renders for MeasurementTec role."""
mock_api_client.get.return_value = {
"items": [
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
],
"total": 1,
"pages": 1,
}
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 200
def test_select_recipe_requires_login(self, client):
"""Unauthenticated user is redirected to login."""
resp = client.get("/measure/select", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
class TestTaskList:
"""GET /measure/tasks/<recipe_id> tests."""
def test_task_list_renders(self, logged_in_client, mock_api_client):
"""Task list page renders with recipe and task data."""
# First call: recipe details (dict), second call: tasks list.
# The route calls tasks_resp.get("error") so the mock must return
# a dict (not a bare list) to avoid AttributeError.
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
{
"items": [
{"id": 1, "title": "Task 1", "order_index": 0},
{"id": 2, "title": "Task 2", "order_index": 1},
],
},
]
resp = logged_in_client.get("/measure/tasks/1")
assert resp.status_code == 200
class TestSaveMeasurement:
"""POST /measure/save-measurement tests."""
def test_save_measurement_proxy(self, logged_in_client, mock_api_client):
"""Save measurement forwards data to API and returns 201."""
mock_api_client.post.return_value = {
"id": 1,
"subtask_id": 10,
"task_id": 5,
"value": 9.95,
"pass_fail": "pass",
}
resp = logged_in_client.post(
"/measure/save-measurement",
json={
"subtask_id": 10,
"task_id": 5,
"value": 9.95,
"pass_fail": "pass",
"deviation": 0.05,
},
content_type="application/json",
)
assert resp.status_code == 201
data = resp.get_json()
assert data["id"] == 1
assert data["pass_fail"] == "pass"
def test_save_measurement_missing_fields(self, logged_in_client, mock_api_client):
"""Missing required fields return 400."""
resp = logged_in_client.post(
"/measure/save-measurement",
json={"subtask_id": 10}, # missing task_id and value
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert data["error"] is True
+88
View File
@@ -0,0 +1,88 @@
"""Tests for statistics blueprint (client/blueprints/statistics.py).
Covers dashboard rendering, role enforcement, and SPC/summary API proxies.
"""
import pytest
class TestDashboard:
"""GET /statistics/dashboard tests."""
def test_dashboard_renders(self, logged_in_client, mock_api_client):
"""Dashboard renders for Metrologist role."""
mock_api_client.get.return_value = {
"items": [
{"id": 1, "code": "REC-001", "name": "Recipe A"},
],
"total": 1,
"pages": 1,
}
resp = logged_in_client.get("/statistics/dashboard")
assert resp.status_code == 200
def test_dashboard_requires_login(self, client):
"""Unauthenticated user is redirected to login."""
resp = client.get("/statistics/dashboard", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
class TestRoleRequired:
"""Metrologist role enforcement tests."""
def test_requires_metrologist_role(self, flask_app):
"""User without Metrologist role gets 403 on statistics endpoints."""
with flask_app.test_client() as c:
with c.session_transaction() as sess:
sess["api_key"] = "test-key"
sess["user"] = {
"id": 3,
"username": "maker_only",
"display_name": "Maker Only",
"roles": ["Maker"],
"is_admin": False,
"language_pref": "en",
"theme_pref": "light",
"active": True,
}
sess["user_id"] = 3
sess["language"] = "en"
sess["theme"] = "light"
resp = c.get("/statistics/dashboard")
assert resp.status_code == 403
class TestSPCProxy:
"""SPC data proxy endpoint tests."""
def test_summary_proxy(self, logged_in_client, mock_api_client):
"""Summary API proxy returns JSON from backend."""
mock_api_client.get.return_value = {
"total": 100,
"pass_count": 90,
"fail_count": 5,
"warning_count": 5,
}
resp = logged_in_client.get(
"/statistics/api/summary?recipe_id=1"
)
assert resp.status_code == 200
data = resp.get_json()
assert data["total"] == 100
def test_capability_proxy(self, logged_in_client, mock_api_client):
"""Capability API proxy returns JSON from backend."""
mock_api_client.get.return_value = {
"cp": 1.33,
"cpk": 1.20,
}
resp = logged_in_client.get(
"/statistics/api/capability?recipe_id=1&subtask_id=1"
)
assert resp.status_code == 200
data = resp.get_json()
assert "cp" in data