feat(offline): la policy arriva dove le pagine si aprono davvero

Punto 12. Le cinque librerie erano già state portate in casa (28ee44b): nessun
template carica più niente dalla rete, il worker di PDF.js è locale in tutti e
quattro i file che lo impostano, i font sono woff2 nel pacchetto. Verificato riga
per riga, e le impronte SHA-256 in VERSIONS.md corrispondono ancora.

Mancava però la seconda metà dell'intervento, e mancava dove conta. La
Content-Security-Policy a sola origine locale esisteva sul backend, cioè sulle
risposte API; le pagine HTML le serve il client Flask, che non mandava alcuna
policy. La regola stava scritta dove non poteva essere infranta e assente dove
poteva. Ora il client la manda su ogni risposta.

Serve meno a difendere e più a non far tornare indietro il punto: un tag verso un
CDN aggiunto fra sei mesi viene rifiutato dal browser alla scrivania, dove c'è la
rete e l'errore si legge in console, invece che in reparto dove la rete non c'è.

Tailwind era ancora agganciato a `tailwindcss@3` nel Dockerfile: stesso difetto
che il documento cita per le librerie del browser, un gradino più in basso. Fissato
a 3.4.19, che è la versione con cui l'immagine in esercizio è stata costruita.

I test non renderizzano niente: leggono i sorgenti, perché il difetto che devono
impedire si scrive in un template e non si vede finché non si stacca la rete.
Controllano anche le impronte — una libreria sostituita sul posto tiene lo stesso
nome e la stessa riga in tabella, e l'hash è l'unica parte che se ne accorge.

Annotato in VERSIONS.md che `html5-qrcode` è l'unica libreria dichiarata e mai
caricata: lo scanner da fotocamera non è incluso da nessuna pagina, il lettore che
l'operatore usa è un campo di testo. Va portata in casa prima di accenderlo.

README aggiornato a V3.0.0: novità della versione punto per punto, ruolo
Supervisor, librerie locali al posto dei CDN, stato dei test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-28 21:58:13 +00:00
parent 78816dfe8c
commit c4a429d952
5 changed files with 335 additions and 15 deletions
+7 -1
View File
@@ -20,8 +20,14 @@ RUN uv sync --frozen --no-dev --extra client
COPY src/frontend/flask_app/ ./flask_app/
# Build TailwindCSS (one-shot; no watcher in production image).
#
# Versione esatta, non `tailwindcss@3`. Con il vincolo aperto ogni ricostruzione
# dell'immagine poteva prendere una minor diversa e produrre un CSS diverso senza
# che nessuno l'avesse validato: per un sistema che produce evidenze per audit
# ISO 9001 / IATF 16949 è lo stesso problema che il punto 12 risolve per le
# librerie del browser. Aggiornarla è una decisione, non un effetto collaterale.
WORKDIR /app/flask_app
RUN npm install tailwindcss@3 && \
RUN npm install tailwindcss@3.4.19 && \
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
# Compile Flask-Babel translation catalogs.
+70 -13
View File
@@ -2,8 +2,9 @@
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate, identità per-stazione e rate limiting per-tablet.
> **Versione corrente:** V2.0.0 (in sviluppo) — branch default `V2.0.0`.
> **Versione corrente:** V3.0.0 (in sviluppo) — branch `V3.0.0`.
> Per stato dettagliato e prossimi passi vedi [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) e [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md).
> V3.0.0 lavora sui quindici punti di `TieMeasureFlow_modifiche_2026-07-28.md`: vedi [Novità di V3.0.0](#novità-di-v300).
---
@@ -25,6 +26,32 @@ Caratteristiche principali:
---
## Novità di V3.0.0
V3.0.0 lavora sui quindici punti raccolti in `TieMeasureFlow_modifiche_2026-07-28.md`,
dal sopralluogo del 28/07. Quanto segue è **fatto e in esercizio**.
| # | Punto | Cosa cambia |
|---|---|---|
| 1 · 6 | **Produzione con una vita propria** | `production_runs` e `production_events` sul server: avvio, fermo linea, ripresa e fine produzione esistono come stato, non come pulsanti. La fine produzione emette il file di statistica dell'intera produzione |
| 2 | **Tipo di task dichiarato** | `task_type` (`note`, `measure`, `drawing`, `xf_compare`, `camera_measure`) al posto di «ha subtask, quindi è una misura»: un task di misura senza quote non si comporta più come una nota |
| 3 | **Ciclo di misura** | L'intervallo della ricetta vive sul server, sopravvive al cambio pagina, conta anche il ritardo (in rosso, oltre lo zero) e riporta l'operatore alla misura. Cicalino via WebAudio |
| 5 | **Fuori tolleranza vincolante** | Una quota fuori tolleranza blocca l'avanzamento finché il capoturno non autorizza — o finché quella stessa quota non viene rimisurata dentro i limiti. L'autorizzazione resta scritta sulla misura e nel CSV |
| 7 | **Stazioni** | Ricette per stazione, cambio stazione da URL per il collaudo, reset per riga |
| 8 | **Tracciabilità obbligatoria** | `requires_lot` / `requires_serial` per ricetta, verificati sul server: nessuna porta d'ingresso li aggira, barcode compreso |
| 9 | **Blocco inserimento manuale** | `allow_manual_input`: con la ricetta a solo calibro il tastierino non viene disegnato affatto, e il server rifiuta comunque un valore digitato a mano |
| 10 | **Sequenza per l'operatore** | La ricetta si apre sul primo task, non su un elenco. La lista scende a secondo livello e dice quali task sono rimasti incompiuti (`2/3`). «Fine ciclo misura» è visibile da subito e spento finché mancano quote |
| 11 | **Descrizioni formattate** | `**grassetto**` e a capo nelle descrizioni dei task, con marcatura e non HTML: la sanificazione è per costruzione |
| 12 | **Funzionamento senza internet** | Tutte le librerie e i font nell'installazione, versioni congelate e verificate per impronta, Content-Security-Policy a sola origine locale |
| 14 | **Layout stabile** | Una cornice sola per tutte le viste, allineata alla navbar; spazio della barra di scorrimento sempre riservato; schermata di misura su `100dvh`. Vedi [`docs/architecture/LAYOUT.md`](docs/architecture/LAYOUT.md) |
| 15 | **Statistica separata** | Si registra sempre, si consulta a parte: nessun percorso dell'operatore porta alla statistica (verificato da test) |
**Aperto**: il punto 4 (numero di tentativi prima del capoturno) attende le risposte
del cliente su modalità di autorizzazione e numero di tentativi; il punto 13
(generazione task dalla scheda tecnica con l'AI) è fuori offerta.
---
## Architettura
```
@@ -67,13 +94,22 @@ Il frontend Flask non espone mai le credenziali al browser: ogni chiamata al bac
| Flask | 3.x | Framework web server-side |
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) |
| Jinja2 | incluso in Flask | Template engine |
| Alpine.js | 3.x (CDN) | Reattività leggera lato client |
| TailwindCSS | 3.x | CSS utility-first |
| Plotly.js | CDN | Grafici SPC interattivi |
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici |
| html5-qrcode | CDN | Scanner barcode/QR camera |
| Alpine.js | 3.15.12 (locale) | Reattività leggera lato client |
| TailwindCSS | 3.4.19 (build) | CSS utility-first, compilato nell'immagine |
| Plotly.js | 2.32.0 (locale) | Grafici SPC interattivi |
| PDF.js | 3.11.174 (locale) | Visualizzazione disegni PDF, worker incluso |
| Fabric.js | 5.3.1 (locale) | Editor annotazioni disegni tecnici |
| Inter + JetBrains Mono | woff2 locali | Font UI e numeri |
| Flask-Babel | ultima stabile | i18n IT/EN |
**Nessuna libreria arriva dalla rete.** Tutte stanno in
[`src/frontend/flask_app/static/vendor/`](src/frontend/flask_app/static/vendor/VERSIONS.md)
con versione nel nome e impronta SHA-256 verificata da un test: l'installazione a
Tràfilo è su rete di produzione isolata, dove una pagina che aspetta un CDN è una
pagina bianca. Una Content-Security-Policy a sola origine locale (`app.py`) fa sì che
un tag verso l'esterno aggiunto in futuro venga rifiutato alla scrivania, non in
reparto.
### Tooling
| Componente | Ruolo |
@@ -211,7 +247,8 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|---|---|
| **Maker** | Crea e gestisce ricette di misurazione: caricamento disegni (PDF/immagini), annotazioni Fabric.js, definizione task/subtask, configurazione tolleranze, versioning copy-on-write |
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail. Vede solo le ricette assegnate alla propria stazione (`STATION_CODE`) |
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart |
| **Supervisor** (capoturno) | Autorizza ciò che l'operatore non può decidere da solo: una quota fuori tolleranza che deve restare, il fermo linea, la fine produzione. L'autorizzazione resta scritta sulla misura (`supervisor_id`, `authorised_at`) e finisce nel file di statistica |
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart. **L'operatore non raggiunge la statistica da nessun percorso** |
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account, **CRUD stazioni e assegnazioni ricette** |
---
@@ -229,6 +266,7 @@ TieMeasureFlow/
├── docker-compose.yml # Produzione (Traefik, SSL)
├── nginx/ # Config Nginx (dev)
├── uploads/ # Volume Docker file caricati
├── scripts/ # Script del progetto (seed ricette di collaudo)
├── docs/ # Documentazione (vedi indice docs/README.md)
└── src/
├── backend/ # FastAPI Backend
@@ -245,11 +283,14 @@ TieMeasureFlow/
│ │ │ # RecipeTask, RecipeSubtask, Measurement,
│ │ │ # AccessLog, SystemSetting,
│ │ │ # RecipeVersionAudit, Station,
│ │ │ # StationRecipeAssignment
│ │ │ # StationRecipeAssignment,
│ │ │ # ProductionRun, ProductionEvent
│ │ └── api/ # Pydantic v2 schemas request/response
│ ├── services/ # recipe_service, measurement_service,
│ │ # spc_service, report_service,
│ │ # auth_service, station_service
│ │ # auth_service, station_service,
│ │ # production_service,
│ │ # production_export_service
│ ├── migrations/ # Alembic (alembic.ini + env.py)
│ ├── templates/ # Pagina setup (Jinja2)
│ └── tests/ # pytest + httpx + aiosqlite
@@ -262,9 +303,13 @@ TieMeasureFlow/
├── services/ # APIClient (proxy verso FastAPI con XFF)
├── templates/ # Jinja2 + Alpine.js
├── static/
│ ├── css/ # TailwindCSS compilato
└── js/ # numpad, caliper, barcode, csv-export,
# spc-charts, annotation-editor/viewer
│ ├── css/ # TailwindCSS compilato + themes.css
# (cornice pagina, temi, scrollbar)
├── js/ # numpad, caliper, barcode, csv-export,
│ │ # spc-charts, annotation-editor/viewer,
│ │ # production-clock, rich-text
│ └── vendor/ # Alpine, Plotly, PDF.js (+worker), Fabric,
│ # font woff2 — vedi VERSIONS.md
├── translations/ # Flask-Babel .po/.mo IT/EN
└── tests/
```
@@ -341,7 +386,16 @@ uv run pytest --cov src/backend
uv run pytest src/frontend/flask_app/tests/
```
Stato corrente: **183 pass, 0 fail** (vedi `docs/architecture/STATO_PROGETTO.md`).
Stato corrente su `V3.0.0`: **360 pass, 0 fail** (212 backend + 148 frontend).
Alcuni test frontend non renderizzano niente e leggono i sorgenti, perché guardano
proprietà che sopravvivono solo se qualcuno le controlla:
| File | Cosa impedisce |
|---|---|
| `test_offline.py` | Una libreria caricata dalla rete, un worker PDF.js lasciato sul CDN, una libreria sostituita senza aggiornare l'impronta |
| `test_layout_shell.py` | Una vista che torna a dichiararsi la propria larghezza |
| `test_template_js_syntax.py` | Una traduzione con l'apostrofo dentro una stringa JS a virgolette singole, che spegne Alpine su tutta la pagina |
---
@@ -377,6 +431,7 @@ Indice completo: [`docs/README.md`](docs/README.md).
| Documento | Contenuto |
|---|---|
| [`TieMeasureFlow_modifiche_2026-07-28.md`](TieMeasureFlow_modifiche_2026-07-28.md) | I quindici punti del 28/07: cosa deve cambiare, dove intervenire, decisioni in attesa del cliente (D-1…D-9) |
| [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
| [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
@@ -388,6 +443,8 @@ Indice completo: [`docs/README.md`](docs/README.md).
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
| [`docs/USER_GUIDE.md`](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
| [`docs/I18N_SETUP.md`](docs/I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js) |
| [`docs/architecture/LAYOUT.md`](docs/architecture/LAYOUT.md) | La cornice delle pagine: perché il layout si spostava e la regola che lo tiene fermo |
| [`src/frontend/flask_app/static/vendor/VERSIONS.md`](src/frontend/flask_app/static/vendor/VERSIONS.md) | Librerie di terze parti in locale: versioni, impronte, come aggiornarle |
### Piani dettagliati
+41
View File
@@ -17,6 +17,28 @@ from config import Config
# line break. DOTALL so a phrase that wraps onto the next line still closes.
_BOLD_RX = re.compile(r"\*\*(.+?)\*\*", re.DOTALL)
# Punto 12. Nothing loads from outside: every library ships with the install
# (static/vendor/), because the shop floor network has no way out and a page that
# waits for a CDN there is a white screen, not a slow one.
#
# unsafe-inline the templates carry their Alpine components inline
# unsafe-eval Alpine 3 and Plotly both compile expressions at runtime
# blob: PDF.js runs its worker from a blob, Fabric exports canvases
# data: thumbnails and canvas exports are inlined
CSP = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self'; "
"img-src 'self' data: blob:; "
"connect-src 'self'; "
"worker-src 'self' blob:; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'"
)
def get_locale():
"""Get user's preferred language from session or Accept-Language header."""
@@ -115,6 +137,25 @@ def create_app() -> Flask:
)
return Markup(rv)
@app.after_request
def security_headers(response):
"""Same-origin only, on the pages the browser actually loads.
The policy existed on the backend, which serves the API; the HTML comes
from here and carried no policy at all. So the rule that says «nothing
from outside» was written where it could not be broken and absent where
it could.
It is also the guard that makes point 12 stay done: a CDN tag added to a
template months from now is refused by the browser here, at a desk with a
network, instead of on a shop floor that has none.
"""
response.headers.setdefault("Content-Security-Policy", CSP)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
return response
@app.context_processor
def inject_globals():
"""Inject global variables into all templates."""
+43 -1
View File
@@ -64,7 +64,49 @@ db5ff4db83e580426280e9337a58dc57d3a83784a1b03ad80914651594441d52 fonts/jetbrain
```
Verifica: `sha256sum -c` da questa cartella, oppure
`sha256sum *.js fonts/*.woff2`.
`sha256sum *.js fonts/*.woff2`. Il confronto è anche un test
(`tests/test_offline.py::test_the_shipped_libraries_are_the_ones_that_were_checked`):
una libreria sostituita sul posto tiene lo stesso nome e la stessa riga in tabella,
e l'impronta è l'unica parte che se ne accorge.
## La regola che tiene il punto chiuso
Le copie locali risolvono il problema una volta; quello che lo tiene risolto è la
**Content-Security-Policy** servita dal client Flask (`app.py`, `CSP`), che ammette
solo l'origine locale. Prima esisteva solo sul backend — cioè sulle risposte API,
non sulle pagine che il browser disegna: la regola stava scritta dove non poteva
essere infranta e mancava dove poteva.
Con la policy in vigore, un `<script src="https://cdn…">` aggiunto fra sei mesi viene
rifiutato dal browser **alla scrivania**, dove c'è la rete e l'errore si legge in
console, invece che in reparto dove la rete non c'è.
## Tailwind
Tailwind non sta in questa cartella: è compilato in fase di build dell'immagine
(`Dockerfile.frontend`) e il CSS finito viene servito da `static/css/tailwind.css`.
Vale però lo stesso vincolo — la versione è fissata a `tailwindcss@3.4.19`. Con
`tailwindcss@3` ogni ricostruzione poteva prendere una minor diversa e produrre un
foglio di stile diverso senza che nessuno l'avesse validato.
Nota per l'installazione on-premise: la **costruzione** dell'immagine richiede rete
(npm, apt, uv). È l'esecuzione a non richiederla. In reparto va portata l'immagine
già costruita, non il repository da compilare sul posto.
## Una sesta libreria, oggi non caricata
`static/js/barcode.js` e `templates/components/barcode_scanner.html` implementano lo
scanner QR **con la fotocamera** e si appoggiano a `html5-qrcode`, che non è mai
caricata da nessuna pagina: il componente non è incluso da nessuna parte e la
libreria non è in questa cartella. A runtime non rompe niente — è codice morto.
La lettura del barcode che l'operatore usa davvero è un campo di testo in
`measure/select_recipe.html`: il lettore hardware la digita, e non serve nessuna
libreria.
Se un giorno si accende lo scanner da fotocamera, `html5-qrcode` va portata qui
**prima**, altrimenti in reparto quel pulsante mostra «scanner_lib_not_loaded» e
basta.
## Aggiornare una libreria
@@ -0,0 +1,174 @@
"""Point 12: the install must work on a network with no way out.
The factory floor at Tràfilo has no internet. A page that fetches a library from
a CDN there does not load slowly, it does not load: the interface never starts.
The libraries were brought in-house; this is what keeps them there. The failure
mode this guards against is not dramatic - someone adds a `<script src="https://
cdn...">` because it is the quickest way to try something, it works at their desk,
and nobody finds out until the machine is on the shop floor.
"""
import re
from pathlib import Path
import pytest
APP_ROOT = Path(__file__).resolve().parents[1]
TEMPLATES = APP_ROOT / "templates"
STATIC = APP_ROOT / "static"
# Anything loaded by the browser: src=, href=, url(), importScripts, workerSrc.
_EXTERNAL_ASSET_RX = re.compile(
r"""(?:src|href)\s*=\s*["']https?://""", re.I,
)
_EXTERNAL_URL_RX = re.compile(r"""https?://[^\s"'<>)]+""")
# Hosts a comment may legitimately mention: the check is about what gets fetched,
# not about what gets written down. Only documentation links live here.
_DOC_HOSTS = (
"tailwindcss.com", "github.com", "bugzilla.mozilla.org",
"bugs.chromium.org", "bugs.webkit.org", "www.w3.org", "schema.org",
"developer.mozilla.org",
)
def _first_party_files(suffixes):
for path in sorted(STATIC.rglob("*")):
if path.suffix in suffixes and "vendor" not in path.parts:
yield path
for path in sorted(TEMPLATES.rglob("*.html")):
yield path
def test_no_template_loads_anything_from_the_network():
"""A src= or href= pointing outside is the whole bug, in one line."""
offenders = []
for path in sorted(TEMPLATES.rglob("*.html")):
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if _EXTERNAL_ASSET_RX.search(line):
offenders.append(f"{path.relative_to(APP_ROOT)}:{n}")
assert not offenders, f"asset caricati dalla rete: {offenders}"
def test_no_first_party_script_calls_out():
"""Not only tags: a fetch() or a workerSrc reaches the network just the same."""
offenders = []
for path in _first_party_files({".js", ".css"}):
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
for url in _EXTERNAL_URL_RX.findall(line):
if any(host in url for host in _DOC_HOSTS):
continue
offenders.append(f"{path.relative_to(APP_ROOT)}:{n} {url}")
assert not offenders, f"riferimenti esterni: {offenders}"
def test_the_libraries_are_actually_in_the_box():
"""Repointing the tags without shipping the files is the same white screen."""
vendor = STATIC / "vendor"
missing = [
name for name in (
"alpinejs-3.15.12.min.js",
"plotly-2.32.0.min.js",
"pdf-3.11.174.min.js",
"pdf.worker-3.11.174.min.js",
"fabric-5.3.1.min.js",
"fonts.css",
)
if not (vendor / name).is_file()
]
assert not missing, f"librerie dichiarate ma non presenti: {missing}"
assert list((vendor / "fonts").glob("*.woff2")), "i font non sono nel pacchetto"
def test_the_pdf_worker_is_local_everywhere_it_is_set():
"""The trap of this point: the library loads from disk and its worker does not.
Everything looks right until someone opens a drawing.
"""
setters = []
for path in sorted(TEMPLATES.rglob("*.html")):
text = path.read_text(encoding="utf-8")
if "workerSrc" not in text:
continue
setters.append(path.relative_to(APP_ROOT).as_posix())
for n, line in enumerate(text.splitlines(), 1):
if "workerSrc" in line:
assert "vendor/pdf.worker" in line, (
f"{path.relative_to(APP_ROOT)}:{n} punta il worker fuori casa"
)
# Four templates render PDFs; if that number changes the new one needs the
# worker set too, and this is where you find out.
assert len(setters) == 4, f"template che impostano il worker: {setters}"
def test_the_shipped_libraries_are_the_ones_that_were_checked():
"""The fingerprints in VERSIONS.md are the record of what was validated.
A library swapped in place - a hurried patch, a bad merge, something worse -
keeps the same filename and the same version in the table. The hash is the
only part that notices.
"""
vendor = STATIC / "vendor"
recorded = dict(
(name, digest)
for digest, name in (
line.split(maxsplit=1)
for line in (
l.strip() for l in
(vendor / "VERSIONS.md").read_text(encoding="utf-8").splitlines()
)
if re.fullmatch(r"[0-9a-f]{64}\s+\S+", line)
)
)
assert recorded, "VERSIONS.md non elenca più le impronte"
import hashlib
wrong = []
for name, digest in recorded.items():
path = vendor / name
if not path.is_file():
wrong.append(f"{name}: assente")
continue
actual = hashlib.sha256(path.read_bytes()).hexdigest()
if actual != digest:
wrong.append(f"{name}: {actual} != {digest}")
assert not wrong, f"librerie diverse da quelle validate: {wrong}"
def test_the_versions_are_pinned():
"""No `3.x.x`: a library that updates itself changes an audit system without
anyone validating it. The filenames on disk carry the version."""
vendor = STATIC / "vendor"
unpinned = [
p.name for p in vendor.glob("*.js")
if not re.search(r"-\d+\.\d+\.\d+(\.min)?\.js$", p.name)
]
assert not unpinned, f"librerie senza versione nel nome: {unpinned}"
# ---------------------------------------------------------------------------
# The policy that keeps it that way
# ---------------------------------------------------------------------------
PAGES = ["/auth/login", "/"]
@pytest.mark.parametrize("path", PAGES)
def test_every_page_carries_the_policy(client, path):
resp = client.get(path, follow_redirects=False)
csp = resp.headers.get("Content-Security-Policy", "")
assert "default-src 'self'" in csp
assert "connect-src 'self'" in csp
# No CDN host may be allowed back in through the policy itself.
assert "http://" not in csp and "https://" not in csp
def test_the_policy_allows_what_the_app_actually_needs(client):
"""A policy that breaks the interface gets removed, and then it protects
nothing. These are the four allowances the libraries genuinely require."""
csp = client.get("/auth/login").headers["Content-Security-Policy"]
assert "'unsafe-eval'" in csp # Alpine 3, Plotly
assert "'unsafe-inline'" in csp # componenti Alpine nei template
assert "worker-src 'self' blob:" in csp # worker di PDF.js
assert "img-src 'self' data: blob:" in csp # canvas Fabric, miniature