diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c460fd..f97a1e2 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/Makefile b/Makefile index 31c0e88..b4f02d1 100644 --- a/Makefile +++ b/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 \ diff --git a/README.md b/README.md index a95c739..3a40a9d 100644 --- a/README.md +++ b/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) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 050e089..aaa67e8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.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 diff --git a/docs/DEPENDENCE.md b/docs/DEPENDENCE.md index 6771891..0473a3a 100644 --- a/docs/DEPENDENCE.md +++ b/docs/DEPENDENCE.md @@ -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. diff --git a/docs/README.md b/docs/README.md index b056f3b..2dc7df2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/architecture/WEB_WORKSPACE_CONTROL.md b/docs/architecture/WEB_WORKSPACE_CONTROL.md index 25cb6b2..8d5555d 100644 --- a/docs/architecture/WEB_WORKSPACE_CONTROL.md +++ b/docs/architecture/WEB_WORKSPACE_CONTROL.md @@ -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:`. 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. diff --git a/docs/ui/WEB_WORKBENCH.md b/docs/ui/WEB_WORKBENCH.md index 94c375e..8b91cb1 100644 --- a/docs/ui/WEB_WORKBENCH.md +++ b/docs/ui/WEB_WORKBENCH.md @@ -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). diff --git a/prototypes/web-graph/package.json b/prototypes/web-graph/package.json index e5449b4..3e2d95a 100644 --- a/prototypes/web-graph/package.json +++ b/prototypes/web-graph/package.json @@ -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" diff --git a/prototypes/web-graph/public/app.js b/prototypes/web-graph/public/app.js index cf031cd..0727a68 100644 --- a/prototypes/web-graph/public/app.js +++ b/prototypes/web-graph/public/app.js @@ -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, diff --git a/prototypes/web-graph/public/index.html b/prototypes/web-graph/public/index.html index 1404341..905fda7 100644 --- a/prototypes/web-graph/public/index.html +++ b/prototypes/web-graph/public/index.html @@ -7,14 +7,28 @@ - +
+
+
Poste local

Bibliothèque d’enquêtes

+ Chargement… +
+
+

Nouvelle enquête

+

Crée un espace local vide. Aucun contenu d’exemple n’est ajouté.

+
+ +
+
+
+ +
+
+

Enquêtes disponibles

+ +
    +
    +
    +
    +
    diff --git a/prototypes/web-graph/public/login.html b/prototypes/web-graph/public/login.html index 8243957..e84c8f5 100644 --- a/prototypes/web-graph/public/login.html +++ b/prototypes/web-graph/public/login.html @@ -3,7 +3,8 @@ Labfy — session locale
    diff --git a/prototypes/web-graph/public/login.js b/prototypes/web-graph/public/login.js index 47b93ba..2fbdf72 100644 --- a/prototypes/web-graph/public/login.js +++ b/prototypes/web-graph/public/login.js @@ -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(); + } }); diff --git a/prototypes/web-graph/public/styles.css b/prototypes/web-graph/public/styles.css index 08ff51b..7972c9b 100644 --- a/prototypes/web-graph/public/styles.css +++ b/prototypes/web-graph/public/styles.css @@ -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; diff --git a/prototypes/web-graph/run_web_workspace.py b/prototypes/web-graph/run_web_workspace.py index 166dc3a..d1a4f5d 100644 --- a/prototypes/web-graph/run_web_workspace.py +++ b/prototypes/web-graph/run_web_workspace.py @@ -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)])) diff --git a/prototypes/web-graph/tests/owned_process_host.mjs b/prototypes/web-graph/tests/owned_process_host.mjs new file mode 100644 index 0000000..56dda33 --- /dev/null +++ b/prototypes/web-graph/tests/owned_process_host.mjs @@ -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 }); + }); +} diff --git a/prototypes/web-graph/tests/owned_process_supervisor.mjs b/prototypes/web-graph/tests/owned_process_supervisor.mjs new file mode 100644 index 0000000..780fc0d --- /dev/null +++ b/prototypes/web-graph/tests/owned_process_supervisor.mjs @@ -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(); + }); +} diff --git a/prototypes/web-graph/tests/run_browser_suite.mjs b/prototypes/web-graph/tests/run_browser_suite.mjs new file mode 100644 index 0000000..4f3d67d --- /dev/null +++ b/prototypes/web-graph/tests/run_browser_suite.mjs @@ -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: "", 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(); +} diff --git a/prototypes/web-graph/tests/test_run_browser_suite.mjs b/prototypes/web-graph/tests/test_run_browser_suite.mjs new file mode 100644 index 0000000..2d75d66 --- /dev/null +++ b/prototypes/web-graph/tests/test_run_browser_suite.mjs @@ -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 }); + } +}); diff --git a/prototypes/web-graph/tests/test_session_lifetime.py b/prototypes/web-graph/tests/test_session_lifetime.py new file mode 100644 index 0000000..77a4b19 --- /dev/null +++ b/prototypes/web-graph/tests/test_session_lifetime.py @@ -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() diff --git a/prototypes/web-graph/tests/test_session_lifetime_browser.mjs b/prototypes/web-graph/tests/test_session_lifetime_browser.mjs new file mode 100644 index 0000000..9031ebb --- /dev/null +++ b/prototypes/web-graph/tests/test_session_lifetime_browser.mjs @@ -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 }))); +} diff --git a/prototypes/web-graph/tests/test_web_app.py b/prototypes/web-graph/tests/test_web_app.py new file mode 100644 index 0000000..2605a04 --- /dev/null +++ b/prototypes/web-graph/tests/test_web_app.py @@ -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() diff --git a/prototypes/web-graph/tests/test_web_app_browser.mjs b/prototypes/web-graph/tests/test_web_app_browser.mjs new file mode 100644 index 0000000..bf0991e --- /dev/null +++ b/prototypes/web-graph/tests/test_web_app_browser.mjs @@ -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 \r\n" + + "To: Pivot SPECIMEN \r\n" + + "Message-ID: \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 }))); +} diff --git a/prototypes/web-graph/tests/test_workspace_control_browser.mjs b/prototypes/web-graph/tests/test_workspace_control_browser.mjs index ed64a9d..c368f8f 100644 --- a/prototypes/web-graph/tests/test_workspace_control_browser.mjs +++ b/prototypes/web-graph/tests/test_workspace_control_browser.mjs @@ -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, diff --git a/prototypes/web-graph/web_app.py b/prototypes/web-graph/web_app.py new file mode 100644 index 0000000..005b9b7 --- /dev/null +++ b/prototypes/web-graph/web_app.py @@ -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()) diff --git a/prototypes/web-graph/web_library.py b/prototypes/web-graph/web_library.py new file mode 100644 index 0000000..f7cb7a7 --- /dev/null +++ b/prototypes/web-graph/web_library.py @@ -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) diff --git a/prototypes/web-graph/workspace_server.py b/prototypes/web-graph/workspace_server.py index 4e290fa..dbcd4ef 100644 --- a/prototypes/web-graph/workspace_server.py +++ b/prototypes/web-graph/workspace_server.py @@ -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, diff --git a/tools/labfy-web b/tools/labfy-web new file mode 100755 index 0000000..94cfeb8 --- /dev/null +++ b/tools/labfy-web @@ -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" "$@"