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:
+7
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user