feat(web): add local application on 8081 with session recovery
This commit is contained in:
parent
d65f9559e6
commit
3488f67b2a
28 changed files with 3197 additions and 193 deletions
30
CHANGELOG.md
30
CHANGELOG.md
|
|
@ -1,5 +1,24 @@
|
|||
## [0.1.0-dev] — en préparation, non publiée
|
||||
|
||||
### Connexion Web
|
||||
|
||||
- La session de `3600` secondes (une heure) commence à l'authentification, et non au démarrage
|
||||
du serveur ; une reconnexion après expiration renouvelle cookie et CSRF.
|
||||
- Régressions HTTP synthétiques : première connexion tardive, expiration
|
||||
absolue, ancien cookie refusé et échec de connexion sans prolongation.
|
||||
- Le parcours Firefox synthétique couvre désormais l'expiration pendant les
|
||||
pollings, l'arrêt unique des requêtes, la reconnexion sur le même serveur et
|
||||
le refus du couple cookie/CSRF précédent. L'interface ne rejoue aucune
|
||||
mutation refusée ou d'admission inconnue.
|
||||
|
||||
### Validation Web
|
||||
|
||||
- Le runner navigateur comptabilise `MISSING`, `LAUNCH_ERROR`, `EXIT_CODE`,
|
||||
`SIGNALED`, `TIMEOUT`, `INTERRUPTED` et `SUCCESS`. Sur Linux, il ne termine
|
||||
que le groupe et la session de processus qu'il a créés : `SIGTERM`, grâce
|
||||
bornée, puis `SIGKILL` si nécessaire. Ses régressions synthétiques vérifient
|
||||
qu'un témoin indépendant survit.
|
||||
|
||||
### Pivots locaux J7
|
||||
|
||||
- Index C typé des observations persistées, rapprochements explicables
|
||||
|
|
@ -167,6 +186,17 @@
|
|||
|
||||
# Changements locaux non publiés — poste Web UX V1
|
||||
|
||||
## Poste Web local et bibliothèque (non publié)
|
||||
|
||||
- Lanceur local à instance unique, bibliothèque explicitement choisie,
|
||||
création C idempotente et ouverture d'une seule enquête active par instance.
|
||||
- État d'instance, configuration et code d'amorçage séparés dans les répertoires
|
||||
XDG privés ; écoute loopback et contrôles de santé avant `status`, `stop` ou
|
||||
consultation du code.
|
||||
- Validation Web intégrée : bridges C, unités Node, découverte Python et
|
||||
parcours navigateur exécutés séquentiellement par
|
||||
`make web-workspace-web-check`.
|
||||
|
||||
- Réorganisation graph-first en barre compacte, projections, inspecteur à
|
||||
onglets et tiroir Tâches/Actions/Plans/Rapports responsive.
|
||||
- Ajout d’actions ancrées près des nœuds, avec clic droit, `Maj+F10`, fermeture
|
||||
|
|
|
|||
33
Makefile
33
Makefile
|
|
@ -17,8 +17,30 @@ SOURCE_SIZE_EXCEPTIONS := \
|
|||
src/views/main_window.c:2239 \
|
||||
src/views/create_relation_dialog.c:2043
|
||||
|
||||
.PHONY: check-source-size web-workspace web-workspace-j7 web-workspace-j8 web-workspace-j9 web-workspace-local web-workspace-review-check
|
||||
.PHONY: check-source-size web web-start web-status web-stop web-code web-workspace web-workspace-j7 web-workspace-j8 web-workspace-j9 web-workspace-local web-workspace-review-check web-workspace-web-check
|
||||
.NOTPARALLEL: web-workspace-review-check
|
||||
|
||||
# CONTRACT: le poste Web possède sa bibliothèque explicite, séparée des
|
||||
# enquêtes existantes. XDG_DATA_HOME est évalué par Python afin que Make ne
|
||||
# propage pas un chemin personnel figé dans le dépôt.
|
||||
PORT ?= 8081
|
||||
LIBRARY ?= $(shell python3 -c 'import os; from pathlib import Path; print(Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "labfy-investigation" / "web-8081-lab")')
|
||||
|
||||
web: tools/local-jobs
|
||||
tools/labfy-web serve --port "$(PORT)" --library "$(LIBRARY)"
|
||||
|
||||
web-start: tools/local-jobs
|
||||
tools/labfy-web start --port "$(PORT)" --library "$(LIBRARY)"
|
||||
|
||||
web-status:
|
||||
tools/labfy-web status
|
||||
|
||||
web-stop:
|
||||
tools/labfy-web stop
|
||||
|
||||
web-code:
|
||||
tools/labfy-web code
|
||||
|
||||
check-source-size:
|
||||
@limit=$(SOURCE_SIZE_LIMIT); failed=0; \
|
||||
files="$$(git ls-files --cached --others --exclude-standard -- \
|
||||
|
|
@ -72,6 +94,15 @@ web-workspace-review-check: tools/local-jobs tools/local-jobs-test
|
|||
@echo "Phase 2/2 — parcours navigateur de l'espace de travail local"
|
||||
node prototypes/web-graph/tests/test_local_workspace_import_browser.mjs
|
||||
|
||||
# CONTRACT: construit les bridges C avant les validations Web qui les invoquent.
|
||||
web-workspace-web-check: $(LOCAL_JOBS) $(LOCAL_JOBS_TEST) $(CORE_GRAPH_DEMO) $(EML_GRAPH_DEMO) $(LOCAL_TOOLKIT_DEMO)
|
||||
@echo "Phase 1/3 — unité Node du graphe Web"
|
||||
cd prototypes/web-graph && npm test
|
||||
@echo "Phase 2/3 — découverte Python du Web"
|
||||
python3 -m unittest discover -s prototypes/web-graph/tests -p 'test_*.py'
|
||||
@echo "Phase 3/3 — parcours navigateur Web séquentiels"
|
||||
cd prototypes/web-graph && npm run test:browser
|
||||
|
||||
CFLAGS = -std=c17 \
|
||||
-Wall \
|
||||
-Wextra \
|
||||
|
|
|
|||
30
README.md
30
README.md
|
|
@ -10,10 +10,11 @@ Le premier lot J7 ajoute un index C recalculable et des rapprochements locaux
|
|||
explicables pour les e-mails, domaines et IP observés :
|
||||
[pivots locaux et corrélation](docs/architecture/LOCAL_PIVOTS_CORRELATION.md).
|
||||
|
||||
Le [poste Web local](docs/ui/WEB_WORKBENCH.md) rassemble désormais le graphe,
|
||||
l’inspecteur contextuel, les tâches, le planner J8 et les rapports J9 dans une
|
||||
surface `SPECIMEN` responsive. Cette disponibilité locale ne change ni le
|
||||
statut de production, ni l’interdiction d’utiliser une enquête réelle.
|
||||
Le [poste Web local](docs/ui/WEB_WORKBENCH.md) rassemble désormais une
|
||||
bibliothèque d’enquêtes explicitement choisie, le graphe, l’inspecteur
|
||||
contextuel, les tâches, le planner J8 et les rapports J9. Cette disponibilité
|
||||
locale ne change ni le statut de production, ni l’interdiction d’utiliser une
|
||||
enquête réelle pendant les validations de développement.
|
||||
|
||||
Le parcours local expérimental d’import et de revue est détaillé dans
|
||||
[le contrat d’espace local](docs/architecture/LOCAL_WORKSPACE_IMPORT.md) :
|
||||
|
|
@ -66,10 +67,10 @@ données. Labfy vise à relier leurs résultats dans une enquête cohérente :
|
|||
La cible de développement est **v0.1.0**. Le projet reste en développement actif
|
||||
et n'est pas prêt pour un usage opérationnel en production.
|
||||
|
||||
**Interface GTK conservée ; poste Web local `SPECIMEN` disponible.** La
|
||||
fondation v0.1.0 reste en développement : le poste Web réutilise le moteur de
|
||||
jobs persistant existant, sans constituer une interface de production ni une
|
||||
autonomie générale.
|
||||
**Interface GTK conservée ; poste Web local contrôlé disponible.** La fondation
|
||||
v0.1.0 reste en développement : le poste Web réutilise le moteur de jobs
|
||||
persistant existant et une bibliothèque locale explicitement choisie, sans
|
||||
constituer une interface de production ni une autonomie générale.
|
||||
|
||||
### Disponible aujourd'hui
|
||||
|
||||
|
|
@ -295,6 +296,19 @@ python3 run_core_demo.py --port 8765
|
|||
Le projet exige C17, GTK4, GLib/GIO, SQLite, libheif et Poppler GLib. Plusieurs
|
||||
outils d'analyse sont optionnels et ne sont jamais installés automatiquement.
|
||||
|
||||
Le poste Web local se lance depuis une bibliothèque explicitement désignée ;
|
||||
il écoute par défaut sur `127.0.0.1:8081` :
|
||||
|
||||
```bash
|
||||
make web PORT=8081 LIBRARY=/chemin/vers/la-bibliotheque-locale
|
||||
```
|
||||
|
||||
Les commandes `start`, `status`, `stop` et `code` de ce lanceur, ainsi que les
|
||||
contrats de stockage local et de session, sont décrits dans
|
||||
[le contrôle du poste Web](docs/architecture/WEB_WORKSPACE_CONTROL.md). Le
|
||||
code éphémère affiché au démarrage reste privé : il ne doit ni être ajouté à une
|
||||
commande, ni enregistré dans la documentation.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Index documentaire](docs/README.md)
|
||||
|
|
|
|||
|
|
@ -169,6 +169,11 @@ Le socle comprend déjà :
|
|||
extraction et observations `proposed` sont publiés atomiquement en V20, puis
|
||||
projetés en snapshot v3 sans reconstruction dans le pont Web ; voir
|
||||
[EML_ANALYSIS_TO_GRAPH.md](architecture/EML_ANALYSIS_TO_GRAPH.md).
|
||||
- poste Web local contrôlé : une instance loopback possède une bibliothèque
|
||||
explicitement choisie et ne rend active qu'une enquête à la fois. Le bridge
|
||||
C reste le créateur des enquêtes, le lecteur des projections et l'unique
|
||||
accès aux stockages métier ; voir
|
||||
[WEB_WORKSPACE_CONTROL.md](architecture/WEB_WORKSPACE_CONTROL.md).
|
||||
|
||||
Ces composants doivent être réutilisés ou migrés. La direction V2 n'autorise
|
||||
pas une réécriture générale du cœur fonctionnel.
|
||||
|
|
@ -591,9 +596,20 @@ analyse approuvée et rapport.
|
|||
## 17. Espace local, revue et projections — CURRENT expérimental
|
||||
|
||||
Le parcours local est limité à une instance loopback et à des validations sur
|
||||
fixtures `SPECIMEN`. Il ne change pas le statut de la surface Web : GTK reste
|
||||
le parcours fonctionnel actuel et le serveur, son packaging ainsi que le choix
|
||||
du renderer de production restent `UNDECIDED`.
|
||||
fixtures `SPECIMEN`. Son lanceur peut ouvrir une bibliothèque locale
|
||||
explicitement choisie, en créer les enquêtes par le bridge C et n'en active
|
||||
qu'une seule dans une instance. Il ne rouvre pas implicitement l'enquête d'une
|
||||
session précédente. Cette surface ne change pas son statut : GTK reste le
|
||||
parcours fonctionnel actuel et le serveur, son packaging ainsi que le choix du
|
||||
renderer de production restent `UNDECIDED`.
|
||||
|
||||
Le runtime métier demeure V20 et le JobStore opérationnel demeure V3 ; le
|
||||
poste Web ne migre ni ne modifie la tranche financière V21. L'état d'instance
|
||||
et le secret d'amorçage sont séparés dans le runtime privé XDG (avec un repli
|
||||
privé propre à l'UID lorsque `XDG_RUNTIME_DIR` est absent) ; la configuration
|
||||
durable est placée dans l'état XDG privé. La bibliothèque est une destination
|
||||
locale fournie explicitement au lancement : elle n'est pas déduite d'une
|
||||
dernière enquête ouverte.
|
||||
|
||||
Après la publication d'une preuve V20, le bridge Web demande au cœur C un
|
||||
aperçu EML, PNG ou JPEG. L'original, son chemin et son répertoire ne sont jamais
|
||||
|
|
|
|||
|
|
@ -131,3 +131,10 @@ vérifiée avec `command -v`, puis avec son option de version (`--version` ou
|
|||
L’import d’espace local n’ajoute aucune dépendance : le transport borné utilise
|
||||
la bibliothèque HTTP Python existante, et la publication/empreinte réutilise le
|
||||
cœur C/GLib. Firefox et `puppeteer-core` restent nécessaires au scénario Web.
|
||||
|
||||
Le poste Web local requiert également l'interpréteur `python3` déjà utilisé par
|
||||
les lanceurs du prototype. Il ne requiert ni serveur HTTP système, ni base de
|
||||
données distincte, ni installation automatique. Ses fichiers d'instance et de
|
||||
configuration suivent XDG (`XDG_RUNTIME_DIR`, `XDG_STATE_HOME`) ; la
|
||||
bibliothèque d'enquêtes est une destination locale explicitement fournie par
|
||||
`LIBRARY`, et non une dépendance à installer.
|
||||
|
|
|
|||
|
|
@ -16,8 +16,12 @@ déjà disponible.
|
|||
- [architecture de la base](database/DATABASE_ARCHITECTURE.md) et
|
||||
[audit courant du schéma](database/SCHEMA_AUDIT_CURRENT.md) ;
|
||||
- [test manuel du pivot EML](testing/EML_PIVOT_MANUAL_TEST.md).
|
||||
- [poste Web local contrôlé](architecture/WEB_WORKSPACE_CONTROL.md) et
|
||||
[guide du poste Web](ui/WEB_WORKBENCH.md).
|
||||
|
||||
Les guides GTK restent applicables tant que l'interface actuelle est maintenue.
|
||||
Le poste Web local `CURRENT` ne retire donc aucun parcours GTK ni ne décide le
|
||||
serveur, le renderer ou le packaging de production.
|
||||
|
||||
## Direction v0.1.0 — TARGET
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,77 @@
|
|||
# Poste de travail Web contrôlé — J6 local
|
||||
|
||||
> État : `CURRENT` pour le seul espace `SPECIMEN` produit par le lanceur J6.
|
||||
> Runtime métier : V20 inchangé. JobStore : V1 inchangé. V21 reste indépendante.
|
||||
> État : `CURRENT` pour le poste Web local contrôlé et sa bibliothèque
|
||||
> explicitement choisie. Runtime métier : V20 inchangé. JobStore : V3 inchangé.
|
||||
> V21 reste indépendante.
|
||||
|
||||
## Parcours
|
||||
|
||||
Une commande prépare ou reprend un espace synthétique, lance l’API loopback et
|
||||
son worker possédé, puis affiche l’URL et un code de session éphémère :
|
||||
La commande principale lance l’API loopback sur une bibliothèque locale
|
||||
explicitement choisie. Elle affiche l’URL et un code de session éphémère :
|
||||
|
||||
```sh
|
||||
make -j8 tools/local-jobs
|
||||
make web-workspace WORKSPACE=/tmp/labfy-j6-specimen
|
||||
make web PORT=8081 LIBRARY=/chemin/vers/la-bibliotheque-locale
|
||||
```
|
||||
|
||||
L’utilisateur ouvre l’URL, saisit le code, sélectionne l’EML ou l’image dans le
|
||||
graphe et choisit « Analyser les en-têtes » ou « Examiner les métadonnées ».
|
||||
L’intention est persistée avant la réponse HTTP `202`. Le worker C exécute le
|
||||
service J3/J4 correspondant, puis C republie atomiquement les snapshots jobs et
|
||||
métier. Le navigateur les relit sans rechargement de page. Pause, reprise, stop
|
||||
et annulation sont disponibles dans le panneau Jobs.
|
||||
L'instance écoute sur `127.0.0.1:8081` dans cet exemple. Après authentification,
|
||||
la bibliothèque permet de créer une enquête locale par le bridge C ou d'ouvrir
|
||||
une enquête enregistrée. Une seule enquête peut être active par instance ; une
|
||||
autre ouverture exige le redémarrage du poste local. L'utilisateur sélectionne
|
||||
ensuite l’EML ou l’image dans le graphe et choisit « Analyser les en-têtes » ou
|
||||
« Examiner les métadonnées ». L’intention est persistée avant la réponse HTTP
|
||||
`202`. Le worker C exécute le service J3/J4 correspondant, puis C republie
|
||||
atomiquement les snapshots jobs et métier. Le navigateur les relit sans
|
||||
rechargement de page. Pause, reprise, stop et annulation sont disponibles dans
|
||||
le panneau Jobs.
|
||||
|
||||
Le lanceur gère une seule instance authentifiée. Ses sous-commandes sont :
|
||||
|
||||
```sh
|
||||
python3 prototypes/web-graph/web_app.py start --library /chemin/vers/la-bibliotheque-locale --port 8081
|
||||
python3 prototypes/web-graph/web_app.py status
|
||||
python3 prototypes/web-graph/web_app.py stop
|
||||
python3 prototypes/web-graph/web_app.py code
|
||||
```
|
||||
|
||||
`start` refuse une seconde instance active. `status`, `stop` et `code` valident
|
||||
l'identité du processus et l'état de santé loopback avant d'agir ou de révéler
|
||||
le code éphémère. Ne pas consigner ce code dans des scripts, URLs, journaux ou
|
||||
documents. Le mode `--workspace` reste le parcours ciblé existant ; il ne crée
|
||||
pas une bibliothèque implicite.
|
||||
|
||||
## Bibliothèque, état XDG et limites
|
||||
|
||||
La bibliothèque est privée (`0700`), refuse les liens symboliques et conserve
|
||||
un registre atomique borné à 256 enquêtes. Chaque enquête est un enfant direct
|
||||
identifié par UUID ; le bridge C existant est le seul créateur de son runtime
|
||||
V20 et de son JobStore V3. La création est idempotente pour une même clé et le
|
||||
même titre ; réemployer la clé avec un autre titre est refusé. L'ouverture exige
|
||||
la génération courante du registre afin de ne pas utiliser une projection
|
||||
périmée.
|
||||
|
||||
L'instance place ses verrous, identité et code d'amorçage dans
|
||||
`$XDG_RUNTIME_DIR/labfy-investigation-web/`, ou dans un repli privé par UID
|
||||
sous `/tmp` si `XDG_RUNTIME_DIR` n'est pas défini. Sa configuration durable est
|
||||
dans `$XDG_STATE_HOME/labfy-investigation-web/` (ou le repli XDG usuel). Ces
|
||||
fichiers ne sont ni des données métier, ni une bibliothèque : la destination
|
||||
des enquêtes reste l'argument explicite `LIBRARY`.
|
||||
|
||||
La session a une durée maximale de `3600` secondes (une heure) à compter de chaque connexion
|
||||
réussie, indépendamment de l'heure de démarrage du serveur. Après expiration,
|
||||
le code de l'instance permet une nouvelle authentification : les secrets de
|
||||
session et CSRF sont renouvelés et l'ancien cookie reste invalide. Un code
|
||||
incorrect ou une origine refusée ne renouvelle jamais cette durée.
|
||||
Le premier refus `session_required` reçu par le navigateur arrête ses flux et
|
||||
pollings, invalide les secrets seulement en mémoire et présente une reconnexion
|
||||
explicite. Il ne redirige pas en boucle et ne rejoue ni une mutation refusée ni
|
||||
une admission dont l'issue est inconnue. Les brouillons autorisés restent dans
|
||||
la session du navigateur, tandis que les jobs déjà admis conservent leurs
|
||||
budgets et leur cycle de vie propres.
|
||||
Les limites HTTP et opérationnelles
|
||||
restent opposables : corps JSON de commande de 4 Kio, huit fichiers sélectionnés
|
||||
pour 16 Mio, 4 Mio par réception, 64 Mio de staging, deux réceptions actives et
|
||||
deux générations de rapport simultanées. Une limite, une annulation ou une
|
||||
erreur d'export est un état explicite et ne prouve jamais l'absence de résultat.
|
||||
|
||||
## Décision HTTP et bridge
|
||||
|
||||
|
|
@ -55,7 +108,7 @@ Le serveur écoute exclusivement sur l’autorité annoncée
|
|||
`127.0.0.1:<port>`. Le code d’amorçage et les secrets de session proviennent de
|
||||
`secrets`, ne sont pas placés dans une URL et ne sont pas journalisés par les
|
||||
routes. Après amorçage, le cookie est host-only, `HttpOnly`, `SameSite=Strict`,
|
||||
`Path=/`, limité à une heure. Il n’a pas l’attribut `Secure`, puisque ce mode
|
||||
`Path=/`, avec `Max-Age=3600` secondes (une heure). Il n’a pas l’attribut `Secure`, puisque ce mode
|
||||
testé utilise HTTP loopback et ne revendique pas TLS.
|
||||
|
||||
Lectures opérationnelles et mutations exigent la session. Les mutations exigent
|
||||
|
|
@ -109,8 +162,25 @@ zoom, filtres, repli et positions épinglées restent dans l’état du navigate
|
|||
|
||||
## Limites
|
||||
|
||||
Ce poste est une fondation locale synthétique : pas d’enquête réelle, LAN,
|
||||
TLS, reverse proxy, service permanent, autonomie réseau, recherche OSINT,
|
||||
worker distribué ou mutation de la V21. Les anciens modes J2–J5 restent
|
||||
strictement read-only. `ThreadingHTTPServer` n’est pas présenté comme un serveur
|
||||
Internet ou une frontière de sécurité de production.
|
||||
Ce poste est une fondation locale contrôlée : les validations automatisées
|
||||
emploient exclusivement des fixtures `SPECIMEN`. Il n'autorise ni enquête
|
||||
réelle, ni LAN, TLS, reverse proxy, service permanent, autonomie réseau,
|
||||
recherche OSINT, worker distribué ou mutation de la V21. Les anciens modes
|
||||
J2–J5 restent strictement read-only. `ThreadingHTTPServer` n’est pas présenté
|
||||
comme un serveur Internet ou une frontière de sécurité de production.
|
||||
|
||||
## Validation Web
|
||||
|
||||
La cible `make web-workspace-web-check` construit d'abord les bridges C requis,
|
||||
puis exécute les unités Node, la découverte Python et les parcours navigateur
|
||||
séquentiels. Le runner navigateur classe chaque scénario avec l’un des statuts
|
||||
`MISSING`, `LAUNCH_ERROR`, `EXIT_CODE`, `SIGNALED`, `TIMEOUT`, `INTERRUPTED`
|
||||
ou `SUCCESS`. Sur Linux, il crée et possède le groupe et la session de
|
||||
processus de chaque scénario ; son nettoyage est limité à ce groupe/session et
|
||||
envoie `SIGTERM`, attend une grâce bornée, puis escalade avec `SIGKILL` si
|
||||
nécessaire. Il ne recherche ni ne termine des processus utilisateur.
|
||||
Les régressions emploient un Firefox, des profils, ports, XDG et bibliothèques
|
||||
`SPECIMEN` isolés, notamment pour la connexion tardive, l'expiration et la
|
||||
reconnexion sans redémarrer le serveur. Elles ne constituent pas à elles seules
|
||||
une certification de sécurité, de production, de navigateur ou de données non
|
||||
synthétiques.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
# Poste Web local — guide d’utilisation
|
||||
|
||||
> **Statut CURRENT local :** poste utilisable sur une enquête `SPECIMEN`, au-dessus
|
||||
> des services C et du JobStore V3 existants. Il ne constitue pas une autorisation
|
||||
> d’ouvrir une enquête réelle ni un service distant.
|
||||
> **Statut CURRENT local :** poste utilisable au-dessus des services C et du
|
||||
> JobStore V3 existants, depuis une bibliothèque d'enquêtes explicitement
|
||||
> choisie. Les validations emploient des fixtures `SPECIMEN`. Il ne constitue
|
||||
> ni une autorisation d’ouvrir une enquête réelle, ni un service distant.
|
||||
|
||||
## Organisation
|
||||
|
||||
À l'ouverture, la bibliothèque affiche les enquêtes locales enregistrées et
|
||||
permet d'en créer une avec un titre explicite. Une enquête ouverte reste la
|
||||
seule enquête active de cette instance ; le poste n'en restaure pas une autre
|
||||
automatiquement. Le titre et la génération de la bibliothèque identifient le
|
||||
contexte affiché, afin qu'une réponse tardive d'une enquête précédente ne le
|
||||
repeuple pas.
|
||||
|
||||
Le graphe reste la surface principale. La barre haute porte l’identité de
|
||||
l’enquête, le statut local et la recherche. La rangée suivante change de
|
||||
projection (`Réseau`, `Preuves`, `Chronologie`, `Infrastructure locale`) sans
|
||||
|
|
@ -82,16 +90,18 @@ Une date sans fuseau ou invalide reste non positionnable.
|
|||
|
||||
## Lancement et limites
|
||||
|
||||
Créer exclusivement une fixture synthétique dans un répertoire temporaire :
|
||||
Lancer le poste local sur une bibliothèque explicitement choisie :
|
||||
|
||||
```bash
|
||||
make -j8 tools/local-jobs
|
||||
tmp_workspace=$(mktemp -d /tmp/labfy-workbench-specimen-XXXXXX)
|
||||
tools/local-jobs init-j7-specimen --workspace "$tmp_workspace"
|
||||
make web-workspace-j9 WORKSPACE="$tmp_workspace"
|
||||
make web PORT=8081 LIBRARY=/chemin/vers/la-bibliotheque-locale
|
||||
```
|
||||
|
||||
L’écoute reste loopback, la session est authentifiée et les mutations exigent
|
||||
le jeton CSRF. Le poste n’expose pas le répertoire des preuves et n’installe
|
||||
aucun outil. Les projections vides, erreurs, annulations et limites sont des
|
||||
états explicites ; elles ne prouvent jamais une absence d’information.
|
||||
|
||||
Pour les validations, utiliser seulement une bibliothèque temporaire et des
|
||||
enquêtes `SPECIMEN`. Les commandes d'exploitation `start`, `status`, `stop` et
|
||||
`code`, les répertoires XDG privés et les plafonds de réception sont documentés
|
||||
dans [le contrat du poste Web](../architecture/WEB_WORKSPACE_CONTROL.md).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test tests/test_graph_state.mjs",
|
||||
"test:browser": "node tests/test_browser.mjs && node tests/test_core_browser.mjs && node tests/test_eml_browser.mjs && node tests/test_local_toolkit_browser.mjs && node tests/test_workspace_control_browser.mjs && node tests/test_j8_browser.mjs && node tests/test_j8_runtime_browser.mjs && node tests/test_j9_browser.mjs && node tests/test_workbench_browser.mjs && node tests/test_workbench_populated_browser.mjs && node tests/test_local_workspace_import_browser.mjs"
|
||||
"test:browser": "node tests/run_browser_suite.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"puppeteer-core": "24.28.0"
|
||||
|
|
|
|||
516
prototypes/web-graph/public/app.js
vendored
516
prototypes/web-graph/public/app.js
vendored
|
|
@ -118,7 +118,7 @@ let reconnectCount = 0;
|
|||
let operationalMode = false;
|
||||
let csrfToken = null;
|
||||
let workspaceReady = false;
|
||||
let workspaceMode = "specimen";
|
||||
let libraryMode = false;
|
||||
const preparedUploads = new Map();
|
||||
let jobsRefreshing = false;
|
||||
let graphRefreshing = false;
|
||||
|
|
@ -135,6 +135,109 @@ let previewSequence = 0;
|
|||
let reportDraftKey = "labfy-report-draft:unbound";
|
||||
let evidenceOpenSequence = 0;
|
||||
let openedEvidenceId = null;
|
||||
let libraryGeneration = null;
|
||||
let pendingCreateIntent = null;
|
||||
let workspaceContext = null;
|
||||
let workspaceAbortController = null;
|
||||
const refreshTimers = new Set();
|
||||
let sessionExpired = false;
|
||||
|
||||
function invalidateWorkspaceContext() {
|
||||
workspaceAbortController?.abort();
|
||||
workspaceAbortController = null;
|
||||
workspaceContext = null;
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
for (const timer of refreshTimers) clearInterval(timer);
|
||||
refreshTimers.clear();
|
||||
evidenceOpenSequence += 1;
|
||||
previewSequence += 1;
|
||||
openedEvidenceId = null;
|
||||
jobsRefreshing = false;
|
||||
graphRefreshing = false;
|
||||
correlationsRefreshing = false;
|
||||
}
|
||||
|
||||
function beginWorkspaceContext(workspaceId, generation) {
|
||||
invalidateWorkspaceContext();
|
||||
workspaceAbortController = new AbortController();
|
||||
workspaceContext = Object.freeze({ workspaceId, generation });
|
||||
return workspaceContext;
|
||||
}
|
||||
|
||||
function expireAuthenticatedSession() {
|
||||
if (sessionExpired || !operationalMode) return;
|
||||
sessionExpired = true;
|
||||
// CONTRACT: le premier 401 authentifié ferme tous les producteurs de données
|
||||
// et invalide les secrets en mémoire. Aucun appel refusé n'est rejoué.
|
||||
invalidateWorkspaceContext();
|
||||
csrfToken = null;
|
||||
operationalMode = false;
|
||||
libraryMode = false;
|
||||
workspaceReady = false;
|
||||
libraryGeneration = null;
|
||||
pendingCreateIntent = null;
|
||||
preparedUploads.clear();
|
||||
for (const dialog of document.querySelectorAll("dialog[open]")) dialog.close();
|
||||
byId("node-context-menu").hidden = true;
|
||||
byId("workbench-shell").hidden = true;
|
||||
byId("library-home").hidden = false;
|
||||
byId("workspace-create").hidden = true;
|
||||
byId("library-list-section").hidden = true;
|
||||
byId("library-title").textContent = "Session locale expirée";
|
||||
byId("library-connection").textContent =
|
||||
"Le travail en cours a été arrêté. Reconnectez-vous pour reprendre ; aucune action refusée n’a été relancée.";
|
||||
let reconnect = byId("session-reconnect");
|
||||
if (!reconnect) {
|
||||
reconnect = document.createElement("a");
|
||||
reconnect.id = "session-reconnect";
|
||||
reconnect.href = "/";
|
||||
reconnect.textContent = "Se reconnecter";
|
||||
byId("library-connection").insertAdjacentElement("afterend", reconnect);
|
||||
}
|
||||
reconnect.focus();
|
||||
}
|
||||
|
||||
async function detectExpiredSession(response) {
|
||||
if (!operationalMode || response.status !== 401) return false;
|
||||
let value = null;
|
||||
try { value = await response.clone().json(); } catch (_) { return false; }
|
||||
if (value?.error !== "session_required") return false;
|
||||
expireAuthenticatedSession();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function workspaceFetch(path, options = {}) {
|
||||
const context = workspaceContext;
|
||||
const controller = workspaceAbortController;
|
||||
if (!context || !controller) throw new DOMException("Contexte fermé", "AbortError");
|
||||
const response = await fetch(path, { ...options, signal: controller.signal });
|
||||
if (await detectExpiredSession(response))
|
||||
throw new DOMException("Session expirée", "AbortError");
|
||||
// INVARIANT: une réponse appartient au couple espace/génération qui l'a
|
||||
// demandée ; elle ne peut jamais repeupler l'espace ouvert ensuite.
|
||||
if (context !== workspaceContext) throw new DOMException("Contexte remplacé", "AbortError");
|
||||
Object.defineProperty(response, "_labfyWorkspaceContext", { value: context });
|
||||
return response;
|
||||
}
|
||||
|
||||
async function workspaceJson(response) {
|
||||
const value = await response.json();
|
||||
if (response._labfyWorkspaceContext !== workspaceContext)
|
||||
throw new DOMException("Contexte remplacé", "AbortError");
|
||||
return value;
|
||||
}
|
||||
|
||||
async function responseJson(response, fallback) {
|
||||
let value;
|
||||
try {
|
||||
value = await response.json();
|
||||
} catch (_) {
|
||||
throw new Error(fallback);
|
||||
}
|
||||
if (!response.ok) throw new Error(value.message ?? fallback);
|
||||
return value;
|
||||
}
|
||||
|
||||
function saveReportDraft() {
|
||||
if (!operationalMode) return;
|
||||
|
|
@ -153,7 +256,7 @@ function restoreReportDraft() {
|
|||
try {
|
||||
const draft = JSON.parse(sessionStorage.getItem(reportDraftKey) ?? "null");
|
||||
if (!draft) return;
|
||||
byId("report-name").value = draft.title ?? "Rapport SPECIMEN";
|
||||
byId("report-name").value = draft.title ?? "Rapport d’enquête";
|
||||
byId("report-comment").value = draft.comment ?? "";
|
||||
for (const input of document.querySelectorAll('[name="report-section"]'))
|
||||
input.checked = draft.sections?.includes(input.value) ?? input.checked;
|
||||
|
|
@ -176,19 +279,19 @@ function invalidateReportPreview(
|
|||
}
|
||||
|
||||
async function postCommand(path, value = {}) {
|
||||
const response = await fetch(path, {
|
||||
const response = await workspaceFetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Labfy-CSRF": csrfToken },
|
||||
body: JSON.stringify(value),
|
||||
});
|
||||
const result = await response.json();
|
||||
const result = await workspaceJson(response);
|
||||
if (!response.ok) throw new Error(result.message ?? "Commande refusée");
|
||||
return result;
|
||||
}
|
||||
|
||||
async function evidenceJson(path) {
|
||||
const response = await fetch(path, { cache: "no-store" });
|
||||
const value = await response.json();
|
||||
const response = await workspaceFetch(path, { cache: "no-store" });
|
||||
const value = await workspaceJson(response);
|
||||
if (!response.ok) throw new Error(value.message ?? "Preuve indisponible");
|
||||
return value;
|
||||
}
|
||||
|
|
@ -307,14 +410,14 @@ async function openEvidence(evidenceId) {
|
|||
byId("evidence-status").textContent =
|
||||
`Intégrité vérifiée · cache ${preview.cache_id.slice(0, 12)} · aperçu ${preview.renderer_version}`;
|
||||
} catch (error) {
|
||||
if (sequence === evidenceOpenSequence)
|
||||
if (error.name !== "AbortError" && sequence === evidenceOpenSequence)
|
||||
byId("evidence-status").textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshGraphAfterImport() {
|
||||
const response = await fetch("/api/v1/snapshot", { cache: "no-store" });
|
||||
const value = await response.json();
|
||||
const response = await workspaceFetch("/api/v1/snapshot", { cache: "no-store" });
|
||||
const value = await workspaceJson(response);
|
||||
if (!response.ok) throw new Error(value.message ?? "Projection indisponible");
|
||||
snapshot = prepareSnapshot(value); coreMode = true; render();
|
||||
await refreshPlanner();
|
||||
|
|
@ -330,11 +433,11 @@ async function receiveFile(file, selectionId) {
|
|||
declared_type: file.type || "application/octet-stream",
|
||||
selection_id: selectionId,
|
||||
});
|
||||
const response = await fetch(`/api/v1/uploads/${intention.upload_id}`, {
|
||||
const response = await workspaceFetch(`/api/v1/uploads/${intention.upload_id}`, {
|
||||
method: "PUT", headers: {"Content-Type":"application/octet-stream",
|
||||
"X-Labfy-CSRF":csrfToken}, body:file,
|
||||
});
|
||||
const prepared = await response.json();
|
||||
const prepared = await workspaceJson(response);
|
||||
if (!response.ok) throw new Error(prepared.message ?? "Réception refusée");
|
||||
preparedUploads.set(prepared.upload_id, prepared);
|
||||
item.textContent = `${prepared.name} — ${prepared.expected_size} octets — ${prepared.recognized_type} — préparé`;
|
||||
|
|
@ -374,9 +477,9 @@ async function refreshJobs() {
|
|||
if (jobsRefreshing) return;
|
||||
jobsRefreshing = true;
|
||||
try {
|
||||
const response = await fetch("/api/v1/jobs", { cache: "no-store" });
|
||||
const response = await workspaceFetch("/api/v1/jobs", { cache: "no-store" });
|
||||
if (!response.ok) return;
|
||||
const value = await response.json();
|
||||
const value = await workspaceJson(response);
|
||||
if (
|
||||
value.contract !== "labfy.local_jobs.snapshot.v1" ||
|
||||
!Array.isArray(value.jobs)
|
||||
|
|
@ -449,8 +552,8 @@ async function refreshOperationalGraph() {
|
|||
if (!operationalMode || graphRefreshing) return;
|
||||
graphRefreshing = true;
|
||||
try {
|
||||
const response = await fetch("/api/v1/snapshot", { cache: "no-store" });
|
||||
const value = await response.json();
|
||||
const response = await workspaceFetch("/api/v1/snapshot", { cache: "no-store" });
|
||||
const value = await workspaceJson(response);
|
||||
if (!response.ok) throw new Error(value.message ?? "export indisponible");
|
||||
const fingerprint = snapshotFingerprint(value);
|
||||
if (fingerprint !== acceptedSnapshotFingerprint) {
|
||||
|
|
@ -492,7 +595,8 @@ async function refreshOperationalGraph() {
|
|||
"Poste local synchronisé · sans changement";
|
||||
}
|
||||
} catch (error) {
|
||||
byId("connection").textContent = `Dernière vue valide · ${error.message}`;
|
||||
if (error.name !== "AbortError")
|
||||
byId("connection").textContent = `Dernière vue valide · ${error.message}`;
|
||||
} finally {
|
||||
graphRefreshing = false;
|
||||
}
|
||||
|
|
@ -502,8 +606,8 @@ async function refreshCorrelations() {
|
|||
if (!operationalMode || correlationsRefreshing) return;
|
||||
correlationsRefreshing = true;
|
||||
try {
|
||||
const response = await fetch("/api/v1/correlations", { cache: "no-store" });
|
||||
const value = await response.json();
|
||||
const response = await workspaceFetch("/api/v1/correlations", { cache: "no-store" });
|
||||
const value = await workspaceJson(response);
|
||||
if (
|
||||
!response.ok ||
|
||||
value.contract !== "labfy.local_correlation.snapshot.v1"
|
||||
|
|
@ -557,8 +661,9 @@ async function refreshCorrelations() {
|
|||
? "Aucun rapprochement calculé depuis les observations persistées."
|
||||
: `${value.groups.length} rapprochements calculés · ${value.complete ? "index complet" : "résultat borné"}`;
|
||||
} catch (error) {
|
||||
byId("correlations-note").textContent =
|
||||
`Index local indisponible · ${error.message}`;
|
||||
if (error.name !== "AbortError")
|
||||
byId("correlations-note").textContent =
|
||||
`Index local indisponible · ${error.message}`;
|
||||
} finally {
|
||||
correlationsRefreshing = false;
|
||||
}
|
||||
|
|
@ -567,8 +672,8 @@ async function refreshCorrelations() {
|
|||
async function refreshPlanner() {
|
||||
if (!operationalMode) return;
|
||||
try {
|
||||
const response = await fetch("/api/v1/planner", { cache: "no-store" });
|
||||
const value = await response.json();
|
||||
const response = await workspaceFetch("/api/v1/planner", { cache: "no-store" });
|
||||
const value = await workspaceJson(response);
|
||||
if (!response.ok || value.contract !== "labfy.local_planner.snapshot.v1")
|
||||
throw new Error(value.message ?? "planner indisponible");
|
||||
if (value.input_revision === plannerRevision) return;
|
||||
|
|
@ -623,8 +728,9 @@ async function refreshPlanner() {
|
|||
? "Gain d'information attendu : heuristique locale, jamais un score d'identité."
|
||||
: "Aucune action locale pertinente dans cette projection.";
|
||||
} catch (error) {
|
||||
byId("planner-note").textContent =
|
||||
`Planner indisponible · ${error.message}`;
|
||||
if (error.name !== "AbortError")
|
||||
byId("planner-note").textContent =
|
||||
`Planner indisponible · ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1023,10 +1129,10 @@ async function generateReport() {
|
|||
byId("report-status").textContent = "Génération locale en cours…";
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const response = await fetch(`/api/v1/reports/${admission.report_id}`, {
|
||||
const response = await workspaceFetch(`/api/v1/reports/${admission.report_id}`, {
|
||||
cache: "no-store",
|
||||
}),
|
||||
status = await response.json();
|
||||
status = await workspaceJson(response);
|
||||
if (status.state === "READY") {
|
||||
const box = byId("report-status");
|
||||
box.className = "report-ready";
|
||||
|
|
@ -1074,7 +1180,8 @@ async function runCapability(capability) {
|
|||
`[data-capability-id="${capability.id}"]`,
|
||||
);
|
||||
if (button) button.disabled = true;
|
||||
const storageKey = `labfy-intent:${node.object_id}:${capability.id}`;
|
||||
const storageKey = `labfy-intent:${workspaceContext.workspaceId}:` +
|
||||
`${workspaceContext.generation}:${node.object_id}:${capability.id}`;
|
||||
const key = sessionStorage.getItem(storageKey) ?? crypto.randomUUID();
|
||||
sessionStorage.setItem(storageKey, key);
|
||||
try {
|
||||
|
|
@ -1140,8 +1247,8 @@ function resetGlobalView() {
|
|||
async function resyncSnapshot(reason) {
|
||||
byId("connection").textContent = `Rattrapage snapshot — ${reason}`;
|
||||
try {
|
||||
const response = await fetch("/api/v1/snapshot?size=demo&revision=latest");
|
||||
const replacement = await response.json();
|
||||
const response = await workspaceFetch("/api/v1/snapshot?size=demo&revision=latest");
|
||||
const replacement = await workspaceJson(response);
|
||||
if (
|
||||
!response.ok ||
|
||||
replacement.contract !== "labfy.web_graph.snapshot.v1" ||
|
||||
|
|
@ -1199,7 +1306,7 @@ function connectEvents() {
|
|||
eventSource.onerror = () => {
|
||||
if (eventSource.readyState !== EventSource.CLOSED) {
|
||||
reconnectCount += 1;
|
||||
byId("connection").textContent = "Reconnexion au scénario synthétique…";
|
||||
byId("connection").textContent = "Reconnexion au flux…";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1282,7 +1389,7 @@ function configureControls() {
|
|||
candidate.id.toLocaleLowerCase("fr").includes(query),
|
||||
);
|
||||
if (!node) {
|
||||
byId("graph-state").textContent = "Aucun objet synthétique trouvé.";
|
||||
byId("graph-state").textContent = "Aucun objet trouvé.";
|
||||
return;
|
||||
}
|
||||
if (!projection.nodes.some((candidate) => candidate.id === node.id)) {
|
||||
|
|
@ -1439,73 +1546,253 @@ function configureGraphInteractions() {
|
|||
});
|
||||
}
|
||||
|
||||
async function start() {
|
||||
async function libraryRequest(path, options = {}) {
|
||||
const response = await fetch(path, options);
|
||||
if (await detectExpiredSession(response))
|
||||
throw new DOMException("Session expirée", "AbortError");
|
||||
return responseJson(response, "Bibliothèque locale indisponible");
|
||||
}
|
||||
|
||||
function renderLibrary(value) {
|
||||
if (value.contract !== "labfy.web_library.v1" ||
|
||||
!Array.isArray(value.entries) || !Number.isInteger(value.generation))
|
||||
throw new Error("Contrat de bibliothèque inattendu");
|
||||
libraryGeneration = value.generation;
|
||||
const list = byId("workspace-list");
|
||||
list.replaceChildren();
|
||||
for (const entry of value.entries) {
|
||||
const item = document.createElement("li");
|
||||
item.className = "workspace-card";
|
||||
const heading = document.createElement("h3");
|
||||
heading.textContent = entry.title;
|
||||
const state = document.createElement("p");
|
||||
const active = value.active_workspace_id === entry.workspace_id;
|
||||
const anotherActive = value.active_workspace_id !== null && !active;
|
||||
state.textContent = active ? "Ouverte dans cette session" : anotherActive
|
||||
? "Disponible après redémarrage du poste local"
|
||||
: entry.state === "READY" ? "Prête à ouvrir" : `État : ${entry.state}`;
|
||||
const open = document.createElement("button");
|
||||
open.type = "button";
|
||||
open.textContent = active ? "Revenir à l’enquête" : "Ouvrir l’enquête";
|
||||
open.disabled = entry.state !== "READY" || anotherActive;
|
||||
open.dataset.workspaceId = entry.workspace_id;
|
||||
open.addEventListener("click", () => void openWorkspace(entry.workspace_id, open));
|
||||
item.append(heading, state, open);
|
||||
list.append(item);
|
||||
}
|
||||
byId("library-empty").hidden = value.entries.length !== 0;
|
||||
byId("library-connection").textContent =
|
||||
`${value.entries.length} enquête(s) · génération ${value.generation}`;
|
||||
}
|
||||
|
||||
async function refreshLibrary() {
|
||||
const value = await libraryRequest("/api/v1/library", { cache: "no-store" });
|
||||
renderLibrary(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function showLibrary({ focus = false } = {}) {
|
||||
invalidateWorkspaceContext();
|
||||
for (const dialog of document.querySelectorAll("dialog[open]")) dialog.close();
|
||||
preparedUploads.clear();
|
||||
workspaceReady = false;
|
||||
byId("workbench-shell").hidden = true;
|
||||
byId("library-home").hidden = false;
|
||||
byId("workspace-create").hidden = false;
|
||||
byId("library-list-section").hidden = false;
|
||||
byId("library-title").textContent = "Bibliothèque d’enquêtes";
|
||||
byId("library-connection").textContent = "Actualisation…";
|
||||
try {
|
||||
const initialResponse = await fetch("/api/v1/snapshot", {
|
||||
cache: "no-store",
|
||||
});
|
||||
const initialValue = await initialResponse.json();
|
||||
snapshot = prepareSnapshot(initialValue);
|
||||
acceptedSnapshotFingerprint = snapshotFingerprint(initialValue);
|
||||
await refreshLibrary();
|
||||
} catch (error) {
|
||||
byId("library-connection").textContent = error.message;
|
||||
}
|
||||
if (focus) byId("library-title").focus();
|
||||
}
|
||||
|
||||
function resetWorkspacePresentation() {
|
||||
snapshot = prepareSnapshot({ contract: "labfy.web_graph.snapshot.v3", origin: "core",
|
||||
revision: 0, nodes: [], edges: [], capability_catalog: [], capabilities: [] });
|
||||
acceptedSnapshotFingerprint = snapshotFingerprint(snapshot);
|
||||
selectedNodeId = null;
|
||||
focusNodeId = null;
|
||||
navigationHistory = [];
|
||||
reportSelection.clear();
|
||||
reportPreview = null;
|
||||
correlationRevision = null;
|
||||
plannerRevision = null;
|
||||
plannerValue = null;
|
||||
preparedUploads.clear();
|
||||
pinnedPositions.clear();
|
||||
resetGlobalView();
|
||||
}
|
||||
|
||||
async function activateWorkspace(opened) {
|
||||
beginWorkspaceContext(opened.workspace_id, opened.generation);
|
||||
resetWorkspacePresentation();
|
||||
workspaceReady = true;
|
||||
byId("library-home").hidden = true;
|
||||
byId("workbench-shell").hidden = false;
|
||||
byId("home-button").hidden = false;
|
||||
byId("investigation-title").textContent = opened.title;
|
||||
byId("mode-badge").textContent = "Espace local · contrôles locaux";
|
||||
byId("open-import").disabled = false;
|
||||
byId("connection").textContent = "Ouverture…";
|
||||
reportDraftKey = `labfy-report-draft:${opened.workspace_id}:${opened.generation}`;
|
||||
try {
|
||||
const response = await workspaceFetch("/api/v1/snapshot", { cache: "no-store" });
|
||||
const value = await workspaceJson(response);
|
||||
if (!response.ok) throw new Error(value.message ?? "Projection indisponible");
|
||||
snapshot = prepareSnapshot(value);
|
||||
acceptedSnapshotFingerprint = snapshotFingerprint(value);
|
||||
coreMode = snapshot.origin === "core";
|
||||
} catch (_) {}
|
||||
scale = coreMode
|
||||
? fitGraphScale()
|
||||
: Math.min(1, Math.max(0.6, (svg.clientWidth - 160) / 700));
|
||||
syncTypeFilterOptions();
|
||||
try {
|
||||
const sessionResponse = await fetch("/api/v1/session", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (sessionResponse.ok) {
|
||||
const session = await sessionResponse.json();
|
||||
csrfToken = session.csrf;
|
||||
operationalMode = true;
|
||||
workspaceReady = session.workspace_state === "READY";
|
||||
byId("workspace-create").hidden = workspaceReady;
|
||||
byId("open-import").disabled = !workspaceReady;
|
||||
if (session.title) byId("investigation-title").textContent=session.title;
|
||||
workspaceMode = session.mode;
|
||||
byId("mode-badge").textContent = session.mode === "specimen"
|
||||
? "Mode SPECIMEN" : "Local expérimental";
|
||||
const investigation =
|
||||
snapshot.investigation?.id ?? snapshot.investigation_id ?? "unknown";
|
||||
reportDraftKey = `labfy-report-draft:${session.contract}:${session.origin}:${investigation}`;
|
||||
scale = fitGraphScale();
|
||||
syncTypeFilterOptions();
|
||||
restoreReportDraft();
|
||||
render();
|
||||
renderTimeline();
|
||||
renderReportSelection();
|
||||
byId("connection").textContent = `Ouverte · génération ${opened.generation}`;
|
||||
await Promise.all([refreshJobs(), refreshCorrelations(), refreshPlanner()]);
|
||||
for (const [callback, delay] of [[refreshJobs, 500],
|
||||
[refreshOperationalGraph, 700], [refreshCorrelations, 900], [refreshPlanner, 1100]])
|
||||
refreshTimers.add(setInterval(callback, delay));
|
||||
svg.focus();
|
||||
} catch (error) {
|
||||
if (error.name !== "AbortError") {
|
||||
byId("connection").textContent = `Ouverture incomplète · ${error.message}`;
|
||||
byId("graph-state").textContent = "La projection de cette enquête est indisponible.";
|
||||
}
|
||||
} catch (_) {}
|
||||
if (coreMode) {
|
||||
byId("mode-badge").textContent = workspaceMode === "specimen"
|
||||
? (operationalMode ? "Poste J6 · SPECIMEN · contrôles locaux" : "Snapshot du cœur C · SPECIMEN · lecture seule")
|
||||
: "Local expérimental · contrôles locaux";
|
||||
}
|
||||
}
|
||||
|
||||
async function activateLegacyWorkspace(session) {
|
||||
const workspaceId = session?.investigation_id ?? "legacy-read-only";
|
||||
const generation = Number.isInteger(session?.generation) ? session.generation : 0;
|
||||
beginWorkspaceContext(workspaceId, generation);
|
||||
resetWorkspacePresentation();
|
||||
workspaceReady = session?.workspace_state === "READY" || session === null;
|
||||
byId("library-home").hidden = true;
|
||||
byId("workbench-shell").hidden = false;
|
||||
byId("home-button").hidden = true;
|
||||
byId("investigation-title").textContent = session?.title ?? "Enquête locale";
|
||||
byId("open-import").disabled = !operationalMode || !workspaceReady;
|
||||
reportDraftKey = `labfy-report-draft:${workspaceId}:${generation}`;
|
||||
try {
|
||||
const response = await workspaceFetch("/api/v1/snapshot", { cache: "no-store" });
|
||||
const value = await workspaceJson(response);
|
||||
snapshot = prepareSnapshot(value);
|
||||
acceptedSnapshotFingerprint = snapshotFingerprint(value);
|
||||
coreMode = snapshot.origin === "core";
|
||||
byId("mode-badge").textContent = operationalMode
|
||||
? "Poste local · contrôles locaux"
|
||||
: coreMode ? "Snapshot du cœur C · lecture seule" : "Démonstration locale · lecture seule";
|
||||
byId("connection").textContent = snapshot._loadError
|
||||
? `Erreur d'export cœur · ${snapshot._loadError}`
|
||||
: "Lecture seule — snapshot cœur chargé";
|
||||
} else {
|
||||
byId("connection").textContent = `Connecté · révision ${snapshot.revision}`;
|
||||
: operationalMode ? "Poste local synchronisé" :
|
||||
coreMode ? "Lecture seule — snapshot cœur chargé" : `Connecté · révision ${snapshot.revision}`;
|
||||
scale = coreMode ? fitGraphScale() :
|
||||
Math.min(1, Math.max(0.6, (svg.clientWidth - 160) / 700));
|
||||
syncTypeFilterOptions();
|
||||
restoreReportDraft();
|
||||
render();
|
||||
if (snapshot._loadError)
|
||||
byId("graph-state").textContent = `Snapshot cœur indisponible : ${snapshot._loadError}`;
|
||||
if (!coreMode) connectEvents();
|
||||
if (operationalMode) {
|
||||
await Promise.all([refreshJobs(), refreshCorrelations(), refreshPlanner()]);
|
||||
for (const [callback, delay] of [[refreshJobs, 500],
|
||||
[refreshOperationalGraph, 700], [refreshCorrelations, 900], [refreshPlanner, 1100]])
|
||||
refreshTimers.add(setInterval(callback, delay));
|
||||
} else {
|
||||
byId("queue-controls").hidden = true;
|
||||
}
|
||||
renderTimeline();
|
||||
renderReportSelection();
|
||||
} catch (error) {
|
||||
if (error.name !== "AbortError") {
|
||||
byId("connection").textContent = `Chargement impossible · ${error.message}`;
|
||||
byId("graph-state").textContent = "La projection est indisponible.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showLegacyCreation() {
|
||||
beginWorkspaceContext("legacy-empty", 0);
|
||||
workspaceReady = false;
|
||||
byId("workbench-shell").hidden = true;
|
||||
byId("library-home").hidden = false;
|
||||
byId("library-title").textContent = "Créer l’enquête locale";
|
||||
byId("library-connection").textContent = "Aucune enquête ouverte";
|
||||
byId("library-list-section").hidden = true;
|
||||
}
|
||||
|
||||
async function openWorkspace(workspaceId, button) {
|
||||
button.disabled = true;
|
||||
byId("library-connection").textContent = "Ouverture…";
|
||||
try {
|
||||
const opened = await libraryRequest("/api/v1/library/open", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Labfy-CSRF": csrfToken },
|
||||
body: JSON.stringify({ workspace_id: workspaceId, expected_generation: libraryGeneration }),
|
||||
});
|
||||
if (opened.contract !== "labfy.web_library.open.v1")
|
||||
throw new Error("Contrat d’ouverture inattendu");
|
||||
libraryGeneration = opened.generation;
|
||||
await activateWorkspace(opened);
|
||||
} catch (error) {
|
||||
byId("library-connection").textContent = error.message;
|
||||
button.disabled = false;
|
||||
await refreshLibrary().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function configureApplication() {
|
||||
configureControls();
|
||||
configureGraphInteractions();
|
||||
render();
|
||||
if (snapshot._loadError) {
|
||||
byId("graph-state").textContent =
|
||||
`Snapshot cœur indisponible : ${snapshot._loadError}`;
|
||||
}
|
||||
if (!coreMode) connectEvents();
|
||||
if (operationalMode) {
|
||||
byId("workspace-create-form").addEventListener("submit",async(event)=>{
|
||||
event.preventDefault();
|
||||
try{await postCommand("/api/v1/workspace",{title:byId("workspace-title").value});
|
||||
workspaceReady=true;byId("workspace-create").hidden=true;byId("open-import").disabled=false;
|
||||
await refreshGraphAfterImport();
|
||||
}catch(error){byId("workspace-create-status").textContent=error.message;}
|
||||
});
|
||||
configureImport();
|
||||
byId("jobs-note").textContent = "Contrôle local authentifié.";
|
||||
for (const [id, command] of [
|
||||
configureImport();
|
||||
byId("home-button").addEventListener("click", () => void showLibrary({ focus: true }));
|
||||
byId("workspace-create-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget.querySelector("button");
|
||||
button.disabled = true;
|
||||
byId("workspace-create-status").textContent = "Création…";
|
||||
try {
|
||||
const title = byId("workspace-title").value.trim();
|
||||
if (pendingCreateIntent?.title !== title)
|
||||
pendingCreateIntent = { title, idempotencyKey: crypto.randomUUID() };
|
||||
const path = libraryMode ? "/api/v1/library/workspaces" : "/api/v1/workspace";
|
||||
const body = libraryMode
|
||||
? { title, idempotency_key: pendingCreateIntent.idempotencyKey }
|
||||
: { title };
|
||||
const created = await libraryRequest(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Labfy-CSRF": csrfToken },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (libraryMode && created.contract !== "labfy.web_library.workspace.v1")
|
||||
throw new Error("Contrat de création inattendu");
|
||||
byId("workspace-title").value = "";
|
||||
pendingCreateIntent = null;
|
||||
if (libraryMode) {
|
||||
byId("workspace-create-status").textContent = `« ${created.title} » créée. Ouvrez-la depuis la liste.`;
|
||||
await refreshLibrary();
|
||||
document.querySelector(`[data-workspace-id="${CSS.escape(created.workspace_id)}"]`)?.focus();
|
||||
} else {
|
||||
byId("workspace-create").hidden = true;
|
||||
await activateLegacyWorkspace({ workspace_state: "READY", title,
|
||||
investigation_id: created.investigation_id, generation: 0 });
|
||||
}
|
||||
} catch (error) {
|
||||
byId("workspace-create-status").textContent = error.message;
|
||||
} finally { button.disabled = false; }
|
||||
});
|
||||
byId("jobs-note").textContent = "Contrôle local authentifié.";
|
||||
for (const [id, command] of [
|
||||
["queue-pause", "pause"],
|
||||
["queue-resume", "resume"],
|
||||
["queue-stop", "stop"],
|
||||
]) {
|
||||
]) {
|
||||
byId(id).addEventListener(
|
||||
"click",
|
||||
() =>
|
||||
|
|
@ -1515,11 +1802,8 @@ async function start() {
|
|||
byId("jobs-note").textContent = error.message;
|
||||
}),
|
||||
);
|
||||
}
|
||||
await refreshJobs();
|
||||
await refreshCorrelations();
|
||||
await refreshPlanner();
|
||||
byId("planner-launch").addEventListener("click", async () => {
|
||||
}
|
||||
byId("planner-launch").addEventListener("click", async () => {
|
||||
const ids = [
|
||||
...byId("planner-list").querySelectorAll("input:checked"),
|
||||
].map((input) => input.value);
|
||||
|
|
@ -1538,40 +1822,49 @@ async function start() {
|
|||
} catch (error) {
|
||||
byId("planner-note").textContent = error.message;
|
||||
}
|
||||
});
|
||||
byId("report-preview").addEventListener(
|
||||
});
|
||||
byId("report-preview").addEventListener(
|
||||
"click",
|
||||
() =>
|
||||
void previewReport().catch((error) => {
|
||||
byId("report-preview-content").textContent = error.message;
|
||||
}),
|
||||
);
|
||||
byId("report-generate").addEventListener(
|
||||
);
|
||||
byId("report-generate").addEventListener(
|
||||
"click",
|
||||
() =>
|
||||
void generateReport().catch((error) => {
|
||||
byId("report-status").textContent = error.message;
|
||||
}),
|
||||
);
|
||||
restoreReportDraft();
|
||||
for (const input of [
|
||||
);
|
||||
for (const input of [
|
||||
byId("report-name"),
|
||||
byId("report-comment"),
|
||||
...document.querySelectorAll('[name="report-section"]'),
|
||||
]) {
|
||||
]) {
|
||||
input.addEventListener("input", () => {
|
||||
invalidateReportPreview();
|
||||
saveReportDraft();
|
||||
});
|
||||
}
|
||||
renderTimeline();
|
||||
renderReportSelection();
|
||||
setInterval(refreshJobs, 500);
|
||||
setInterval(refreshOperationalGraph, 700);
|
||||
setInterval(refreshCorrelations, 900);
|
||||
setInterval(refreshPlanner, 1100);
|
||||
} else {
|
||||
byId("queue-controls").hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
const sessionResponse = await fetch("/api/v1/session", { cache: "no-store" });
|
||||
const session = sessionResponse.ok ? await sessionResponse.json() : null;
|
||||
csrfToken = session?.csrf ?? null;
|
||||
operationalMode = session !== null;
|
||||
libraryMode = session?.library_mode === true;
|
||||
configureApplication();
|
||||
// WHY: seule l'application bibliothèque ouvre toujours sur l'accueil ; les
|
||||
// serveurs historiques et de démonstration conservent leur contrat direct.
|
||||
if (libraryMode) await showLibrary();
|
||||
else if (session?.workspace_state === "EMPTY") showLegacyCreation();
|
||||
else await activateLegacyWorkspace(session);
|
||||
} catch (error) {
|
||||
byId("library-home").hidden = false;
|
||||
byId("library-connection").textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1589,6 +1882,11 @@ window.__LABFY_TEST__ = {
|
|||
reconnectCount,
|
||||
coreMode,
|
||||
contract: snapshot.contract,
|
||||
workspaceId: workspaceContext?.workspaceId ?? null,
|
||||
workspaceGeneration: workspaceContext?.generation ?? null,
|
||||
sessionExpired,
|
||||
refreshTimerCount: refreshTimers.size,
|
||||
hasCsrf: csrfToken !== null,
|
||||
}),
|
||||
processEventData,
|
||||
selectNode,
|
||||
|
|
|
|||
|
|
@ -7,14 +7,28 @@
|
|||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<section id="workspace-create" class="modal-card" hidden aria-labelledby="create-title">
|
||||
<h1 id="create-title">Créer l’enquête</h1>
|
||||
<p>La destination locale a été fixée au lancement. Aucun exemple ne sera ajouté.</p>
|
||||
<form id="workspace-create-form"><label>Titre
|
||||
<input id="workspace-title" maxlength="160" required /></label>
|
||||
<button type="submit">Créer l’enquête vide</button></form>
|
||||
<p id="workspace-create-status" role="alert"></p>
|
||||
</section>
|
||||
<main id="library-home" class="library-home" aria-labelledby="library-title" hidden>
|
||||
<header class="library-header">
|
||||
<div><span class="eyebrow">Poste local</span><h1 id="library-title" tabindex="-1">Bibliothèque d’enquêtes</h1></div>
|
||||
<span id="library-connection" role="status">Chargement…</span>
|
||||
</header>
|
||||
<section id="workspace-create" class="library-create" aria-labelledby="create-title">
|
||||
<h2 id="create-title">Nouvelle enquête</h2>
|
||||
<p>Crée un espace local vide. Aucun contenu d’exemple n’est ajouté.</p>
|
||||
<form id="workspace-create-form">
|
||||
<label for="workspace-title">Titre de l’enquête</label>
|
||||
<div class="library-create-row"><input id="workspace-title" maxlength="160" required autocomplete="off" />
|
||||
<button type="submit">Créer</button></div>
|
||||
</form>
|
||||
<p id="workspace-create-status" role="alert"></p>
|
||||
</section>
|
||||
<section id="library-list-section" aria-labelledby="workspace-list-title">
|
||||
<h2 id="workspace-list-title">Enquêtes disponibles</h2>
|
||||
<p id="library-empty" class="empty-state" hidden>Aucune enquête. Créez votre premier espace local pour commencer.</p>
|
||||
<ul id="workspace-list" class="workspace-list" aria-live="polite"></ul>
|
||||
</section>
|
||||
</main>
|
||||
<div id="workbench-shell" hidden>
|
||||
<dialog id="import-dialog" aria-labelledby="import-title">
|
||||
<form method="dialog"><button class="dialog-close" aria-label="Fermer">×</button></form>
|
||||
<h2 id="import-title">Ajouter des preuves</h2>
|
||||
|
|
@ -31,7 +45,7 @@
|
|||
<div class="brand">
|
||||
<strong>LABFY</strong><span id="investigation-title">Enquête locale</span
|
||||
><span id="mode-badge" class="badge"
|
||||
>Prototype — données synthétiques — lecture seule</span
|
||||
>Espace local · contrôles locaux</span
|
||||
>
|
||||
</div>
|
||||
<label class="global-search"
|
||||
|
|
@ -54,6 +68,8 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="graph-tools">
|
||||
<button id="home-button">Accueil</button
|
||||
>
|
||||
<button id="open-import">Ajouter des preuves</button
|
||||
>
|
||||
<button id="back" disabled>← Retour</button
|
||||
|
|
@ -89,7 +105,7 @@
|
|||
<svg
|
||||
id="graph"
|
||||
tabindex="0"
|
||||
aria-label="Réseau d’investigation synthétique"
|
||||
aria-label="Réseau d’investigation"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
|
|
@ -231,7 +247,7 @@
|
|||
<input
|
||||
id="report-name"
|
||||
maxlength="160"
|
||||
value="Rapport SPECIMEN" /></label
|
||||
value="Rapport d’enquête" /></label
|
||||
><label
|
||||
>Commentaire humain
|
||||
<textarea id="report-comment" maxlength="1000"></textarea>
|
||||
|
|
@ -293,6 +309,7 @@
|
|||
<ol id="observation-list"></ol>
|
||||
</section>
|
||||
</dialog>
|
||||
</div>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Labfy — session locale</title><link rel="stylesheet" href="styles.css"></head>
|
||||
<body class="login"><main><section class="login-card"><h1>Labfy · poste local</h1>
|
||||
<p>Entrez le code éphémère affiché par le lanceur. Il n’est ni placé dans l’URL ni conservé dans le navigateur.</p>
|
||||
<form id="login-form"><label>Code de session <input id="bootstrap-code" type="password" autocomplete="off" required></label>
|
||||
<p>Ce poste fonctionne uniquement en local. Entrez le code éphémère affiché dans le terminal qui a lancé Labfy.</p>
|
||||
<p class="login-privacy">Le code n’est ni placé dans l’URL, ni enregistré dans le navigateur, ni affiché après saisie.</p>
|
||||
<form id="login-form"><label for="bootstrap-code">Code de session</label><input id="bootstrap-code" type="password" autocomplete="one-time-code" required autofocus>
|
||||
<button type="submit">Ouvrir la session</button></form><p id="login-status" role="alert"></p>
|
||||
</section></main><script type="module" src="login.js"></script></body></html>
|
||||
|
|
|
|||
31
prototypes/web-graph/public/login.js
vendored
31
prototypes/web-graph/public/login.js
vendored
|
|
@ -2,12 +2,27 @@ const form = document.getElementById("login-form");
|
|||
const status = document.getElementById("login-status");
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const bootstrapCode = document.getElementById("bootstrap-code").value;
|
||||
const response = await fetch("/api/v1/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bootstrap_code: bootstrapCode }),
|
||||
});
|
||||
if (response.ok) location.replace("/");
|
||||
else status.textContent = "Code refusé ou session indisponible.";
|
||||
const input = document.getElementById("bootstrap-code");
|
||||
const button = form.querySelector("button");
|
||||
button.disabled = true;
|
||||
status.textContent = "Ouverture de la session locale…";
|
||||
try {
|
||||
const response = await fetch("/api/v1/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bootstrap_code: input.value }),
|
||||
});
|
||||
// INVARIANT: le secret saisi ne rejoint ni l'URL, ni le stockage Web,
|
||||
// ni un message d'erreur rendu à l'écran.
|
||||
input.value = "";
|
||||
if (!response.ok) throw new Error("Code refusé ou session indisponible.");
|
||||
location.replace("/");
|
||||
} catch (error) {
|
||||
input.value = "";
|
||||
status.textContent = error.message === "Failed to fetch"
|
||||
? "Le service local ne répond pas. Vérifiez qu’il est toujours lancé."
|
||||
: error.message;
|
||||
button.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -76,6 +76,77 @@ textarea:focus-visible,
|
|||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
#workbench-shell {
|
||||
height: 100%;
|
||||
}
|
||||
.library-home {
|
||||
width: min(72rem, 100%);
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
padding: clamp(1rem, 4vw, 3rem);
|
||||
overflow: auto;
|
||||
}
|
||||
.library-header {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.library-header h1 {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
#library-connection {
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
}
|
||||
.library-create {
|
||||
margin: 1.5rem 0 2rem;
|
||||
padding: 1.25rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.library-create h2,
|
||||
.library-create p {
|
||||
margin-top: 0;
|
||||
}
|
||||
.library-create-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.library-create-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.workspace-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(18rem, 100%), 1fr));
|
||||
gap: 1rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.workspace-card,
|
||||
.empty-state {
|
||||
padding: 1rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.workspace-card h3 {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.workspace-card p,
|
||||
.login-privacy {
|
||||
color: var(--muted);
|
||||
}
|
||||
.workspace-card button {
|
||||
width: 100%;
|
||||
}
|
||||
.topbar {
|
||||
height: var(--top);
|
||||
display: grid;
|
||||
|
|
@ -457,6 +528,10 @@ body.login .workbench {
|
|||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.login-card form {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
:root {
|
||||
--top: 6.2rem;
|
||||
|
|
@ -506,6 +581,25 @@ body.login .workbench {
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.library-header,
|
||||
.library-create-row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
#library-connection {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
#graph:focus-visible {
|
||||
outline-color: Highlight;
|
||||
}
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.badge {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import argparse
|
|||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from workspace_server import main as serve_main
|
||||
from web_app import main as web_app_main
|
||||
|
||||
|
||||
def prepare(workspace: Path, bridge: Path):
|
||||
|
|
@ -27,12 +27,11 @@ def prepare(workspace: Path, bridge: Path):
|
|||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument("--port", type=int, default=8081)
|
||||
args = parser.parse_args()
|
||||
repository = Path(__file__).resolve().parents[2]
|
||||
bridge = repository / "tools" / "local-jobs"
|
||||
prepare(args.workspace.resolve(), bridge)
|
||||
import sys
|
||||
sys.argv = [sys.argv[0], "--workspace", str(args.workspace.resolve()),
|
||||
"--bridge", str(bridge), "--port", str(args.port)]
|
||||
serve_main()
|
||||
raise SystemExit(web_app_main(["serve", "--workspace",
|
||||
str(args.workspace.resolve()), "--bridge", str(bridge),
|
||||
"--port", str(args.port)]))
|
||||
|
|
|
|||
36
prototypes/web-graph/tests/owned_process_host.mjs
Normal file
36
prototypes/web-graph/tests/owned_process_host.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { spawn } from "node:child_process";
|
||||
|
||||
function report(message) {
|
||||
if (typeof process.send !== "function") {
|
||||
process.exit(70);
|
||||
return;
|
||||
}
|
||||
process.send(message, () => process.exit(0));
|
||||
}
|
||||
|
||||
let launch;
|
||||
try {
|
||||
launch = JSON.parse(Buffer.from(process.argv[2] ?? "", "base64url").toString("utf8"));
|
||||
} catch (error) {
|
||||
report({ type: "host_error", message: error.message });
|
||||
}
|
||||
|
||||
if (launch) {
|
||||
let settled = false;
|
||||
const child = spawn(launch.command, launch.args, {
|
||||
cwd: launch.cwd,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
});
|
||||
|
||||
child.once("error", (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
report({ type: "launch_error", message: error.message, code: error.code ?? null });
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
report({ type: "result", code, signal });
|
||||
});
|
||||
}
|
||||
232
prototypes/web-graph/tests/owned_process_supervisor.mjs
Normal file
232
prototypes/web-graph/tests/owned_process_supervisor.mjs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const host_path = fileURLToPath(new URL("owned_process_host.mjs", import.meta.url));
|
||||
|
||||
export const PROCESS_STATUSES = Object.freeze([
|
||||
"MISSING", "LAUNCH_ERROR", "EXIT_CODE", "SIGNALED", "TIMEOUT", "INTERRUPTED", "SUCCESS",
|
||||
]);
|
||||
|
||||
function positive_integer(value, name) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new TypeError(`${name} doit être un entier positif.`);
|
||||
}
|
||||
}
|
||||
|
||||
function read_linux_identity(pid) {
|
||||
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
const suffix = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/);
|
||||
return {
|
||||
pid,
|
||||
process_group_id: Number.parseInt(suffix[2], 10),
|
||||
session_id: Number.parseInt(suffix[3], 10),
|
||||
start_time: suffix[19],
|
||||
};
|
||||
}
|
||||
|
||||
function validate_owned_session(child) {
|
||||
const identity = read_linux_identity(child.pid);
|
||||
if (identity.process_group_id !== child.pid || identity.session_id !== child.pid) {
|
||||
throw new Error(`session détachée invalide pour le PID ${child.pid}`);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
function signal_owned_group(ownership, signal) {
|
||||
if (!ownership.active) return false;
|
||||
|
||||
// INVARIANT: Linux cannot reuse a PGID/SID while a member of this owned
|
||||
// session remains. ESRCH closes ownership permanently, so later PID reuse
|
||||
// can never become a signal target of this supervisor.
|
||||
try {
|
||||
process.kill(-ownership.identity.process_group_id, signal);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === "ESRCH") {
|
||||
ownership.active = false;
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function group_exists(ownership) {
|
||||
return signal_owned_group(ownership, 0);
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function stop_owned_group(ownership, term_grace_ms, kill_grace_ms) {
|
||||
if (!ownership || !group_exists(ownership)) return;
|
||||
signal_owned_group(ownership, "SIGTERM");
|
||||
await delay(term_grace_ms);
|
||||
if (!group_exists(ownership)) return;
|
||||
signal_owned_group(ownership, "SIGKILL");
|
||||
|
||||
const deadline = Date.now() + kill_grace_ms;
|
||||
while (Date.now() < deadline && group_exists(ownership)) {
|
||||
await delay(Math.min(10, Math.max(1, deadline - Date.now())));
|
||||
}
|
||||
// WHY: a non-reaped zombie can keep kill(0) true. The verdict remains
|
||||
// bounded and ownership is retired instead of ever targeting this PGID again.
|
||||
if (group_exists(ownership)) ownership.active = false;
|
||||
}
|
||||
|
||||
function destroy_pipes(child) {
|
||||
child.stdout?.unpipe();
|
||||
child.stderr?.unpipe();
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
if (child.connected) child.disconnect();
|
||||
}
|
||||
|
||||
function status_from_exit(code, signal) {
|
||||
if (signal !== null) return "SIGNALED";
|
||||
if (code !== 0) return "EXIT_CODE";
|
||||
return "SUCCESS";
|
||||
}
|
||||
|
||||
export function create_interruption_controller(signals = ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
||||
const controller = new AbortController();
|
||||
let interrupted_signal = null;
|
||||
const handlers = new Map();
|
||||
|
||||
for (const signal of signals) {
|
||||
const handler = () => {
|
||||
if (controller.signal.aborted) return;
|
||||
interrupted_signal = signal;
|
||||
controller.abort(signal);
|
||||
};
|
||||
handlers.set(signal, handler);
|
||||
process.on(signal, handler);
|
||||
}
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
get interrupted_signal() { return interrupted_signal; },
|
||||
dispose() {
|
||||
for (const [signal, handler] of handlers) process.off(signal, handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function supervise_process({
|
||||
command,
|
||||
args = [],
|
||||
cwd = process.cwd(),
|
||||
timeout_ms,
|
||||
term_grace_ms = 1000,
|
||||
kill_grace_ms = 1000,
|
||||
abort_signal,
|
||||
stdout = process.stdout,
|
||||
stderr = process.stderr,
|
||||
}) {
|
||||
positive_integer(timeout_ms, "timeout_ms");
|
||||
positive_integer(term_grace_ms, "term_grace_ms");
|
||||
positive_integer(kill_grace_ms, "kill_grace_ms");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let ownership = null;
|
||||
let target_result = null;
|
||||
let stopping_status = null;
|
||||
let settled = false;
|
||||
let cleanup_started = false;
|
||||
let child;
|
||||
let timeout;
|
||||
|
||||
const payload = Buffer.from(JSON.stringify({ command, args, cwd }), "utf8").toString("base64url");
|
||||
|
||||
const finish = async (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
abort_signal?.removeEventListener("abort", on_abort);
|
||||
destroy_pipes(child);
|
||||
await stop_owned_group(ownership, term_grace_ms, kill_grace_ms);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const begin_cleanup = (result) => {
|
||||
if (cleanup_started) return;
|
||||
cleanup_started = true;
|
||||
void finish(result);
|
||||
};
|
||||
|
||||
const request_stop = (status, interrupted_signal = null) => {
|
||||
if (stopping_status) return;
|
||||
stopping_status = status;
|
||||
begin_cleanup({
|
||||
status,
|
||||
code: target_result?.code ?? null,
|
||||
signal: target_result?.signal ?? interrupted_signal,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
const on_abort = () => request_stop("INTERRUPTED", abort_signal.reason ?? null);
|
||||
|
||||
try {
|
||||
child = spawn(process.execPath, [host_path, payload], {
|
||||
cwd,
|
||||
env: process.env,
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
});
|
||||
} catch (error) {
|
||||
resolve({ status: "LAUNCH_ERROR", code: null, signal: null, error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
// CONTRACT: spawn() returns only after the detached child has performed
|
||||
// setsid(2). Capture and validate that fresh identity synchronously, before
|
||||
// a pre-aborted signal or a very short timeout can request cleanup.
|
||||
try {
|
||||
ownership = { identity: validate_owned_session(child), active: true };
|
||||
} catch (error) {
|
||||
child.kill("SIGKILL");
|
||||
destroy_pipes(child);
|
||||
resolve({ status: "LAUNCH_ERROR", code: null, signal: null, error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
child.stdout.pipe(stdout, { end: false });
|
||||
child.stderr.pipe(stderr, { end: false });
|
||||
|
||||
child.on("message", (message) => {
|
||||
if (settled || !message || typeof message !== "object") return;
|
||||
if (message.type === "launch_error" || message.type === "host_error") {
|
||||
begin_cleanup({
|
||||
status: "LAUNCH_ERROR", code: null, signal: null,
|
||||
error: message.message ?? "échec de lancement",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type === "result") {
|
||||
target_result = { code: message.code ?? null, signal: message.signal ?? null };
|
||||
begin_cleanup({
|
||||
status: status_from_exit(target_result.code, target_result.signal),
|
||||
...target_result,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.once("error", (error) => {
|
||||
begin_cleanup({ status: "LAUNCH_ERROR", code: null, signal: null, error: error.message });
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
if (settled || cleanup_started || target_result) return;
|
||||
setImmediate(() => {
|
||||
if (settled || cleanup_started || target_result) return;
|
||||
begin_cleanup({ status: status_from_exit(code, signal), code, signal, error: null });
|
||||
});
|
||||
});
|
||||
|
||||
timeout = setTimeout(() => request_stop("TIMEOUT"), timeout_ms);
|
||||
abort_signal?.addEventListener("abort", on_abort, { once: true });
|
||||
if (abort_signal?.aborted) on_abort();
|
||||
});
|
||||
}
|
||||
137
prototypes/web-graph/tests/run_browser_suite.mjs
Normal file
137
prototypes/web-graph/tests/run_browser_suite.mjs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { access } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
PROCESS_STATUSES,
|
||||
create_interruption_controller,
|
||||
supervise_process,
|
||||
} from "./owned_process_supervisor.mjs";
|
||||
|
||||
// CONTRACT: this ordered list is the complete browser-validation suite.
|
||||
export const scenarios = Object.freeze([
|
||||
"test_browser.mjs",
|
||||
"test_core_browser.mjs",
|
||||
"test_eml_browser.mjs",
|
||||
"test_local_toolkit_browser.mjs",
|
||||
"test_workspace_control_browser.mjs",
|
||||
"test_j8_browser.mjs",
|
||||
"test_j8_runtime_browser.mjs",
|
||||
"test_j9_browser.mjs",
|
||||
"test_workbench_browser.mjs",
|
||||
"test_workbench_populated_browser.mjs",
|
||||
"test_local_workspace_import_browser.mjs",
|
||||
// Le scénario import/rapport précédent et ce cycle du vrai launcher forment
|
||||
// la preuve E2E : données SPECIMEN, rapport, bibliothèque, redémarrages et A/B.
|
||||
"test_web_app_browser.mjs",
|
||||
// CONTRACT: ce parcours Firefox exerce le vrai formulaire et une expiration
|
||||
// de harness isolée ; il démontre l'arrêt du polling et la reconnexion sans
|
||||
// rejouer une mutation dont l'admission n'est pas connue.
|
||||
"test_session_lifetime_browser.mjs",
|
||||
]);
|
||||
|
||||
const tests_directory = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
function print_result(scenario, result, started_ms, output) {
|
||||
output.log(
|
||||
`SCENARIO fin ${scenario} ${new Date().toISOString()} résultat=${result.status} ` +
|
||||
`code=${result.code ?? "null"} signal=${result.signal ?? "null"} ` +
|
||||
`durée_ms=${Date.now() - started_ms}`,
|
||||
);
|
||||
if (result.error) output.error(`SCENARIO erreur ${scenario}: ${result.error}`);
|
||||
}
|
||||
|
||||
export function summarize_browser_results(results, expected_count) {
|
||||
const counts = Object.fromEntries(PROCESS_STATUSES.map((status) => [status, 0]));
|
||||
for (const result of results) counts[result.status] += 1;
|
||||
const failures = results.filter(({ status }) => status !== "SUCCESS");
|
||||
const executed = results.filter(({ status }) => status !== "MISSING").length;
|
||||
return {
|
||||
counts,
|
||||
failures,
|
||||
executed,
|
||||
exit_code: failures.length === 0 && executed === expected_count ? 0 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
export async function run_browser_suite({
|
||||
scenario_names = scenarios,
|
||||
directory = tests_directory,
|
||||
timeout_ms,
|
||||
term_grace_ms = 1000,
|
||||
kill_grace_ms = 1000,
|
||||
interruption = create_interruption_controller(),
|
||||
output = console,
|
||||
} = {}) {
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
for (const scenario of scenario_names) {
|
||||
if (interruption.signal.aborted) break;
|
||||
|
||||
const started_ms = Date.now();
|
||||
output.log(`SCENARIO début ${scenario} ${new Date().toISOString()}`);
|
||||
const scenario_path = resolve(directory, scenario);
|
||||
|
||||
try {
|
||||
await access(scenario_path);
|
||||
} catch {
|
||||
const missing = { status: "MISSING", code: null, signal: null, error: null };
|
||||
print_result(scenario, missing, started_ms, output);
|
||||
results.push({ scenario, ...missing });
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await supervise_process({
|
||||
command: process.execPath,
|
||||
args: [scenario_path],
|
||||
cwd: directory,
|
||||
timeout_ms,
|
||||
term_grace_ms,
|
||||
kill_grace_ms,
|
||||
abort_signal: interruption.signal,
|
||||
});
|
||||
print_result(scenario, result, started_ms, output);
|
||||
results.push({ scenario, ...result });
|
||||
|
||||
// CONTRACT: an operator interruption cancels the current owned unit and
|
||||
// prevents every later scenario from being launched.
|
||||
if (result.status === "INTERRUPTED") break;
|
||||
}
|
||||
|
||||
if (interruption.signal.aborted && !results.some(({ status }) => status === "INTERRUPTED")) {
|
||||
results.push({
|
||||
scenario: "<suite>", status: "INTERRUPTED", code: null,
|
||||
signal: interruption.interrupted_signal, error: null,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
interruption.dispose();
|
||||
}
|
||||
|
||||
const summary = summarize_browser_results(results, scenario_names.length);
|
||||
output.log(
|
||||
`SUITE navigateur scénarios_exécutés=${summary.executed}/${scenario_names.length} ` +
|
||||
`échecs=${summary.failures.length} ` +
|
||||
`résultats=${PROCESS_STATUSES.map((status) => `${status}:${summary.counts[status]}`).join(",")}`,
|
||||
);
|
||||
return {
|
||||
results,
|
||||
counts: summary.counts,
|
||||
exit_code: summary.exit_code,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const timeout_ms = Number.parseInt(process.env.LABFY_BROWSER_SCENARIO_TIMEOUT_MS ?? "300000", 10);
|
||||
if (!Number.isSafeInteger(timeout_ms) || timeout_ms <= 0) {
|
||||
console.error("LABFY_BROWSER_SCENARIO_TIMEOUT_MS doit être un entier positif.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
const summary = await run_browser_suite({ timeout_ms });
|
||||
process.exitCode = summary.exit_code;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await main();
|
||||
}
|
||||
199
prototypes/web-graph/tests/test_run_browser_suite.mjs
Normal file
199
prototypes/web-graph/tests/test_run_browser_suite.mjs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Writable } from "node:stream";
|
||||
import test from "node:test";
|
||||
import {
|
||||
create_interruption_controller,
|
||||
supervise_process,
|
||||
} from "./owned_process_supervisor.mjs";
|
||||
import { run_browser_suite, summarize_browser_results } from "./run_browser_suite.mjs";
|
||||
|
||||
const SHORT = { timeout_ms: 1000, term_grace_ms: 40, kill_grace_ms: 120 };
|
||||
|
||||
function null_output() {
|
||||
return new Writable({ write(_chunk, _encoding, callback) { callback(); } });
|
||||
}
|
||||
|
||||
async function wait_for(predicate, timeout_ms = 1000) {
|
||||
const deadline = Date.now() + timeout_ms;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.fail("condition non satisfaite avant la limite");
|
||||
}
|
||||
|
||||
async function process_is_running(pid) {
|
||||
try {
|
||||
const stat = await readFile(`/proc/${pid}/stat`, "utf8");
|
||||
return stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/)[0] !== "Z";
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test("classifie succès, code non nul, signal et lancement impossible", async () => {
|
||||
const silent = null_output();
|
||||
const common = { ...SHORT, stdout: silent, stderr: silent };
|
||||
|
||||
const success = await supervise_process({
|
||||
...common, command: process.execPath, args: ["-e", "process.exit(0)"],
|
||||
});
|
||||
const exit_code = await supervise_process({
|
||||
...common, command: process.execPath, args: ["-e", "process.exit(17)"],
|
||||
});
|
||||
const signaled = await supervise_process({
|
||||
...common,
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.kill(process.pid, 'SIGUSR2')"],
|
||||
});
|
||||
const launch_error = await supervise_process({
|
||||
...common, command: "/chemin/SPECIMEN/inexistant", args: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
[success.status, exit_code.status, signaled.status, launch_error.status],
|
||||
["SUCCESS", "EXIT_CODE", "SIGNALED", "LAUNCH_ERROR"],
|
||||
);
|
||||
assert.equal(exit_code.code, 17);
|
||||
assert.equal(signaled.signal, "SIGUSR2");
|
||||
assert.match(launch_error.error, /ENOENT/);
|
||||
});
|
||||
|
||||
test("chaque verdict négatif impose un code global non nul", () => {
|
||||
for (const status of ["MISSING", "LAUNCH_ERROR", "EXIT_CODE", "SIGNALED", "TIMEOUT", "INTERRUPTED"]) {
|
||||
assert.equal(summarize_browser_results([{ status }], 1).exit_code, 1, status);
|
||||
}
|
||||
assert.equal(summarize_browser_results([{ status: "SUCCESS" }], 1).exit_code, 0);
|
||||
});
|
||||
|
||||
test("le timeout escalade à SIGKILL quand le scénario ignore SIGTERM", async () => {
|
||||
const started = Date.now();
|
||||
const result = await supervise_process({
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"],
|
||||
timeout_ms: 80,
|
||||
term_grace_ms: 40,
|
||||
kill_grace_ms: 120,
|
||||
stdout: null_output(),
|
||||
stderr: null_output(),
|
||||
});
|
||||
|
||||
assert.equal(result.status, "TIMEOUT");
|
||||
assert.ok(Date.now() - started < 600, "le délai total doit rester borné");
|
||||
});
|
||||
|
||||
test("un descendant qui garde les pipes ouverts est nettoyé sans bloquer close", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "labfy-runner-SPECIMEN-"));
|
||||
const pid_file = join(directory, "descendant.pid");
|
||||
const child_source = [
|
||||
"const { spawn } = require('node:child_process');",
|
||||
"const { writeFileSync } = require('node:fs');",
|
||||
"const descendant = spawn(process.execPath, ['-e', `process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)`], { stdio: 'inherit' });",
|
||||
"writeFileSync(process.argv[1], String(descendant.pid));",
|
||||
"process.exit(0);",
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
const started = Date.now();
|
||||
const result = await supervise_process({
|
||||
command: process.execPath,
|
||||
args: ["-e", child_source, pid_file],
|
||||
cwd: directory,
|
||||
...SHORT,
|
||||
stdout: null_output(),
|
||||
stderr: null_output(),
|
||||
});
|
||||
const descendant_pid = Number.parseInt(await readFile(pid_file, "utf8"), 10);
|
||||
|
||||
assert.equal(result.status, "SUCCESS");
|
||||
assert.ok(Date.now() - started < 600, "les pipes hérités ne doivent pas retenir le verdict");
|
||||
await wait_for(async () => !(await process_is_running(descendant_pid)));
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("le nettoyage du groupe possédé épargne un témoin indépendant", async () => {
|
||||
const witness = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
witness.once("spawn", resolve);
|
||||
witness.once("error", reject);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await supervise_process({
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"],
|
||||
timeout_ms: 80,
|
||||
term_grace_ms: 40,
|
||||
kill_grace_ms: 120,
|
||||
stdout: null_output(),
|
||||
stderr: null_output(),
|
||||
});
|
||||
assert.equal(result.status, "TIMEOUT");
|
||||
assert.equal(await process_is_running(witness.pid), true);
|
||||
} finally {
|
||||
try { process.kill(-witness.pid, "SIGKILL"); } catch (error) {
|
||||
if (error.code !== "ESRCH") throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("SIGINT interrompt le scénario courant et produit un verdict unique", async () => {
|
||||
const interruption = create_interruption_controller(["SIGINT"]);
|
||||
const pending = supervise_process({
|
||||
command: process.execPath,
|
||||
args: ["-e", "setInterval(() => {}, 1000)"],
|
||||
...SHORT,
|
||||
abort_signal: interruption.signal,
|
||||
stdout: null_output(),
|
||||
stderr: null_output(),
|
||||
});
|
||||
setTimeout(() => process.kill(process.pid, "SIGINT"), 60);
|
||||
|
||||
try {
|
||||
const result = await pending;
|
||||
assert.equal(result.status, "INTERRUPTED");
|
||||
assert.equal(result.signal, "SIGINT");
|
||||
} finally {
|
||||
interruption.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("le runner classe MISSING, ne lance pas la suite après interruption et échoue globalement", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "labfy-suite-SPECIMEN-"));
|
||||
const first = join(directory, "first.mjs");
|
||||
const forbidden = join(directory, "forbidden.mjs");
|
||||
const marker = join(directory, "forbidden-ran");
|
||||
await writeFile(first, "setInterval(() => {}, 1000);\n", "utf8");
|
||||
await writeFile(forbidden, `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'bad');\n`, "utf8");
|
||||
const interruption = create_interruption_controller(["SIGINT"]);
|
||||
const output = { log() {}, error() {} };
|
||||
setTimeout(() => process.kill(process.pid, "SIGINT"), 60);
|
||||
|
||||
try {
|
||||
const summary = await run_browser_suite({
|
||||
scenario_names: ["missing.mjs", "first.mjs", "forbidden.mjs"],
|
||||
directory,
|
||||
timeout_ms: 1000,
|
||||
term_grace_ms: 40,
|
||||
kill_grace_ms: 120,
|
||||
interruption,
|
||||
output,
|
||||
});
|
||||
assert.deepEqual(summary.results.map(({ status }) => status), ["MISSING", "INTERRUPTED"]);
|
||||
assert.equal(summary.exit_code, 1);
|
||||
assert.equal(await readFile(marker, "utf8").then(() => true, () => false), false);
|
||||
} finally {
|
||||
interruption.dispose();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
126
prototypes/web-graph/tests/test_session_lifetime.py
Normal file
126
prototypes/web-graph/tests/test_session_lifetime.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Session locale : connexion tardive, expiration et nouvelle authentification."""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
WEB = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(WEB))
|
||||
import workspace_server # noqa: E402
|
||||
|
||||
|
||||
class SessionLifetimeTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="labfy-session-SPECIMEN-")
|
||||
self.now = 1000.0
|
||||
clock = mock.Mock(wraps=time)
|
||||
clock.monotonic.side_effect = lambda: self.now
|
||||
self.clock_patch = mock.patch.object(workspace_server, "time", clock)
|
||||
self.clock_patch.start()
|
||||
self.addCleanup(self.clock_patch.stop)
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.server = workspace_server.WorkspaceServer(
|
||||
("127.0.0.1", 0), workspace_server.Handler,
|
||||
workspace=Path(self.temporary.name), bridge=WEB / "unused-SPECIMEN",
|
||||
bootstrap="code-SPECIMEN-only")
|
||||
self.server.bridge_call = mock.Mock(
|
||||
side_effect=AssertionError("L'authentification ne doit pas ouvrir d'enquête"))
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=3)
|
||||
self.assertFalse(self.thread.is_alive())
|
||||
self.server.bridge_call.assert_not_called()
|
||||
|
||||
def request(self, method, path, *, cookie=None, body=None, origin=None):
|
||||
connection = http.client.HTTPConnection(
|
||||
"127.0.0.1", self.server.server_port, timeout=3)
|
||||
headers = {"Host": self.server.authority}
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
if body is not None:
|
||||
headers.update({"Content-Type": "application/json",
|
||||
"Origin": origin or self.server.origin})
|
||||
try:
|
||||
connection.request(method, path, headers=headers,
|
||||
body=json.dumps(body) if body is not None else None)
|
||||
response = connection.getresponse()
|
||||
return response.status, dict(response.getheaders()), response.read()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def login(self):
|
||||
status, headers, body = self.request("POST", "/api/v1/session",
|
||||
body={"bootstrap_code": "code-SPECIMEN-only"})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(json.loads(body)["authenticated"])
|
||||
self.assertIn("HttpOnly", headers["Set-Cookie"])
|
||||
self.assertIn("SameSite=Strict", headers["Set-Cookie"])
|
||||
self.assertIn("Max-Age=3600", headers["Set-Cookie"])
|
||||
return headers["Set-Cookie"].split(";", 1)[0]
|
||||
|
||||
def test_production_ttl_remains_one_hour(self):
|
||||
# INVARIANT: le TTL court du harness navigateur est opt-in ; les
|
||||
# constructions normales et leur cookie restent fixés à une heure.
|
||||
self.assertEqual(workspace_server.SESSION_TTL_SECONDS, 3600)
|
||||
self.assertEqual(self.server.session_ttl_seconds, 3600)
|
||||
self.login()
|
||||
self.assertEqual(self.server.session_deadline, self.now + 3600)
|
||||
|
||||
def test_first_login_after_two_hours_has_its_own_lifetime(self):
|
||||
self.now += 7200
|
||||
cookie = self.login()
|
||||
self.assertEqual(self.request("GET", "/api/v1/session", cookie=cookie)[0], 200)
|
||||
deadline = self.server.session_deadline
|
||||
self.assertEqual(deadline, self.now + 3600)
|
||||
self.now += 3599
|
||||
self.assertEqual(self.request("GET", "/api/v1/session", cookie=cookie)[0], 200)
|
||||
self.assertEqual(self.server.session_deadline, deadline)
|
||||
self.now += 1
|
||||
self.assertEqual(self.request("GET", "/api/v1/session", cookie=cookie)[0], 401)
|
||||
|
||||
def test_reauthentication_renews_session_without_reviving_old_cookie(self):
|
||||
old_cookie = self.login()
|
||||
old_csrf = self.server.csrf
|
||||
self.now += 3601
|
||||
self.assertEqual(self.request("GET", "/api/v1/session", cookie=old_cookie)[0], 401)
|
||||
cookie = self.login()
|
||||
self.assertNotEqual(cookie, old_cookie)
|
||||
self.assertNotEqual(self.server.csrf, old_csrf)
|
||||
self.assertEqual(self.request("GET", "/api/v1/session", cookie=old_cookie)[0], 401)
|
||||
status, _, body = self.request("GET", "/api/v1/session", cookie=cookie)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(json.loads(body)["csrf"], self.server.csrf)
|
||||
status, _, html = self.request("GET", "/", cookie=cookie)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertNotIn(b'id="login-form"', html)
|
||||
self.assertEqual(self.server.session_deadline, self.now + 3600)
|
||||
|
||||
def test_invalid_login_never_extends_or_replaces_a_session(self):
|
||||
cookie = self.login()
|
||||
before = (self.server.session, self.server.csrf, self.server.session_deadline)
|
||||
for expired in (False, True):
|
||||
if expired:
|
||||
self.now += 3601
|
||||
for code, origin in (("wrong-SPECIMEN", self.server.origin),
|
||||
("code-SPECIMEN-only", "http://foreign.test")):
|
||||
status, _, _ = self.request("POST", "/api/v1/session",
|
||||
body={"bootstrap_code": code}, origin=origin)
|
||||
self.assertEqual(status, 403)
|
||||
self.assertEqual((self.server.session, self.server.csrf,
|
||||
self.server.session_deadline), before)
|
||||
self.assertEqual(self.request("GET", "/api/v1/session", cookie=cookie)[0],
|
||||
401 if expired else 200)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
197
prototypes/web-graph/tests/test_session_lifetime_browser.mjs
Normal file
197
prototypes/web-graph/tests/test_session_lifetime_browser.mjs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
|
||||
const PROTOTYPE = new URL("..", import.meta.url);
|
||||
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
|
||||
const SERVER_PROGRAM = String.raw`
|
||||
import json, signal, sys, threading
|
||||
from pathlib import Path
|
||||
from web_library import WebLibrary
|
||||
from workspace_server import Handler, WorkspaceServer
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
bridge = Path(sys.argv[2])
|
||||
library = WebLibrary(root, bridge)
|
||||
inactive = root / ".inactive"
|
||||
inactive.mkdir(mode=0o700, exist_ok=True)
|
||||
# CONTRACT: expiration réelle et courte, injectée uniquement dans ce processus
|
||||
# de test. Aucune route ne permet de modifier l'horloge ou le TTL.
|
||||
server = WorkspaceServer(("127.0.0.1", 0), Handler, workspace=inactive,
|
||||
bridge=bridge, bootstrap="session-browser-SPECIMEN", library=library,
|
||||
instance_id="session-browser-SPECIMEN", config_id="session-browser-SPECIMEN",
|
||||
session_ttl_seconds=5)
|
||||
print(json.dumps({"origin": server.origin,
|
||||
"code": "session-browser-SPECIMEN"}), flush=True)
|
||||
signal.signal(signal.SIGTERM,
|
||||
lambda _signum, _frame: threading.Thread(target=server.shutdown,
|
||||
daemon=True).start())
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
server.server_close()
|
||||
`;
|
||||
|
||||
function startServer(library, runtime, state) {
|
||||
return spawn("python3", ["-c", SERVER_PROGRAM, library,
|
||||
"../../tools/local-jobs"], {
|
||||
cwd: PROTOTYPE,
|
||||
env: { ...process.env, PYTHONUNBUFFERED: "1", XDG_RUNTIME_DIR: runtime,
|
||||
XDG_STATE_HOME: state },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function ready(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
let errors = "";
|
||||
const timer = setTimeout(() => reject(new Error(
|
||||
`démarrage Web expiré: ${errors}`)), 10000);
|
||||
server.stderr.on("data", (chunk) => { errors += chunk; });
|
||||
server.stdout.on("data", (chunk) => {
|
||||
output += chunk;
|
||||
const newline = output.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
clearTimeout(timer);
|
||||
resolve(JSON.parse(output.slice(0, newline)));
|
||||
});
|
||||
server.once("exit", (code) => reject(new Error(
|
||||
`serveur arrêté (${code}): ${errors}`)));
|
||||
});
|
||||
}
|
||||
|
||||
async function stopServer(server) {
|
||||
if (!server || server.exitCode !== null) return;
|
||||
const exited = new Promise((resolve) => server.once("exit", resolve));
|
||||
server.kill("SIGTERM");
|
||||
await exited;
|
||||
}
|
||||
|
||||
async function login(page, session) {
|
||||
await page.goto(session.origin, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector("#login-form");
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.click("#login-form button"),
|
||||
]);
|
||||
await page.waitForSelector("#library-home:not([hidden])");
|
||||
}
|
||||
|
||||
async function createAndOpenSpecimen(page) {
|
||||
const title = "Enquête session Firefox SPECIMEN";
|
||||
await page.type("#workspace-title", title);
|
||||
await page.click("#workspace-create-form button");
|
||||
await page.waitForFunction((expected) => [...document.querySelectorAll(
|
||||
".workspace-card h3")].some((heading) => heading.textContent === expected), {}, title);
|
||||
await page.click(".workspace-card [data-workspace-id]");
|
||||
await page.waitForFunction(() => document.querySelector("#connection")
|
||||
?.textContent.includes("Ouverte"), { timeout: 15000 });
|
||||
return page.evaluate(() => window.__LABFY_TEST__.getState().workspaceId);
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
const library = await mkdtemp(join(tmpdir(), "labfy-session-library-SPECIMEN-"));
|
||||
const profile = await mkdtemp(join(tmpdir(), "labfy-session-firefox-SPECIMEN-"));
|
||||
const runtime = await mkdtemp(join(tmpdir(), "labfy-session-runtime-SPECIMEN-"));
|
||||
const state = await mkdtemp(join(tmpdir(), "labfy-session-state-SPECIMEN-"));
|
||||
let browser;
|
||||
let server;
|
||||
try {
|
||||
server = startServer(library, runtime, state);
|
||||
const session = await ready(server);
|
||||
assert.notEqual(new URL(session.origin).port, "8081");
|
||||
assert.notEqual(new URL(session.origin).port, "8080");
|
||||
// CONTRACT: le serveur peut être déjà ancien quand Firefox soumet son premier
|
||||
// formulaire. La connexion réussie doit alors commencer une nouvelle durée.
|
||||
await delay(5500);
|
||||
browser = await puppeteer.launch({ browser: "firefox", executablePath: FIREFOX,
|
||||
headless: true, userDataDir: profile, args: ["--no-remote"] });
|
||||
const page = await browser.newPage();
|
||||
const pageErrors = [];
|
||||
const unauthorized = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(String(error)));
|
||||
page.on("response", (response) => {
|
||||
if (response.status() === 401 && new URL(response.url()).pathname.startsWith("/api/"))
|
||||
unauthorized.push(response.url());
|
||||
});
|
||||
|
||||
await login(page, session);
|
||||
const workspaceId = await createAndOpenSpecimen(page);
|
||||
assert.ok(workspaceId);
|
||||
await page.$eval("#report-comment", (input) => {
|
||||
input.value = "Brouillon conservé après expiration SPECIMEN";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
const before = await page.evaluate(async () => {
|
||||
const response = await fetch("/api/v1/session", { cache: "no-store" });
|
||||
return response.json();
|
||||
});
|
||||
const oldCookie = (await page.cookies()).find((cookie) =>
|
||||
cookie.name.startsWith("labfy_session_"));
|
||||
assert.ok(oldCookie);
|
||||
|
||||
// WHY: un polling réel doit découvrir l'expiration et arrêter ses trois
|
||||
// pairs ; le test n'appelle aucune route d'administration.
|
||||
await page.waitForSelector("#session-reconnect", { timeout: 10000 });
|
||||
const expiredState = await page.evaluate(() => window.__LABFY_TEST__.getState());
|
||||
assert.equal(expiredState.sessionExpired, true);
|
||||
assert.equal(expiredState.refreshTimerCount, 0);
|
||||
assert.equal(expiredState.hasCsrf, false);
|
||||
assert.equal(expiredState.workspaceId, null);
|
||||
assert.equal(await page.$eval("#workbench-shell", (item) => item.hidden), true);
|
||||
assert.match(await page.$eval("#library-connection", (item) => item.textContent),
|
||||
/travail en cours a été arrêté/i);
|
||||
assert.equal(await page.$$eval("#session-reconnect", (items) => items.length), 1);
|
||||
const requestCount = unauthorized.length;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1400));
|
||||
assert.equal(unauthorized.length, requestCount,
|
||||
"aucune rafale API après la première transition d'expiration");
|
||||
assert.equal(requestCount, 1, "un seul polling observe le 401 avant l'annulation");
|
||||
assert.equal(await page.evaluate(() => Object.keys(sessionStorage)
|
||||
.some((key) => key.startsWith("labfy-report-draft:"))), true);
|
||||
|
||||
await page.click("#session-reconnect");
|
||||
await page.waitForSelector("#login-form");
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.click("#login-form button"),
|
||||
]);
|
||||
await page.waitForSelector("#library-home:not([hidden])");
|
||||
const renewed = await page.evaluate(async () => {
|
||||
const response = await fetch("/api/v1/session", { cache: "no-store" });
|
||||
return response.json();
|
||||
});
|
||||
assert.notEqual(renewed.csrf, before.csrf);
|
||||
assert.equal(await page.$eval(".workspace-card h3", (item) => item.textContent),
|
||||
"Enquête session Firefox SPECIMEN");
|
||||
await page.click(`.workspace-card [data-workspace-id="${workspaceId}"]`);
|
||||
await page.waitForFunction((expected) =>
|
||||
window.__LABFY_TEST__.getState().workspaceId === expected, {}, workspaceId);
|
||||
|
||||
const staleHeaders = { Cookie: `${oldCookie.name}=${oldCookie.value}` };
|
||||
assert.equal((await fetch(`${session.origin}/api/v1/session`,
|
||||
{ headers: staleHeaders })).status, 401);
|
||||
assert.equal((await fetch(`${session.origin}/api/v1/queue/pause`, {
|
||||
method: "POST",
|
||||
headers: { ...staleHeaders, Origin: session.origin,
|
||||
"Content-Type": "application/json", "X-Labfy-CSRF": before.csrf },
|
||||
body: "{}",
|
||||
})).status, 403);
|
||||
assert.deepEqual(pageErrors, []);
|
||||
console.log("PASS Firefox session : expiration réelle, arrêt unique, reconnexion, " +
|
||||
"contexte SPECIMEN et rotation cookie/CSRF");
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await stopServer(server);
|
||||
await Promise.all([library, profile, runtime, state].map((path) =>
|
||||
rm(path, { recursive: true, force: true })));
|
||||
}
|
||||
320
prototypes/web-graph/tests/test_web_app.py
Normal file
320
prototypes/web-graph/tests/test_web_app.py
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
"""Régressions synthétiques du lanceur 8081 et de la bibliothèque Web."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
WEB = Path(__file__).resolve().parents[1]
|
||||
ROOT = WEB.parents[1]
|
||||
sys.path.insert(0, str(WEB))
|
||||
|
||||
import web_app # noqa: E402
|
||||
from web_library import LibraryError, WebLibrary # noqa: E402
|
||||
from workspace_server import Handler, WorkspaceServer # noqa: E402
|
||||
|
||||
|
||||
class SyntheticEnvironment(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="labfy-web-SPECIMEN-")
|
||||
self.root = Path(self.temporary.name)
|
||||
self.runtime = self.root / "runtime"
|
||||
self.state = self.root / "state"
|
||||
self.runtime.mkdir(mode=0o700)
|
||||
self.state.mkdir(mode=0o700)
|
||||
self.environment = {**os.environ, "XDG_RUNTIME_DIR": str(self.runtime),
|
||||
"XDG_STATE_HOME": str(self.state)}
|
||||
self.bridge = self.root / "fake-local-jobs"
|
||||
self.bridge.write_text("""#!/usr/bin/env python3
|
||||
import json,sys
|
||||
from pathlib import Path
|
||||
args=sys.argv[1:]
|
||||
workspace=Path(args[args.index('--workspace')+1])
|
||||
if args[0]=='create-workspace':
|
||||
title=args[args.index('--title')+1]
|
||||
(workspace/'.labfy/runtime').mkdir(parents=True,exist_ok=True)
|
||||
(workspace/'Enquete.sqlite').write_bytes(b'SPECIMEN')
|
||||
manifest={'contract':'labfy.local_workspace.v1','investigation_id':'00000000-0000-4000-8000-000000000001','title':title,'mode':'local_experimental'}
|
||||
(workspace/'.labfy/runtime/workspace.json').write_text(json.dumps(manifest))
|
||||
elif args[0]=='export':
|
||||
pass
|
||||
else:
|
||||
print('commande SPECIMEN inconnue',file=sys.stderr)
|
||||
sys.exit(2)
|
||||
""", encoding="utf-8")
|
||||
self.bridge.chmod(0o700)
|
||||
|
||||
def tearDown(self):
|
||||
# Les tests de cycle doivent toujours arrêter leur propre serveur.
|
||||
subprocess.run([sys.executable, str(WEB / "web_app.py"), "stop"],
|
||||
env=self.environment, capture_output=True, timeout=8)
|
||||
self.temporary.cleanup()
|
||||
|
||||
|
||||
class LauncherTest(SyntheticEnvironment):
|
||||
def command(self, *arguments, timeout=8):
|
||||
return subprocess.run([sys.executable, str(WEB / "web_app.py"), *arguments],
|
||||
cwd=self.root, env=self.environment, text=True, capture_output=True,
|
||||
timeout=timeout, check=False)
|
||||
|
||||
def test_default_and_invalid_ports(self):
|
||||
parser = web_app.build_parser()
|
||||
arguments = parser.parse_args(["serve", "--workspace", str(self.root / "w")])
|
||||
self.assertEqual(arguments.port, 8081)
|
||||
for port in ("-1", "65536", "abc"):
|
||||
with self.assertRaises(SystemExit):
|
||||
parser.parse_args(["serve", "--workspace", str(self.root / "w"),
|
||||
"--port", port])
|
||||
self.assertEqual(parser.parse_args(["serve", "--workspace",
|
||||
str(self.root / "w"), "--port", "0"]).port, 0)
|
||||
|
||||
def test_xdg_start_status_code_double_start_and_stop(self):
|
||||
workspace = self.root / "workspace"
|
||||
started = self.command("start", "--workspace", str(workspace), "--port", "0",
|
||||
"--bridge", str(self.bridge))
|
||||
self.assertEqual(started.returncode, 0, started.stderr)
|
||||
self.assertIn("Code de session éphémère : ", started.stdout)
|
||||
bootstrap = started.stdout.split("Code de session éphémère : ", 1)[1].strip()
|
||||
instance = (self.runtime / web_app.APP_DIRECTORY / "instance.json")
|
||||
self.assertTrue(instance.is_file())
|
||||
self.assertNotIn(bootstrap, instance.read_text(encoding="utf-8"))
|
||||
self.assertEqual(instance.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual((self.runtime / web_app.APP_DIRECTORY).stat().st_mode & 0o777,
|
||||
0o700)
|
||||
config = self.state / web_app.APP_DIRECTORY / "config.json"
|
||||
self.assertEqual(config.stat().st_mode & 0o777, 0o600)
|
||||
self.assertNotIn(bootstrap, config.read_text(encoding="utf-8"))
|
||||
status = self.command("status")
|
||||
self.assertEqual(status.returncode, 0, status.stderr)
|
||||
self.assertIn("actif", status.stdout)
|
||||
code = self.command("code")
|
||||
self.assertEqual(code.returncode, 0, code.stderr)
|
||||
self.assertEqual(code.stdout.strip(), bootstrap)
|
||||
duplicate = self.command("start", "--workspace", str(workspace), "--port", "0",
|
||||
"--bridge", str(self.bridge))
|
||||
self.assertNotEqual(duplicate.returncode, 0)
|
||||
self.assertIn("déjà active", duplicate.stderr)
|
||||
stopped = self.command("stop")
|
||||
self.assertEqual(stopped.returncode, 0, stopped.stderr)
|
||||
self.assertFalse(instance.exists())
|
||||
|
||||
def test_occupied_port_does_not_disturb_witness(self):
|
||||
witness = socket.socket()
|
||||
witness.bind(("127.0.0.1", 0))
|
||||
witness.listen()
|
||||
port = witness.getsockname()[1]
|
||||
result = self.command("serve", "--workspace", str(self.root / "occupied"),
|
||||
"--port", str(port), "--bridge", str(self.bridge))
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
witness.settimeout(0.2)
|
||||
probe = socket.create_connection(("127.0.0.1", port), timeout=1)
|
||||
accepted, _ = witness.accept()
|
||||
probe.close()
|
||||
accepted.close()
|
||||
witness.close()
|
||||
|
||||
def test_stale_state_never_signals_unconfirmed_pid(self):
|
||||
with mock.patch.dict(os.environ, self.environment, clear=True):
|
||||
files = web_app.InstanceFiles()
|
||||
web_app._atomic_json(files.instance, {
|
||||
"pid": os.getpid(), "proc_start": web_app._proc_start(os.getpid()),
|
||||
"port": 1, "instance_id": "foreign", "config_id": "foreign"})
|
||||
with mock.patch.object(web_app.os, "kill") as kill:
|
||||
self.assertEqual(web_app.stop(None), 1)
|
||||
kill.assert_not_called()
|
||||
|
||||
|
||||
class LibraryTest(SyntheticEnvironment):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.library = WebLibrary(self.root / "library", self.bridge)
|
||||
|
||||
def _creation_intent_workspace_id(self):
|
||||
intent = json.loads(self.library.creation_intent_path.read_text(encoding="utf-8"))
|
||||
return intent["workspace_id"]
|
||||
|
||||
def _assert_resumed_once(self, expected_workspace_id, response):
|
||||
self.assertEqual(response["workspace_id"], expected_workspace_id)
|
||||
self.assertEqual(len(list(self.library.workspaces.iterdir())), 1)
|
||||
self.assertEqual(len(self.library.snapshot()["entries"]), 1)
|
||||
self.assertFalse(self.library.creation_intent_path.exists())
|
||||
|
||||
def test_empty_create_replay_conflict_and_symlink_rejection(self):
|
||||
self.assertEqual(self.library.snapshot()["entries"], [])
|
||||
created = self.library.create("Enquête SPECIMEN", "intent-1")
|
||||
self.assertFalse(created["replayed"])
|
||||
replay = self.library.create("Enquête SPECIMEN", "intent-1")
|
||||
self.assertTrue(replay["replayed"])
|
||||
self.assertEqual(replay["workspace_id"], created["workspace_id"])
|
||||
with self.assertRaisesRegex(LibraryError, "autre titre"):
|
||||
self.library.create("Titre contradictoire", "intent-1")
|
||||
workspace = self.library.workspaces / created["workspace_id"]
|
||||
moved = self.root / "moved-workspace"
|
||||
workspace.rename(moved)
|
||||
workspace.symlink_to(moved, target_is_directory=True)
|
||||
with self.assertRaisesRegex(LibraryError, "invalide"):
|
||||
self.library.snapshot()
|
||||
|
||||
def test_create_resumes_same_workspace_after_bridge_interruption(self):
|
||||
run = subprocess.run
|
||||
calls = 0
|
||||
|
||||
def interrupt_after_bridge(*arguments, **keywords):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
result = run(*arguments, **keywords)
|
||||
raise OSError("interruption SPECIMEN après bridge")
|
||||
|
||||
with mock.patch("web_library.subprocess.run", side_effect=interrupt_after_bridge):
|
||||
with self.assertRaisesRegex(OSError, "après bridge"):
|
||||
self.library.create("Reprise bridge SPECIMEN", "resume-bridge")
|
||||
workspace_id = self._creation_intent_workspace_id()
|
||||
self.library = WebLibrary(self.library.root, self.bridge)
|
||||
resumed = self.library.create("Reprise bridge SPECIMEN", "resume-bridge")
|
||||
|
||||
self.assertEqual(calls, 1)
|
||||
self._assert_resumed_once(workspace_id, resumed)
|
||||
|
||||
def test_create_resumes_same_workspace_after_validation_interruption(self):
|
||||
validate = WebLibrary._validate_created_workspace
|
||||
validations = 0
|
||||
|
||||
def interrupt_after_validation(workspace):
|
||||
nonlocal validations
|
||||
validate(workspace)
|
||||
validations += 1
|
||||
if validations == 1:
|
||||
raise OSError("interruption SPECIMEN après validation")
|
||||
|
||||
with mock.patch.object(WebLibrary, "_validate_created_workspace",
|
||||
side_effect=interrupt_after_validation):
|
||||
with self.assertRaisesRegex(OSError, "après validation"):
|
||||
self.library.create("Reprise validation SPECIMEN", "resume-validation")
|
||||
workspace_id = self._creation_intent_workspace_id()
|
||||
|
||||
self.library = WebLibrary(self.library.root, self.bridge)
|
||||
with mock.patch("web_library.subprocess.run") as bridge:
|
||||
resumed = self.library.create(
|
||||
"Reprise validation SPECIMEN", "resume-validation")
|
||||
bridge.assert_not_called()
|
||||
self._assert_resumed_once(workspace_id, resumed)
|
||||
|
||||
def test_create_republishes_same_workspace_after_registry_write_failure(self):
|
||||
with mock.patch.object(self.library, "_write_registry",
|
||||
side_effect=OSError("écriture registre SPECIMEN")):
|
||||
with self.assertRaisesRegex(OSError, "écriture registre"):
|
||||
self.library.create("Reprise registre SPECIMEN", "resume-registry")
|
||||
workspace_id = self._creation_intent_workspace_id()
|
||||
|
||||
self.library = WebLibrary(self.library.root, self.bridge)
|
||||
with mock.patch("web_library.subprocess.run") as bridge:
|
||||
resumed = self.library.create("Reprise registre SPECIMEN", "resume-registry")
|
||||
bridge.assert_not_called()
|
||||
self._assert_resumed_once(workspace_id, resumed)
|
||||
|
||||
def test_create_finishes_after_late_registry_publication_failure(self):
|
||||
write_registry = self.library._write_registry
|
||||
|
||||
def interrupt_after_publication(registry):
|
||||
write_registry(registry)
|
||||
raise OSError("interruption SPECIMEN après publication")
|
||||
|
||||
with mock.patch.object(self.library, "_write_registry",
|
||||
side_effect=interrupt_after_publication):
|
||||
with self.assertRaisesRegex(OSError, "après publication"):
|
||||
self.library.create("Publication SPECIMEN", "resume-published")
|
||||
workspace_id = self._creation_intent_workspace_id()
|
||||
|
||||
self.library = WebLibrary(self.library.root, self.bridge)
|
||||
with mock.patch("web_library.subprocess.run") as bridge:
|
||||
resumed = self.library.create("Publication SPECIMEN", "resume-published")
|
||||
bridge.assert_not_called()
|
||||
self.assertTrue(resumed["replayed"])
|
||||
self._assert_resumed_once(workspace_id, resumed)
|
||||
|
||||
def test_authenticated_routes_generation_stale_and_busy(self):
|
||||
server = WorkspaceServer(("127.0.0.1", 0), Handler,
|
||||
workspace=self.library.root / ".inactive", bridge=self.bridge,
|
||||
bootstrap="SPECIMEN-bootstrap", library=self.library,
|
||||
instance_id="SPECIMEN-instance", config_id="SPECIMEN-config")
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
cookie = None
|
||||
|
||||
def request(method, path, body=None, *, authenticated=True, csrf=True):
|
||||
nonlocal cookie
|
||||
connection = http.client.HTTPConnection("127.0.0.1", server.server_port,
|
||||
timeout=3)
|
||||
headers = {"Host": server.authority}
|
||||
if method == "POST":
|
||||
headers.update({"Origin": server.origin, "Content-Type": "application/json"})
|
||||
if csrf: headers["X-Labfy-CSRF"] = server.csrf
|
||||
if authenticated and cookie: headers["Cookie"] = cookie
|
||||
payload = json.dumps(body) if body is not None else None
|
||||
connection.request(method, path, body=payload, headers=headers)
|
||||
response = connection.getresponse()
|
||||
data = response.read()
|
||||
if response.getheader("Set-Cookie"):
|
||||
cookie = response.getheader("Set-Cookie").split(";", 1)[0]
|
||||
result = response.status, json.loads(data)
|
||||
connection.close()
|
||||
return result
|
||||
|
||||
try:
|
||||
self.assertEqual(request("GET", "/api/v1/library", authenticated=False)[0], 401)
|
||||
self.assertEqual(request("POST", "/api/v1/session",
|
||||
{"bootstrap_code": "SPECIMEN-bootstrap"}, authenticated=False,
|
||||
csrf=False)[0], 200)
|
||||
self.assertEqual(request("POST", "/api/v1/library/workspaces",
|
||||
{"title": "Sans CSRF", "idempotency_key": "x"}, csrf=False)[0], 403)
|
||||
status, empty = request("GET", "/api/v1/library")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(empty["generation"], 0)
|
||||
status, first = request("POST", "/api/v1/library/workspaces",
|
||||
{"title": "Première SPECIMEN", "idempotency_key": "create-1"})
|
||||
self.assertEqual(status, 201, first)
|
||||
time.sleep(0.04)
|
||||
status, replay = request("POST", "/api/v1/library/workspaces",
|
||||
{"title": "Première SPECIMEN", "idempotency_key": "create-1"})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(replay["replayed"])
|
||||
time.sleep(0.04)
|
||||
status, second = request("POST", "/api/v1/library/workspaces",
|
||||
{"title": "Seconde SPECIMEN", "idempotency_key": "create-2"})
|
||||
self.assertEqual(status, 201, second)
|
||||
time.sleep(0.04)
|
||||
stale = request("POST", "/api/v1/library/open", {
|
||||
"workspace_id": first["workspace_id"], "expected_generation": 1})
|
||||
self.assertEqual(stale[0], 409)
|
||||
time.sleep(0.04)
|
||||
opened = request("POST", "/api/v1/library/open", {
|
||||
"workspace_id": first["workspace_id"],
|
||||
"expected_generation": second["generation"]})
|
||||
self.assertEqual(opened[0], 200, opened)
|
||||
time.sleep(0.04)
|
||||
busy = request("POST", "/api/v1/library/open", {
|
||||
"workspace_id": second["workspace_id"],
|
||||
"expected_generation": second["generation"]})
|
||||
self.assertEqual(busy[0], 409)
|
||||
session = request("GET", "/api/v1/session")[1]
|
||||
self.assertTrue(session["library_mode"])
|
||||
self.assertEqual(session["active_workspace_id"], first["workspace_id"])
|
||||
self.assertEqual(session["generation"], second["generation"])
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
369
prototypes/web-graph/tests/test_web_app_browser.mjs
Normal file
369
prototypes/web-graph/tests/test_web_app_browser.mjs
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
|
||||
const PROTOTYPE = new URL("..", import.meta.url);
|
||||
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
|
||||
|
||||
function startServer(library, runtime, state, port = 0) {
|
||||
return spawn("python3", ["web_app.py", "serve", "--library", library,
|
||||
"--bridge", "../../tools/local-jobs", "--port", String(port)], {
|
||||
cwd: PROTOTYPE,
|
||||
env: { ...process.env, PYTHONUNBUFFERED: "1", XDG_RUNTIME_DIR: runtime,
|
||||
XDG_STATE_HOME: state },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function ready(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
const timer = setTimeout(() => reject(new Error("démarrage Web expiré")), 10000);
|
||||
server.stdout.on("data", (chunk) => {
|
||||
output += chunk;
|
||||
const origin = output.match(/Labfy Web : (http:\/\/127\.0\.0\.1:\d+)\//);
|
||||
const code = output.match(/Code de session éphémère : ([^\s]+)/);
|
||||
if (origin && code) {
|
||||
clearTimeout(timer);
|
||||
resolve({ origin: origin[1], code: code[1] });
|
||||
}
|
||||
});
|
||||
server.once("exit", (code) => reject(new Error(`serveur arrêté (${code})`)));
|
||||
});
|
||||
}
|
||||
|
||||
async function stopServer(server) {
|
||||
if (!server || server.exitCode !== null) return;
|
||||
const exited = new Promise((resolve) => server.once("exit", resolve));
|
||||
server.kill("SIGTERM");
|
||||
await exited;
|
||||
}
|
||||
|
||||
async function login(page, session) {
|
||||
try {
|
||||
await page.evaluate((origin) => window.location.assign(origin), session.origin);
|
||||
} catch (error) {
|
||||
if (!String(error).includes("Execution context was destroyed")) throw error;
|
||||
}
|
||||
await page.waitForSelector("#login-form");
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await page.click("#login-form button");
|
||||
await page.waitForSelector("#library-home:not([hidden])");
|
||||
assert.equal(page.url().includes(session.code), false);
|
||||
assert.equal((await page.content()).includes(session.code), false);
|
||||
}
|
||||
|
||||
async function createWorkspace(page, title) {
|
||||
await page.type("#workspace-title", title);
|
||||
await page.click("#workspace-create-form button");
|
||||
await page.waitForFunction((expectedTitle) => [...document.querySelectorAll(".workspace-card")]
|
||||
.some((card) => card.querySelector("h3")?.textContent === expectedTitle), {}, title);
|
||||
return page.$$eval(".workspace-card", (cards, expectedTitle) => {
|
||||
const card = cards.find((item) => item.querySelector("h3")?.textContent === expectedTitle);
|
||||
return card?.querySelector("[data-workspace-id]")?.dataset.workspaceId ?? null;
|
||||
}, title);
|
||||
}
|
||||
|
||||
async function openWorkspace(page, workspaceId) {
|
||||
await page.click(`[data-workspace-id="${workspaceId}"]`);
|
||||
await page.waitForSelector("#workbench-shell:not([hidden])");
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const connection = document.querySelector("#connection")?.textContent ?? "";
|
||||
return connection.includes("Ouverte") || connection.includes("Poste local synchronisé");
|
||||
}, { timeout: 15000 });
|
||||
} catch (error) {
|
||||
const connection = await page.$eval("#connection", (item) => item.textContent);
|
||||
throw new Error(`ouverture ${workspaceId} incomplète : ${connection}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDownload(path) {
|
||||
let size = -1;
|
||||
let stable = 0;
|
||||
for (let attempt = 0; attempt < 120 && stable < 3; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
try {
|
||||
const current = (await stat(path)).size;
|
||||
stable = current > 0 && current === size ? stable + 1 : 0;
|
||||
size = current;
|
||||
} catch {
|
||||
stable = 0;
|
||||
}
|
||||
}
|
||||
assert.ok(stable >= 3, `téléchargement Firefox terminé ${path}`);
|
||||
return size;
|
||||
}
|
||||
|
||||
const library = await mkdtemp(join(tmpdir(), "labfy-web-library-"));
|
||||
const profile = await mkdtemp(join(tmpdir(), "labfy-web-firefox-"));
|
||||
const runtime = await mkdtemp(join(tmpdir(), "labfy-web-runtime-"));
|
||||
const state = await mkdtemp(join(tmpdir(), "labfy-web-state-"));
|
||||
const fixtures = await mkdtemp(join(tmpdir(), "labfy-web-SPECIMEN-"));
|
||||
const downloads = join(fixtures, "downloads");
|
||||
await mkdir(downloads, { mode: 0o700 });
|
||||
const specimenEml = join(fixtures, "courrier rapport SPECIMEN.eml");
|
||||
await writeFile(specimenEml,
|
||||
"From: Source SPECIMEN <source@example.test>\r\n" +
|
||||
"To: Pivot SPECIMEN <pivot@example.test>\r\n" +
|
||||
"Message-ID: <web-app-report@example.test>\r\n" +
|
||||
"Subject: Rapport navigateur A\r\n\r\n" +
|
||||
"Contenu synthétique pour le parcours A.\r\n");
|
||||
let browser;
|
||||
let server;
|
||||
try {
|
||||
server = startServer(library, runtime, state);
|
||||
const session = await ready(server);
|
||||
const assignedPort = Number.parseInt(new URL(session.origin).port, 10);
|
||||
browser = await puppeteer.launch({ browser: "firefox", executablePath: FIREFOX,
|
||||
headless: true, userDataDir: profile, args: ["--no-remote"],
|
||||
extraPrefsFirefox: { "browser.download.folderList": 2,
|
||||
"browser.download.dir": downloads, "browser.download.useDownloadDir": true,
|
||||
"browser.helperApps.neverAsk.saveToDisk":
|
||||
"text/html,application/json,application/pdf,text/plain" } });
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
await page.setViewport({ width: 700, height: 900 });
|
||||
await login(page, session);
|
||||
|
||||
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 0);
|
||||
assert.match(await page.$eval("#library-empty", (item) => item.textContent), /Aucune enquête/);
|
||||
|
||||
const workspaceATitle = "Enquête A navigateur SPECIMEN";
|
||||
const workspaceAId = await createWorkspace(page, workspaceATitle);
|
||||
assert.ok(workspaceAId);
|
||||
assert.equal(await page.$eval("#workbench-shell", (item) => item.hidden), true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await openWorkspace(page, workspaceAId);
|
||||
assert.equal(await page.$eval("#investigation-title", (item) => item.textContent),
|
||||
workspaceATitle);
|
||||
assert.equal(await page.$eval("#mode-badge", (item) => item.textContent.includes("SPECIMEN")), false);
|
||||
|
||||
await page.click("#open-import");
|
||||
const importInput = await page.$("#import-files");
|
||||
await importInput.uploadFile(specimenEml);
|
||||
await page.waitForFunction(() => {
|
||||
const items = [...document.querySelectorAll("#import-list li")];
|
||||
return items.length === 1 && items.every((item) => item.textContent.includes("préparé"));
|
||||
}, { timeout: 20000 });
|
||||
await page.click("#import-confirm");
|
||||
await page.waitForFunction(() => document.querySelectorAll(".node").length >= 1,
|
||||
{ timeout: 20000 });
|
||||
|
||||
await page.click('[data-work-panel="next-panel"]');
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelectorAll("#planner-list input:not(:disabled)").length >= 1,
|
||||
{ timeout: 15000 });
|
||||
await page.$$eval("#planner-list input:not(:disabled)", (inputs) => {
|
||||
inputs.forEach((input) => input.click());
|
||||
});
|
||||
await page.select("#planner-profile", "LOCAL_PRUDENT");
|
||||
await page.click("#planner-launch");
|
||||
await page.waitForFunction(() => {
|
||||
const jobs = [...document.querySelectorAll("#jobs-list li")];
|
||||
return jobs.length >= 1 && jobs.every((job) => job.dataset.jobState === "COMPLETED");
|
||||
}, { timeout: 45000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const candidate = [...document.querySelectorAll("#object-list button")]
|
||||
.find((button) => button.textContent.startsWith("email —"));
|
||||
if (!candidate) throw new Error("preuve e-mail SPECIMEN absente");
|
||||
candidate.click();
|
||||
});
|
||||
await page.click('[data-panel="actions-panel"]');
|
||||
await page.evaluate(() => {
|
||||
const button = [...document.querySelectorAll("#actions button")]
|
||||
.find((item) => item.textContent === "Ouvrir la preuve");
|
||||
if (!button) throw new Error("action Ouvrir la preuve absente");
|
||||
button.click();
|
||||
});
|
||||
await page.waitForSelector("#evidence-dialog[open] #observation-list li", { timeout: 15000 });
|
||||
await page.type("#observation-list li input[placeholder='Motif obligatoire']",
|
||||
"Revue rapport A SPECIMEN");
|
||||
await page.evaluate(() => {
|
||||
const button = [...document.querySelectorAll("#observation-list li:first-child button")]
|
||||
.find((item) => item.textContent === "Confirmer");
|
||||
if (!button) throw new Error("action Confirmer absente");
|
||||
button.click();
|
||||
});
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector("#evidence-status")?.textContent.includes("journalisée"),
|
||||
{ timeout: 15000 });
|
||||
await page.click("#evidence-dialog .dialog-close button");
|
||||
|
||||
await page.click('[data-report-action="toggle"]');
|
||||
await page.click('[data-work-panel="reports-panel"]');
|
||||
await page.click("#report-name", { clickCount: 3 });
|
||||
await page.type("#report-name", "Rapport enquête A SPECIMEN");
|
||||
await page.click("#report-preview");
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector("#report-preview-content")?.textContent.includes("Coupe"),
|
||||
{ timeout: 15000 });
|
||||
await page.click("#report-generate");
|
||||
await page.waitForFunction(() => document.querySelectorAll("#report-status a").length === 5,
|
||||
{ timeout: 30000 });
|
||||
|
||||
const reportHref = await page.$eval('#report-status a[href$="report.json"]',
|
||||
(link) => link.getAttribute("href"));
|
||||
const reportId = reportHref.split("/")[4];
|
||||
const artifactNames = ["report.json", "report.html", "report.pdf", "manifest.json",
|
||||
"NOTICE.txt"];
|
||||
const receivedSizes = new Map();
|
||||
for (const name of artifactNames) {
|
||||
await page.$eval(`#report-status a[href$="${name}"]`, (link) => link.click());
|
||||
receivedSizes.set(name, await waitForDownload(join(downloads, name)));
|
||||
}
|
||||
const manifestBytes = await readFile(join(downloads, "manifest.json"));
|
||||
const manifest = JSON.parse(manifestBytes);
|
||||
assert.equal(manifest.contract, "labfy.report_manifest.v1");
|
||||
assert.equal(manifest.report_id, reportId);
|
||||
assert.deepEqual(new Set(manifest.files.map((entry) => entry.path)),
|
||||
new Set(artifactNames.filter((name) => name !== "manifest.json")));
|
||||
for (const entry of manifest.files) {
|
||||
const bytes = await readFile(join(downloads, entry.path));
|
||||
assert.equal(bytes.length, entry.size, `taille manifestée ${entry.path}`);
|
||||
assert.equal(receivedSizes.get(entry.path), entry.size, `taille reçue ${entry.path}`);
|
||||
assert.equal(createHash("sha256").update(bytes).digest("hex"), entry.sha256,
|
||||
`SHA-256 ${entry.path}`);
|
||||
}
|
||||
assert.equal(receivedSizes.get("manifest.json"), manifestBytes.length,
|
||||
"taille reçue manifest.json");
|
||||
const reportHtml = (await readFile(join(downloads, "report.html"))).toString("utf8");
|
||||
assert.doesNotMatch(reportHtml, /<(?:script|iframe|frame|object|embed|link)\b/i);
|
||||
assert.doesNotMatch(reportHtml, /\b(?:src|href|action)\s*=\s*["'](?:https?:|\/\/)/i);
|
||||
assert.doesNotMatch(reportHtml, /url\s*\(/i);
|
||||
const offlinePage = await browser.newPage();
|
||||
await offlinePage.goto(`file://${join(downloads, "report.html")}`,
|
||||
{ waitUntil: "domcontentloaded" });
|
||||
assert.match(await offlinePage.$eval("body", (body) => body.textContent),
|
||||
/Rapport enquête A SPECIMEN/);
|
||||
assert.equal(await offlinePage.$$eval(
|
||||
"script,iframe,frame,object,embed,link,img,audio,video,source",
|
||||
(nodes) => nodes.length), 0);
|
||||
await offlinePage.close();
|
||||
const pdfText = spawnSync("pdftotext", [join(downloads, "report.pdf"), "-"],
|
||||
{ encoding: "utf8" });
|
||||
assert.equal(pdfText.status, 0, pdfText.stderr);
|
||||
assert.match(pdfText.stdout.split(/\s+/).join(" "), /Rapport enquête A SPECIMEN/);
|
||||
|
||||
await page.$eval("#report-name", (input) => {
|
||||
input.value = "Brouillon rapport A SPECIMEN";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
let draftKeys = await page.evaluate(() => Object.keys(sessionStorage)
|
||||
.filter((key) => key.startsWith("labfy-report-draft:")));
|
||||
assert.equal(draftKeys.length, 1);
|
||||
assert.ok(draftKeys[0].includes(workspaceAId));
|
||||
|
||||
// INVARIANT: une actualisation devenue tardive après « Accueil » ne doit pas
|
||||
// restaurer le workbench ni altérer la bibliothèque rendue ensuite.
|
||||
await page.setRequestInterception(true);
|
||||
let releaseSnapshot;
|
||||
const delayedSnapshot = new Promise((resolve) => { releaseSnapshot = resolve; });
|
||||
let markSnapshotContinued;
|
||||
const snapshotContinued = new Promise((resolve) => { markSnapshotContinued = resolve; });
|
||||
let held = false;
|
||||
const holdSnapshotRequest = async (request) => {
|
||||
if (!held && new URL(request.url()).pathname === "/api/v1/snapshot") {
|
||||
held = true;
|
||||
await delayedSnapshot;
|
||||
await request.continue().catch(() => {});
|
||||
markSnapshotContinued();
|
||||
return;
|
||||
}
|
||||
await request.continue().catch(() => {});
|
||||
};
|
||||
page.on("request", holdSnapshotRequest);
|
||||
await page.waitForFunction(() => window.__LABFY_TEST__.getState().workspaceId !== null);
|
||||
for (let attempt = 0; attempt < 100 && !held; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
assert.equal(held, true);
|
||||
await page.click("#home-button");
|
||||
await page.waitForSelector("#library-home:not([hidden])");
|
||||
releaseSnapshot();
|
||||
await snapshotContinued;
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
assert.equal(await page.$eval("#workbench-shell", (item) => item.hidden), true);
|
||||
assert.match(await page.$eval(".workspace-card p", (item) => item.textContent), /Ouverte/);
|
||||
await page.setRequestInterception(false);
|
||||
page.off("request", holdSnapshotRequest);
|
||||
|
||||
// Le port initial est attribué par le système. Sa réutilisation conserve le
|
||||
// même origin et permet de vérifier les brouillons session après redémarrage.
|
||||
await stopServer(server);
|
||||
server = startServer(library, runtime, state, assignedPort);
|
||||
const restartedForB = await ready(server);
|
||||
assert.equal(restartedForB.origin, session.origin);
|
||||
await login(page, restartedForB);
|
||||
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 1);
|
||||
assert.equal(await page.$eval(".workspace-card h3", (item) => item.textContent), workspaceATitle);
|
||||
assert.match(await page.$eval(".workspace-card p", (item) => item.textContent), /Prête à ouvrir/);
|
||||
|
||||
const workspaceBTitle = "Enquête B navigateur SPECIMEN";
|
||||
const workspaceBId = await createWorkspace(page, workspaceBTitle);
|
||||
assert.ok(workspaceBId);
|
||||
assert.notEqual(workspaceBId, workspaceAId);
|
||||
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 2);
|
||||
await openWorkspace(page, workspaceBId);
|
||||
assert.equal(await page.$eval("#investigation-title", (item) => item.textContent),
|
||||
workspaceBTitle);
|
||||
assert.equal(await page.$eval("#report-name", (item) => item.value), "Rapport d’enquête");
|
||||
draftKeys = await page.evaluate(() => Object.keys(sessionStorage)
|
||||
.filter((key) => key.startsWith("labfy-report-draft:")));
|
||||
assert.equal(draftKeys.length, 1);
|
||||
assert.ok(draftKeys[0].includes(workspaceAId));
|
||||
assert.equal(draftKeys[0].includes(workspaceBId), false);
|
||||
|
||||
await page.$eval("#report-name", (input) => {
|
||||
input.value = "Brouillon rapport B SPECIMEN";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
draftKeys = await page.evaluate(() => Object.keys(sessionStorage)
|
||||
.filter((key) => key.startsWith("labfy-report-draft:")));
|
||||
assert.equal(draftKeys.length, 2);
|
||||
assert.equal(draftKeys.some((key) => key.includes(workspaceAId)), true);
|
||||
assert.equal(draftKeys.some((key) => key.includes(workspaceBId)), true);
|
||||
|
||||
await page.click("#home-button");
|
||||
await page.waitForSelector("#library-home:not([hidden])");
|
||||
await stopServer(server);
|
||||
server = startServer(library, runtime, state, assignedPort);
|
||||
const restartedForA = await ready(server);
|
||||
assert.equal(restartedForA.origin, session.origin);
|
||||
await login(page, restartedForA);
|
||||
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 2);
|
||||
await openWorkspace(page, workspaceAId);
|
||||
assert.equal(await page.$eval("#investigation-title", (item) => item.textContent),
|
||||
workspaceATitle);
|
||||
assert.equal(await page.evaluate(() => window.__LABFY_TEST__.getState().workspaceId),
|
||||
workspaceAId);
|
||||
// La création de B incrémente la génération de bibliothèque : le brouillon
|
||||
// A de la génération précédente reste stocké mais ne peut contaminer A ou B.
|
||||
assert.equal(await page.$eval("#report-name", (item) => item.value),
|
||||
"Rapport d’enquête");
|
||||
assert.notEqual(await page.$eval("#report-name", (item) => item.value),
|
||||
"Brouillon rapport B SPECIMEN");
|
||||
draftKeys = await page.evaluate(() => Object.keys(sessionStorage)
|
||||
.filter((key) => key.startsWith("labfy-report-draft:")));
|
||||
assert.equal(draftKeys.length, 2);
|
||||
assert.equal(draftKeys.some((key) => key.includes(workspaceAId)), true);
|
||||
assert.equal(draftKeys.some((key) => key.includes(workspaceBId)), true);
|
||||
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = "200%"; });
|
||||
assert.equal(await page.evaluate(() =>
|
||||
document.documentElement.scrollWidth > document.documentElement.clientWidth), false);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log("PASS navigateur Web : A importée/analysée/revue avec rapport vérifié, " +
|
||||
"bibliothèque A/B, brouillons isolés et deux redémarrages");
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await stopServer(server);
|
||||
await Promise.all([library, profile, runtime, state, fixtures].map((path) =>
|
||||
rm(path, { recursive: true, force: true })));
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ try {
|
|||
await Promise.all([page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.click("#login-form button")]);
|
||||
await page.waitForSelector(`[data-id="evidence:${manifest.eml_id}"]`);
|
||||
assert.match(await page.$eval("#mode-badge", (item) => item.textContent), /Poste J6/);
|
||||
assert.match(await page.$eval("#mode-badge", (item) => item.textContent), /Poste local/);
|
||||
|
||||
await page.click("#queue-pause");
|
||||
await clickEvidenceCapability(page, manifest.eml_id,
|
||||
|
|
|
|||
372
prototypes/web-graph/web_app.py
Normal file
372
prototypes/web-graph/web_app.py
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Lanceur local explicite et gestionnaire d'instance du poste Web Labfy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import secrets
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from web_library import WebLibrary
|
||||
from workspace_server import Handler, WorkspaceServer
|
||||
|
||||
DEFAULT_PORT = 8081
|
||||
APP_DIRECTORY = "labfy-investigation-web"
|
||||
|
||||
|
||||
def valid_port(value):
|
||||
try:
|
||||
port = int(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise argparse.ArgumentTypeError("le port doit être un entier") from error
|
||||
if not 0 <= port <= 65535:
|
||||
raise argparse.ArgumentTypeError("le port doit être compris entre 0 et 65535")
|
||||
return port
|
||||
|
||||
|
||||
def _private_directory(path: Path):
|
||||
absolute = Path(os.path.abspath(path))
|
||||
for component in reversed((absolute, *absolute.parents)):
|
||||
if component.exists() and component.is_symlink():
|
||||
raise RuntimeError(f"Lien symbolique interdit dans l'état : {component}")
|
||||
path = absolute
|
||||
if path.exists() and (path.is_symlink() or not path.is_dir()):
|
||||
raise RuntimeError(f"Répertoire d'état non sûr : {path}")
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if path.stat().st_uid != os.getuid():
|
||||
raise RuntimeError(f"Répertoire d'état d'un autre utilisateur : {path}")
|
||||
os.chmod(path, 0o700)
|
||||
return path
|
||||
|
||||
|
||||
def runtime_directory(env=None):
|
||||
env = os.environ if env is None else env
|
||||
base = env.get("XDG_RUNTIME_DIR")
|
||||
if base:
|
||||
root = Path(base)
|
||||
if root.is_symlink() or not root.is_dir() or root.stat().st_uid != os.getuid():
|
||||
raise RuntimeError("XDG_RUNTIME_DIR non sûr")
|
||||
else:
|
||||
# WHY: le repli est propre à l'UID et non partageable dans /tmp.
|
||||
root = Path(f"/tmp/labfy-runtime-{os.getuid()}")
|
||||
return _private_directory(root / APP_DIRECTORY)
|
||||
|
||||
|
||||
def state_directory(env=None):
|
||||
env = os.environ if env is None else env
|
||||
base = Path(env.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
|
||||
return _private_directory(base / APP_DIRECTORY)
|
||||
|
||||
|
||||
def _atomic_json(path, value):
|
||||
temporary = path.with_name(f".{path.name}-{secrets.token_hex(8)}.tmp")
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600)
|
||||
try:
|
||||
payload = (json.dumps(value, ensure_ascii=False, sort_keys=True,
|
||||
separators=(",", ":")) + "\n").encode()
|
||||
os.write(descriptor, payload)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _read_private_json(path):
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_uid != os.getuid():
|
||||
raise RuntimeError("Fichier d'instance absent ou non sûr")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _proc_start(pid):
|
||||
try:
|
||||
# /proc/PID/stat place le nom entre parenthèses ; le champ 22 est le 20e
|
||||
# champ après la dernière parenthèse.
|
||||
fields = Path(f"/proc/{pid}/stat").read_text(encoding="ascii").rsplit(")", 1)[1].split()
|
||||
return fields[19]
|
||||
except (OSError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _config(selection, bridge):
|
||||
mode, path = selection
|
||||
value = {"mode": mode, "path": str(path.absolute()), "bridge": str(bridge.resolve())}
|
||||
digest = hashlib.sha256(json.dumps(value, sort_keys=True,
|
||||
separators=(",", ":")).encode()).hexdigest()
|
||||
return value, digest
|
||||
|
||||
|
||||
def _health(state, timeout=0.8):
|
||||
try:
|
||||
connection = http.client.HTTPConnection("127.0.0.1", state["port"], timeout=timeout)
|
||||
connection.request("GET", "/healthz", headers={"Host": f"127.0.0.1:{state['port']}"})
|
||||
response = connection.getresponse()
|
||||
value = json.loads(response.read())
|
||||
connection.close()
|
||||
return (response.status == 200 and value.get("instance_id") == state["instance_id"] and
|
||||
value.get("config_id") == state["config_id"])
|
||||
except (OSError, ValueError, KeyError, json.JSONDecodeError):
|
||||
return False
|
||||
|
||||
|
||||
def _identity_valid(state):
|
||||
return (isinstance(state, dict) and isinstance(state.get("pid"), int) and
|
||||
not isinstance(state.get("pid"), bool) and state["pid"] > 1 and
|
||||
isinstance(state.get("proc_start"), str) and
|
||||
state.get("proc_start") == _proc_start(state["pid"]) and _health(state))
|
||||
|
||||
|
||||
class InstanceFiles:
|
||||
def __init__(self):
|
||||
self.runtime = runtime_directory()
|
||||
self.state_root = state_directory()
|
||||
self.instance = self.runtime / "instance.json"
|
||||
self.secret = self.runtime / "bootstrap.code"
|
||||
self.config = self.state_root / "config.json"
|
||||
self.lifecycle = self.runtime / "lifecycle.lock"
|
||||
self.owner = self.runtime / "instance.lock"
|
||||
|
||||
def lifecycle_lock(self):
|
||||
descriptor = os.open(self.lifecycle, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
return descriptor
|
||||
|
||||
def clear_stale(self):
|
||||
for path in (self.instance, self.secret):
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _selection(args):
|
||||
if args.library is not None:
|
||||
return "library", args.library.absolute()
|
||||
return "workspace", args.workspace.absolute()
|
||||
|
||||
|
||||
def _handshake(fd, value):
|
||||
if fd is None:
|
||||
return
|
||||
with os.fdopen(fd, "w", encoding="utf-8", closefd=True) as output:
|
||||
output.write(json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||
output.flush()
|
||||
|
||||
|
||||
def serve(args):
|
||||
files = InstanceFiles()
|
||||
owner = os.open(files.owner, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
fcntl.flock(owner, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
os.close(owner)
|
||||
_handshake(args.handshake_fd, {"ok": False, "error": "Une instance est déjà active"})
|
||||
return 1
|
||||
selection = _selection(args)
|
||||
config, config_id = _config(selection, args.bridge)
|
||||
instance_id = secrets.token_urlsafe(24)
|
||||
bootstrap = secrets.token_urlsafe(18)
|
||||
library = None
|
||||
if selection[0] == "library":
|
||||
library = WebLibrary(selection[1], args.bridge)
|
||||
workspace = library.root / ".inactive"
|
||||
workspace.mkdir(mode=0o700, exist_ok=True)
|
||||
else:
|
||||
workspace = selection[1]
|
||||
workspace.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
try:
|
||||
server = WorkspaceServer(("127.0.0.1", args.port), Handler,
|
||||
workspace=workspace, bridge=args.bridge, bootstrap=bootstrap,
|
||||
library=library, instance_id=instance_id, config_id=config_id)
|
||||
except OSError as error:
|
||||
_handshake(args.handshake_fd, {"ok": False,
|
||||
"error": f"Port indisponible : {error}"})
|
||||
os.close(owner)
|
||||
return 1
|
||||
state = {"contract": "labfy.web_instance.v1", "pid": os.getpid(),
|
||||
"proc_start": _proc_start(os.getpid()), "instance_id": instance_id,
|
||||
"config_id": config_id, "port": server.server_port, "config": config}
|
||||
try:
|
||||
# CONTRACT: la configuration durable ne contient jamais le secret
|
||||
# d'amorçage, qui reste exclusivement dans le runtime privé.
|
||||
_atomic_json(files.config, {"contract": "labfy.web_config.v1",
|
||||
"config_id": config_id, **config})
|
||||
_atomic_json(files.instance, state)
|
||||
descriptor = os.open(files.secret, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW,
|
||||
0o600)
|
||||
try:
|
||||
os.write(descriptor, (bootstrap + "\n").encode())
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
answer = {"ok": True, "origin": server.origin, "bootstrap_code": bootstrap,
|
||||
"instance_id": instance_id}
|
||||
_handshake(args.handshake_fd, answer)
|
||||
if args.handshake_fd is None:
|
||||
print(f"Labfy Web : {server.origin}/", flush=True)
|
||||
print(f"Code de session éphémère : {bootstrap}", flush=True)
|
||||
# INVARIANT: SIGTERM n'arrête que cette instance déjà authentifiée
|
||||
# par le lanceur ; shutdown s'exécute hors du gestionnaire de signal.
|
||||
def request_shutdown(_signum, _frame):
|
||||
threading.Thread(target=server.shutdown, daemon=True).start()
|
||||
signal.signal(signal.SIGTERM, request_shutdown)
|
||||
server.serve_forever()
|
||||
finally:
|
||||
server.stop_worker()
|
||||
server.server_close()
|
||||
try:
|
||||
current = _read_private_json(files.instance)
|
||||
if current.get("instance_id") == instance_id:
|
||||
files.clear_stale()
|
||||
except (OSError, RuntimeError, json.JSONDecodeError):
|
||||
pass
|
||||
os.close(owner)
|
||||
return 0
|
||||
|
||||
|
||||
def start(args):
|
||||
files = InstanceFiles()
|
||||
lifecycle = files.lifecycle_lock()
|
||||
try:
|
||||
try:
|
||||
current = _read_private_json(files.instance)
|
||||
except (OSError, RuntimeError, json.JSONDecodeError):
|
||||
current = None
|
||||
if current is not None and _identity_valid(current):
|
||||
print(f"Instance déjà active sur http://127.0.0.1:{current['port']}/",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
files.clear_stale()
|
||||
read_fd, write_fd = os.pipe()
|
||||
command = [sys.executable, str(Path(__file__).resolve()), "serve",
|
||||
"--port", str(args.port), "--bridge", str(args.bridge),
|
||||
"--handshake-fd", str(write_fd)]
|
||||
mode, path = _selection(args)
|
||||
command.extend((f"--{mode}", str(path)))
|
||||
process = subprocess.Popen(command, stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True, pass_fds=(write_fd,), close_fds=True)
|
||||
os.close(write_fd)
|
||||
ready, _, _ = select.select((read_fd,), (), (), 10)
|
||||
if not ready:
|
||||
process.terminate()
|
||||
process.wait(timeout=3)
|
||||
os.close(read_fd)
|
||||
print("Démarrage expiré", file=sys.stderr)
|
||||
return 1
|
||||
with os.fdopen(read_fd, "r", encoding="utf-8") as pipe:
|
||||
line = pipe.readline()
|
||||
if not line:
|
||||
print("Le serveur n'a pas publié son état", file=sys.stderr)
|
||||
return 1
|
||||
response = json.loads(line)
|
||||
if not response.get("ok"):
|
||||
print(response.get("error", "Démarrage refusé"), file=sys.stderr)
|
||||
return 1
|
||||
print(f"Labfy Web : {response['origin']}/")
|
||||
print(f"Code de session éphémère : {response['bootstrap_code']}")
|
||||
return 0
|
||||
finally:
|
||||
os.close(lifecycle)
|
||||
|
||||
|
||||
def status(_args):
|
||||
files = InstanceFiles()
|
||||
try:
|
||||
state = _read_private_json(files.instance)
|
||||
except (OSError, RuntimeError, json.JSONDecodeError):
|
||||
print("arrêté")
|
||||
return 1
|
||||
if not _identity_valid(state):
|
||||
print("état périmé")
|
||||
return 1
|
||||
print(f"actif pid={state['pid']} origin=http://127.0.0.1:{state['port']}/")
|
||||
return 0
|
||||
|
||||
|
||||
def stop(_args):
|
||||
files = InstanceFiles()
|
||||
lifecycle = files.lifecycle_lock()
|
||||
try:
|
||||
try:
|
||||
state = _read_private_json(files.instance)
|
||||
except (OSError, RuntimeError, json.JSONDecodeError):
|
||||
print("Aucune instance active", file=sys.stderr)
|
||||
return 1
|
||||
# CONTRACT: ne jamais signaler un PID sur la seule foi d'un fichier d'état.
|
||||
if not _identity_valid(state):
|
||||
print("Instance périmée ou identité non confirmée", file=sys.stderr)
|
||||
return 1
|
||||
os.kill(state["pid"], signal.SIGTERM)
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline and _proc_start(state["pid"]) == state["proc_start"]:
|
||||
time.sleep(0.05)
|
||||
if _proc_start(state["pid"]) == state["proc_start"]:
|
||||
print("Arrêt expiré ; aucun signal forcé envoyé", file=sys.stderr)
|
||||
return 1
|
||||
files.clear_stale()
|
||||
print("arrêté")
|
||||
return 0
|
||||
finally:
|
||||
os.close(lifecycle)
|
||||
|
||||
|
||||
def code(_args):
|
||||
files = InstanceFiles()
|
||||
try:
|
||||
state = _read_private_json(files.instance)
|
||||
if not _identity_valid(state):
|
||||
raise RuntimeError("Instance inactive")
|
||||
if files.secret.is_symlink() or not files.secret.is_file():
|
||||
raise RuntimeError("Code indisponible")
|
||||
print(files.secret.read_text(encoding="utf-8").strip())
|
||||
return 0
|
||||
except (OSError, RuntimeError, json.JSONDecodeError) as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(description="Poste Web local Labfy")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
repository = Path(__file__).resolve().parents[2]
|
||||
for name, function in (("serve", serve), ("start", start)):
|
||||
command = subparsers.add_parser(name)
|
||||
targets = command.add_mutually_exclusive_group(required=True)
|
||||
targets.add_argument("--library", type=Path)
|
||||
targets.add_argument("--workspace", type=Path)
|
||||
command.add_argument("--port", type=valid_port, default=DEFAULT_PORT)
|
||||
command.add_argument("--bridge", type=Path, default=repository / "tools/local-jobs")
|
||||
if name == "serve":
|
||||
command.add_argument("--handshake-fd", type=int, help=argparse.SUPPRESS)
|
||||
command.set_defaults(function=function)
|
||||
for name, function in (("status", status), ("stop", stop), ("code", code)):
|
||||
command = subparsers.add_parser(name)
|
||||
command.set_defaults(function=function)
|
||||
help_command = subparsers.add_parser("help")
|
||||
help_command.set_defaults(function=lambda _args: (parser.print_help() or 0))
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return args.function(args)
|
||||
except (RuntimeError, ValueError, OSError, json.JSONDecodeError) as error:
|
||||
print(f"web-app: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
296
prototypes/web-graph/web_library.py
Normal file
296
prototypes/web-graph/web_library.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Bibliothèque locale privée d'espaces Labfy explicitement enregistrés."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
MAX_ENTRIES = 256
|
||||
REGISTRY_CONTRACT = "labfy.web_library.registry.v1"
|
||||
CREATION_INTENT_CONTRACT = "labfy.web_library.creation_intent.v1"
|
||||
|
||||
|
||||
class LibraryError(ValueError):
|
||||
"""Refus d'une opération de bibliothèque sans effet implicite sur le disque."""
|
||||
|
||||
|
||||
class WebLibrary:
|
||||
def __init__(self, root: Path, bridge: Path):
|
||||
self.root = Path(root).absolute()
|
||||
self.bridge = Path(bridge).resolve()
|
||||
self._prepare_private_root()
|
||||
self.workspaces = self.root / "workspaces"
|
||||
self.workspaces.mkdir(mode=0o700, exist_ok=True)
|
||||
self._assert_plain_directory(self.workspaces)
|
||||
self.registry_path = self.root / "registry.json"
|
||||
self.creation_intent_path = self.root / "creation-intent.json"
|
||||
self.lock_path = self.root / "registry.lock"
|
||||
|
||||
def _prepare_private_root(self):
|
||||
for component in reversed((self.root, *self.root.parents)):
|
||||
if component.exists() and component.is_symlink():
|
||||
raise LibraryError(
|
||||
f"Lien symbolique interdit dans la bibliothèque : {component}")
|
||||
if self.root.exists():
|
||||
self._assert_plain_directory(self.root)
|
||||
else:
|
||||
self.root.mkdir(mode=0o700, parents=True)
|
||||
os.chmod(self.root, 0o700)
|
||||
|
||||
@staticmethod
|
||||
def _assert_plain_directory(path: Path):
|
||||
# INVARIANT: aucun composant géré ne peut rediriger la bibliothèque.
|
||||
if path.is_symlink() or not path.is_dir():
|
||||
raise LibraryError(f"Répertoire de bibliothèque invalide : {path}")
|
||||
|
||||
def _locked(self):
|
||||
descriptor = os.open(self.lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
os.fchmod(descriptor, 0o600)
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
return descriptor
|
||||
|
||||
def _empty_registry(self):
|
||||
return {"contract": REGISTRY_CONTRACT, "generation": 0, "entries": []}
|
||||
|
||||
def _read_registry(self):
|
||||
if not self.registry_path.exists():
|
||||
return self._empty_registry()
|
||||
if self.registry_path.is_symlink() or not self.registry_path.is_file():
|
||||
raise LibraryError("Registre de bibliothèque invalide")
|
||||
try:
|
||||
value = json.loads(self.registry_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise LibraryError("Registre de bibliothèque illisible") from error
|
||||
if (not isinstance(value, dict) or value.get("contract") != REGISTRY_CONTRACT or
|
||||
not isinstance(value.get("generation"), int) or
|
||||
not isinstance(value.get("entries"), list) or
|
||||
len(value["entries"]) > MAX_ENTRIES):
|
||||
raise LibraryError("Registre de bibliothèque mal formé")
|
||||
seen = set()
|
||||
for entry in value["entries"]:
|
||||
if not self._valid_entry(entry) or entry["workspace_id"] in seen:
|
||||
raise LibraryError("Entrée de bibliothèque invalide")
|
||||
seen.add(entry["workspace_id"])
|
||||
self._workspace_path(entry["workspace_id"], require_exists=True)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _valid_entry(entry):
|
||||
if not isinstance(entry, dict) or set(entry) != {
|
||||
"workspace_id", "title", "idempotency_key", "request_hash"}:
|
||||
return False
|
||||
try:
|
||||
uuid.UUID(entry["workspace_id"])
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return False
|
||||
return (isinstance(entry["title"], str) and 0 < len(entry["title"]) <= 200 and
|
||||
isinstance(entry["idempotency_key"], str) and
|
||||
0 < len(entry["idempotency_key"]) <= 128 and
|
||||
isinstance(entry["request_hash"], str) and
|
||||
len(entry["request_hash"]) == 64)
|
||||
|
||||
def _write_registry(self, value):
|
||||
self._write_json(self.registry_path, value, "registry")
|
||||
|
||||
def _write_json(self, destination, value, temporary_prefix):
|
||||
temporary = self.root / f".{temporary_prefix}-{uuid.uuid4().hex}.tmp"
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600)
|
||||
try:
|
||||
data = (json.dumps(value, ensure_ascii=False, sort_keys=True,
|
||||
separators=(",", ":")) + "\n").encode()
|
||||
os.write(descriptor, data)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
temporary.replace(destination)
|
||||
self._sync_root()
|
||||
|
||||
def _sync_root(self):
|
||||
directory = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
|
||||
def _read_creation_intent(self):
|
||||
if not self.creation_intent_path.exists():
|
||||
return None
|
||||
if (self.creation_intent_path.is_symlink() or
|
||||
not self.creation_intent_path.is_file()):
|
||||
raise LibraryError("Intention de création invalide")
|
||||
try:
|
||||
value = json.loads(self.creation_intent_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise LibraryError("Intention de création illisible") from error
|
||||
if (not isinstance(value, dict) or
|
||||
value.get("contract") != CREATION_INTENT_CONTRACT or
|
||||
not self._valid_entry({key: item for key, item in value.items()
|
||||
if key != "contract"}) or
|
||||
set(value) != {"contract", "workspace_id", "title",
|
||||
"idempotency_key", "request_hash"}):
|
||||
raise LibraryError("Intention de création mal formée")
|
||||
return {key: item for key, item in value.items() if key != "contract"}
|
||||
|
||||
def _write_creation_intent(self, entry):
|
||||
self._write_json(self.creation_intent_path,
|
||||
{"contract": CREATION_INTENT_CONTRACT, **entry},
|
||||
"creation-intent")
|
||||
|
||||
def _clear_creation_intent(self):
|
||||
try:
|
||||
self.creation_intent_path.unlink()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
self._sync_root()
|
||||
|
||||
def _workspace_path(self, workspace_id, *, require_exists):
|
||||
try:
|
||||
normalized = str(uuid.UUID(workspace_id))
|
||||
except (ValueError, TypeError, AttributeError) as error:
|
||||
raise LibraryError("Identifiant d'enquête invalide") from error
|
||||
if normalized != workspace_id:
|
||||
raise LibraryError("Identifiant d'enquête non canonique")
|
||||
candidate = self.workspaces / normalized
|
||||
if require_exists:
|
||||
self._assert_plain_directory(candidate)
|
||||
# CONTRACT: un espace enregistré est un enfant direct, jamais un chemin exploré.
|
||||
if candidate.resolve().parent != self.workspaces.resolve():
|
||||
raise LibraryError("Espace hors bibliothèque")
|
||||
return candidate
|
||||
|
||||
@staticmethod
|
||||
def _public_entry(entry):
|
||||
return {"workspace_id": entry["workspace_id"], "title": entry["title"],
|
||||
"state": "READY"}
|
||||
|
||||
def snapshot(self, active_workspace_id=None):
|
||||
descriptor = self._locked()
|
||||
try:
|
||||
registry = self._read_registry()
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
entries = sorted((self._public_entry(item) for item in registry["entries"]),
|
||||
key=lambda item: (item["title"].casefold(), item["workspace_id"]))
|
||||
return {"contract": "labfy.web_library.v1", "entries": entries,
|
||||
"active_workspace_id": active_workspace_id,
|
||||
"generation": registry["generation"]}
|
||||
|
||||
def create(self, title, idempotency_key):
|
||||
if (not isinstance(title, str) or not title.strip() or len(title) > 200 or
|
||||
any(ord(character) < 32 for character in title)):
|
||||
raise TypeError("Titre d'enquête invalide")
|
||||
if (not isinstance(idempotency_key, str) or not idempotency_key or
|
||||
len(idempotency_key) > 128):
|
||||
raise TypeError("Clé d'idempotence invalide")
|
||||
title = title.strip()
|
||||
request_hash = hashlib.sha256(title.encode("utf-8")).hexdigest()
|
||||
descriptor = self._locked()
|
||||
try:
|
||||
registry = self._read_registry()
|
||||
intent = self._read_creation_intent()
|
||||
if intent is not None:
|
||||
published = next((item for item in registry["entries"]
|
||||
if item["workspace_id"] == intent["workspace_id"]), None)
|
||||
if published is not None:
|
||||
if published != intent:
|
||||
raise LibraryError("Intention de création incohérente avec le registre")
|
||||
self._clear_creation_intent()
|
||||
intent = None
|
||||
replay = next((item for item in registry["entries"]
|
||||
if item["idempotency_key"] == idempotency_key), None)
|
||||
if replay is not None:
|
||||
if replay["request_hash"] != request_hash:
|
||||
raise LibraryError("Clé d'idempotence déjà liée à un autre titre")
|
||||
return self._response(replay, registry["generation"], True)
|
||||
if len(registry["entries"]) >= MAX_ENTRIES:
|
||||
raise LibraryError("Bibliothèque limitée à 256 enquêtes")
|
||||
if intent is None:
|
||||
entry = {"workspace_id": str(uuid.uuid4()), "title": title,
|
||||
"idempotency_key": idempotency_key,
|
||||
"request_hash": request_hash}
|
||||
self._write_creation_intent(entry)
|
||||
workspace = self._workspace_path(entry["workspace_id"],
|
||||
require_exists=False)
|
||||
workspace.mkdir(mode=0o700)
|
||||
workspace_ready = False
|
||||
else:
|
||||
if (intent["idempotency_key"] != idempotency_key or
|
||||
intent["request_hash"] != request_hash):
|
||||
raise LibraryError(
|
||||
"Une création interrompue doit être reprise avec sa clé d'idempotence")
|
||||
entry = intent
|
||||
workspace = self._workspace_path(entry["workspace_id"],
|
||||
require_exists=False)
|
||||
if not workspace.exists():
|
||||
workspace.mkdir(mode=0o700)
|
||||
workspace_ready = False
|
||||
else:
|
||||
self._workspace_path(entry["workspace_id"], require_exists=True)
|
||||
try:
|
||||
self._validate_created_workspace(workspace)
|
||||
workspace_ready = True
|
||||
except LibraryError:
|
||||
workspace_ready = False
|
||||
if not workspace_ready:
|
||||
# CONTRACT: le bridge C existant reste l'unique créateur de l'enquête.
|
||||
result = subprocess.run(
|
||||
[str(self.bridge), "create-workspace", "--workspace", str(workspace),
|
||||
"--title", title], cwd=self.bridge.parents[1], text=True,
|
||||
capture_output=True, timeout=30, check=False)
|
||||
if result.returncode != 0:
|
||||
message = (result.stderr.strip().splitlines()[-1]
|
||||
if result.stderr.strip() else "Création C refusée")
|
||||
raise LibraryError(message.removeprefix("local-jobs: "))
|
||||
self._validate_created_workspace(workspace)
|
||||
registry["entries"].append(entry)
|
||||
registry["generation"] += 1
|
||||
self._write_registry(registry)
|
||||
self._clear_creation_intent()
|
||||
return self._response(entry, registry["generation"], False)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
@staticmethod
|
||||
def _validate_created_workspace(workspace):
|
||||
database = workspace / "Enquete.sqlite"
|
||||
manifest = workspace / ".labfy" / "runtime" / "workspace.json"
|
||||
if (database.is_symlink() or not database.is_file() or manifest.is_symlink() or
|
||||
not manifest.is_file()):
|
||||
raise LibraryError("Le bridge C n'a pas publié un espace valide")
|
||||
try:
|
||||
value = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise LibraryError("Manifeste d'enquête invalide") from error
|
||||
if value.get("contract") != "labfy.local_workspace.v1":
|
||||
raise LibraryError("Contrat de manifeste d'enquête inattendu")
|
||||
|
||||
@staticmethod
|
||||
def _response(entry, generation, replayed):
|
||||
return {"contract": "labfy.web_library.workspace.v1",
|
||||
**WebLibrary._public_entry(entry), "generation": generation,
|
||||
"replayed": replayed}
|
||||
|
||||
def open(self, workspace_id, expected_generation):
|
||||
if not isinstance(expected_generation, int) or isinstance(expected_generation, bool):
|
||||
raise TypeError("Génération attendue invalide")
|
||||
descriptor = self._locked()
|
||||
try:
|
||||
registry = self._read_registry()
|
||||
if expected_generation != registry["generation"]:
|
||||
raise LibraryError("Projection de bibliothèque périmée")
|
||||
entry = next((item for item in registry["entries"]
|
||||
if item["workspace_id"] == workspace_id), None)
|
||||
if entry is None:
|
||||
raise LibraryError("Enquête absente de la bibliothèque")
|
||||
workspace = self._workspace_path(workspace_id, require_exists=True)
|
||||
self._validate_created_workspace(workspace)
|
||||
return workspace, entry, registry["generation"]
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
|
@ -27,6 +27,7 @@ from report_bundle import publish as publish_report, verify as verify_report, Re
|
|||
ROOT = Path(__file__).resolve().parent
|
||||
PUBLIC = ROOT / "public"
|
||||
MAX_BODY = 4096
|
||||
SESSION_TTL_SECONDS = 3600
|
||||
MAX_EXPORT = 1024 * 1024
|
||||
MAX_PLAN_ITEMS = 8
|
||||
MAX_UPLOAD_BYTES = 4 * 1024 * 1024
|
||||
|
|
@ -47,16 +48,34 @@ class WorkspaceServer(ThreadingHTTPServer):
|
|||
block_on_close = False
|
||||
|
||||
def __init__(self, address, handler, *, workspace: Path, bridge: Path,
|
||||
bootstrap: str):
|
||||
bootstrap: str, library=None, instance_id=None, config_id=None,
|
||||
session_ttl_seconds=SESSION_TTL_SECONDS):
|
||||
if (not isinstance(session_ttl_seconds, int) or
|
||||
isinstance(session_ttl_seconds, bool) or session_ttl_seconds <= 0):
|
||||
raise ValueError("Durée de session invalide")
|
||||
super().__init__(address, handler)
|
||||
self.workspace = workspace.resolve()
|
||||
self.bridge = bridge.resolve()
|
||||
self.authority = f"127.0.0.1:{self.server_port}"
|
||||
self.origin = f"http://{self.authority}"
|
||||
self.bootstrap = bootstrap
|
||||
self.library = library
|
||||
self.active_workspace_id = None
|
||||
self.library_generation = (library.snapshot(None)["generation"]
|
||||
if library is not None else 0)
|
||||
self.instance_id = instance_id or secrets.token_urlsafe(18)
|
||||
self.config_id = config_id or hashlib.sha256(
|
||||
str(self.workspace).encode()).hexdigest()
|
||||
self.cookie_name = f"labfy_session_{self.instance_id[:12]}"
|
||||
self.session = secrets.token_urlsafe(32)
|
||||
self.csrf = secrets.token_urlsafe(32)
|
||||
self.session_deadline = time.monotonic() + 3600
|
||||
self.session_lock = threading.RLock()
|
||||
# CONTRACT: la production conserve 3600 s. L'injection constructeur
|
||||
# permet seulement aux tests d'observer une vraie expiration sans route
|
||||
# d'administration ni manipulation de l'horloge du processus.
|
||||
self.session_ttl_seconds = session_ttl_seconds
|
||||
# La durée commence à la connexion, jamais au démarrage du serveur.
|
||||
self.session_deadline = 0.0
|
||||
self.worker = None
|
||||
self.worker_supervisor = None
|
||||
self.worker_supervisor_stop = threading.Event()
|
||||
|
|
@ -71,6 +90,46 @@ class WorkspaceServer(ThreadingHTTPServer):
|
|||
self.reserved_upload_bytes = 0
|
||||
self.upload_mutexes = {}
|
||||
|
||||
def library_snapshot(self):
|
||||
if self.library is None:
|
||||
raise ValueError("Mode bibliothèque inactif")
|
||||
value = self.library.snapshot(self.active_workspace_id)
|
||||
self.library_generation = value["generation"]
|
||||
return value
|
||||
|
||||
def create_library_workspace(self, title, idempotency_key):
|
||||
if self.library is None:
|
||||
raise ValueError("Mode bibliothèque inactif")
|
||||
value = self.library.create(title, idempotency_key)
|
||||
self.library_generation = value["generation"]
|
||||
return value
|
||||
|
||||
def open_library_workspace(self, workspace_id, expected_generation):
|
||||
if self.library is None:
|
||||
raise ValueError("Mode bibliothèque inactif")
|
||||
with self.lock:
|
||||
if (self.active_workspace_id is not None and
|
||||
self.active_workspace_id != workspace_id):
|
||||
raise ValueError("Une autre enquête est déjà active")
|
||||
workspace, entry, generation = self.library.open(
|
||||
workspace_id, expected_generation)
|
||||
if self.active_workspace_id is None:
|
||||
previous = self.workspace
|
||||
self.workspace = workspace
|
||||
try:
|
||||
# CONTRACT: l'ouverture valide aussi le pipeline C existant ;
|
||||
# le serveur Web ne reconstruit aucune projection lui-même.
|
||||
self.bridge_call(["export"])
|
||||
self.context()
|
||||
except Exception:
|
||||
self.workspace = previous
|
||||
raise
|
||||
self.active_workspace_id = workspace_id
|
||||
self.library_generation = generation
|
||||
return {"contract": "labfy.web_library.open.v1",
|
||||
"workspace_id": workspace_id, "title": entry["title"],
|
||||
"state": "READY", "generation": generation}
|
||||
|
||||
def upload_mutex(self, upload_id):
|
||||
"""Return the process-local owner lock for one persisted upload."""
|
||||
with self.upload_lock:
|
||||
|
|
@ -229,18 +288,20 @@ class Handler(BaseHTTPRequestHandler):
|
|||
return self.headers.get("Host") == self.server.authority
|
||||
|
||||
def _authenticated(self):
|
||||
if time.monotonic() > self.server.session_deadline:
|
||||
return False
|
||||
cookie = SimpleCookie(self.headers.get("Cookie", ""))
|
||||
value = cookie.get("labfy_session")
|
||||
return value is not None and hmac.compare_digest(value.value,
|
||||
self.server.session)
|
||||
with self.server.session_lock:
|
||||
if time.monotonic() >= self.server.session_deadline:
|
||||
return False
|
||||
cookie = SimpleCookie(self.headers.get("Cookie", ""))
|
||||
value = cookie.get(self.server.cookie_name)
|
||||
return value is not None and hmac.compare_digest(
|
||||
value.value, self.server.session)
|
||||
|
||||
def _mutation_allowed(self):
|
||||
return (self._authenticated() and
|
||||
self.headers.get("Origin") == self.server.origin and
|
||||
hmac.compare_digest(self.headers.get("X-Labfy-CSRF", ""),
|
||||
self.server.csrf))
|
||||
with self.server.session_lock:
|
||||
return (self._authenticated() and
|
||||
self.headers.get("Origin") == self.server.origin and
|
||||
hmac.compare_digest(self.headers.get("X-Labfy-CSRF", ""),
|
||||
self.server.csrf))
|
||||
|
||||
def _content_length(self, maximum, *, expected=None):
|
||||
"""Validate HTTP/1.1 framing before a route consumes its body."""
|
||||
|
|
@ -317,6 +378,16 @@ class Handler(BaseHTTPRequestHandler):
|
|||
raise TypeError("Champ JSON vide ou trop long")
|
||||
return value
|
||||
|
||||
def _library_open_body(self):
|
||||
value = self._json_body()
|
||||
if (not isinstance(value, dict) or
|
||||
set(value) != {"workspace_id", "expected_generation"} or
|
||||
not isinstance(value["workspace_id"], str) or
|
||||
not isinstance(value["expected_generation"], int) or
|
||||
isinstance(value["expected_generation"], bool)):
|
||||
raise TypeError("Demande d'ouverture de bibliothèque invalide")
|
||||
return value
|
||||
|
||||
def _plan_body(self):
|
||||
value = self._json_body()
|
||||
if not isinstance(value, dict) or set(value) != {
|
||||
|
|
@ -412,7 +483,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||
path = parsed.path
|
||||
if path == "/healthz":
|
||||
self._json(HTTPStatus.OK, {"contract": "labfy.workspace.health.v1",
|
||||
"status": "ready"})
|
||||
"status": "ready", "instance_id": self.server.instance_id,
|
||||
"config_id": self.server.config_id})
|
||||
return
|
||||
if not self._authenticated():
|
||||
if path in {"/", "/login.js", "/styles.css"}:
|
||||
|
|
@ -430,7 +502,15 @@ class Handler(BaseHTTPRequestHandler):
|
|||
"workspace_state": "READY" if context else "EMPTY",
|
||||
"investigation_id": context.get("investigation_id") if context else None,
|
||||
"title": context.get("title", "SPECIMEN") if context else None,
|
||||
"mode": context.get("mode", "specimen") if context else "local_experimental"})
|
||||
"mode": context.get("mode", "specimen") if context else "local_experimental",
|
||||
"library_mode": self.server.library is not None,
|
||||
"active_workspace_id": self.server.active_workspace_id,
|
||||
"generation": self.server.library_generation})
|
||||
elif path == "/api/v1/library":
|
||||
try:
|
||||
self._json(HTTPStatus.OK, self.server.library_snapshot())
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
self._error(HTTPStatus.CONFLICT, "library_unavailable", str(error))
|
||||
elif path.startswith("/api/v1/evidence/"):
|
||||
parts = path.split("/")
|
||||
if len(parts) != 6 or parts[5] not in {"preview", "observations"}:
|
||||
|
|
@ -518,7 +598,21 @@ class Handler(BaseHTTPRequestHandler):
|
|||
return
|
||||
self.server.last_command = now
|
||||
try:
|
||||
if path == "/api/v1/library/workspaces":
|
||||
value = self._body({"title", "idempotency_key"})
|
||||
response = self.server.create_library_workspace(
|
||||
value["title"], value["idempotency_key"])
|
||||
self._json(HTTPStatus.OK if response["replayed"] else HTTPStatus.CREATED,
|
||||
response)
|
||||
return
|
||||
if path == "/api/v1/library/open":
|
||||
value = self._library_open_body()
|
||||
self._json(HTTPStatus.OK, self.server.open_library_workspace(
|
||||
value["workspace_id"], value["expected_generation"]))
|
||||
return
|
||||
if path == "/api/v1/workspace":
|
||||
if self.server.library is not None:
|
||||
raise ValueError("Utiliser la création explicite de la bibliothèque")
|
||||
value = self._body({"title"})
|
||||
if self.server.context() is not None:
|
||||
raise ValueError("L’espace est déjà créé")
|
||||
|
|
@ -911,8 +1005,16 @@ class Handler(BaseHTTPRequestHandler):
|
|||
if not hmac.compare_digest(value["bootstrap_code"], self.server.bootstrap):
|
||||
self._error(HTTPStatus.FORBIDDEN, "bootstrap_rejected", "Code éphémère invalide")
|
||||
return
|
||||
cookie = (f"labfy_session={self.server.session}; HttpOnly; SameSite=Strict; "
|
||||
"Path=/; Max-Age=3600")
|
||||
with self.server.session_lock:
|
||||
# Une reconnexion renouvelle les secrets ; les anciens cookies
|
||||
# expirés ne doivent pas redevenir valables.
|
||||
self.server.session = secrets.token_urlsafe(32)
|
||||
self.server.csrf = secrets.token_urlsafe(32)
|
||||
self.server.session_deadline = (time.monotonic() +
|
||||
self.server.session_ttl_seconds)
|
||||
cookie = (f"{self.server.cookie_name}={self.server.session}; "
|
||||
"HttpOnly; SameSite=Strict; "
|
||||
f"Path=/; Max-Age={self.server.session_ttl_seconds}")
|
||||
self._json(HTTPStatus.OK, {"contract": "labfy.workspace.session.v1",
|
||||
"authenticated": True}, cookie=cookie)
|
||||
|
||||
|
|
@ -961,7 +1063,12 @@ def main():
|
|||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
parser.add_argument("--bridge", type=Path, required=True)
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
def port(value):
|
||||
parsed = int(value)
|
||||
if not 0 <= parsed <= 65535:
|
||||
raise argparse.ArgumentTypeError("port hors limites")
|
||||
return parsed
|
||||
parser.add_argument("--port", type=port, default=8081)
|
||||
args = parser.parse_args()
|
||||
bootstrap = secrets.token_urlsafe(12)
|
||||
server = WorkspaceServer(("127.0.0.1", args.port), Handler,
|
||||
|
|
|
|||
7
tools/labfy-web
Executable file
7
tools/labfy-web
Executable file
|
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh
|
||||
# CONTRACT: ce lanceur ne compile rien et délègue les arguments tels quels au
|
||||
# gestionnaire d'instance, qui impose loopback, le port et le type de cible.
|
||||
set -eu
|
||||
|
||||
repository=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
exec python3 "$repository/prototypes/web-graph/web_app.py" "$@"
|
||||
Loading…
Reference in a new issue