Files
Password-Manager/TEST_SCENARIOS.md
T
r-zakarya b9b098fe12 fix(editor): fire an input event when the generator fills the password
Assigning .value fires nothing, and the code called soDirtyCheck by hand - so the strength bar, which listens to input, stayed frozen on the previous password. Dispatching the event covers every listener on the field instead of one named handler.
2026-08-07 12:03:42 +01:00

69 KiB
Raw Blame History

Scénarios de test — écriture concurrente & fermeture de l'éditeur

Sessions du 2026-07-22. Tous les tests sont manuels (ils portent sur des interactions UI que le harness node:test ne couvre pas). Rebuild obligatoire avant de commencer : delphi-backend\assets\BuildAssets.cmd puis build Delphi.

Convention : PRÉ = état de départ, ACTION, ATTENDU. Un test qui échoue = régression, noter le commit courant.


Les 4 classes de bug

# Classe Cause racine Corrigé par
A Écriture extérieure écrasée au Save L'éditeur garde le payload chargé à l'ouverture et le re-poste entier ; toute écriture faite ailleurs entre-temps est perdue editorAbsorbChange (d146fbe)
B Éditeur fermé sans garde-fou closeSlideOver() appelé sans condition + click-outside non filtré closeEditorForDeleted (3dbce78), whitelist (e1ddc65)
C PUT /entries sans withEncryptedMeta Les colonnes en clair sont droppées côté serveur → le body ne contient rien de stockable et blanchit les *_enc d146fbe
D UI construite sans regarder la vue Le menu kebab est partagé entre vues ; la corbeille n'était pas testée d146fbe

A — Éditeur ouvert vs écriture extérieure

Préparer : 3 entrées A, B, C, dont A dans le dossier Work avec le tag old.

A1 · batch Add tag sur l'entrée éditée

  • PRÉ : A ouverte dans l'éditeur, taper du texte dans NOTE/PASSWORD (ne pas sauver)
  • ACTION : cocher A + B → barre de sélection → Add tag → shared → puis Save dans l'éditeur
  • ATTENDU : le chip shared apparaît immédiatement dans l'éditeur ; après Save, A porte old, shared et la modif tapée. Le bouton Save reste visible (l'entrée était déjà dirty) mais le tag seul ne rend pas dirty.✅

A2 · batch Add tag sur une autre entrée

  • PRÉ : A ouverte + dirty
  • ACTION : cocher B + C seulement → Add tag → x → Save dans l'éditeur
  • ATTENDU : l'éditeur ne bouge pas (aucun chip ajouté), A conserve ses tags, B/C taguées.✅

A3 · batch Move to folder sur l'entrée éditée

  • ACTION : A ouverte + dirty → cocher A → Move to folder… → Perso → Save
  • ATTENDU : le select Folder de l'éditeur bascule sur Perso tout seul ; après Save l'entrée est bien dans Perso.✅

A4 · dossier choisi dans l'éditeur puis move extérieur

  • ACTION : A ouverte, changer Folder → Archive (ne pas sauver) → cocher A → Move to folder… → Perso → Save
  • ATTENDU : Perso gagne (action extérieure = intention la plus récente). Comportement documenté, pas un bug. ✅

A5 · glisser une entrée sur un dossier (moveEntryToFolder)

  • ACTION : A ouverte + dirty → glisser la ligne A sur Perso dans la sidebar → Save
  • ATTENDU : identique à A3.✅

A6 · glisser une multi-sélection sur un dossier (moveEntriesToFolder) ✅

  • ACTION : cocher A + B → glisser sur Perso → recharger (F5 impossible : relancer l'app)
  • ATTENDU : dossier changé et nom / username / site / tags intacts.
  • Historique : ce chemin n'appelait pas withEncryptedMeta → il blanchissait les métadonnées chiffrées. Si des entrées ont été déplacées ainsi avant le fix, elles sont peut-être déjà vides — vérifier.

A7 · suppression du dossier de l'entrée éditée

  • ACTION : A (dossier Work) ouverte + dirty → sidebar → supprimer le dossier Work → Save
  • ATTENDU : le select de l'éditeur passe à All ; après Save l'entrée est dans All, le dossier supprimé ne réapparaît pas.✅

A8 · tags édités des deux côtés

  • ACTION : A ouverte, ajouter le chip mine dans l'éditeur (ne pas sauver) → batch Add tag shared sur A → Save
  • ATTENDU : old, mine, shared — aucun des trois perdu.✅

A9 · favori / épingle pendant l'édition (non-régression)

  • ACTION : A ouverte + dirty → cliquer ★ et 📌 sur la ligne A → Save
  • ATTENDU : l'entrée reste favorite et épinglée (ces colonnes ne sont pas dans le PUT de l'éditeur).✅

A10 · icône / pièce jointe pendant l'édition (non-régression)

  • ACTION : A ouverte + dirty → Settings → Fetch missing favicons (ou upload d'icône) → Save
  • ATTENDU : l'icône survit au Save (endpoint dédié, hors payload éditeur).✅

A10b · icône rafraîchie dans l'éditeur ouvert

  • ACTION : A ouverte → Settings → Fetch missing / Re-fetch all favicons
  • ATTENDU : l'aperçu d'icône dans l'éditeur se met à jour tout seul (pas seulement la carte). Idem après un import en mode overwrite qui apporte une icône.✅

A11 · sync / import pendant que l'éditeur est ouvert ✅

  • ACTION : A ouverte + dirty → lancer Sync now (ou importer un JSON en mode overwrite) qui met A à jour → Save
  • ATTENDU : modale « Entry changed elsewhere ». Annuler ⇒ rien n'est écrit (la version du sync/import survit, l'éditeur reste ouvert le temps de recopier). Overwrite ⇒ la version de l'éditeur gagne.✅

A12 · pas de fausse alerte (non-régression d'A11)

Aucun de ces enchaînements ne doit afficher « Entry changed elsewhere » :

  • A12a : A ouverte + dirty → Save → modifier encore → Save (le rechargement post-save re-cale la référence)✅
  • A12b : A ouverte + dirty → batch Add tag / Move to folder sur A → Save (l'éditeur a absorbé le changement)✅
  • A12c : A ouverte + dirty → supprimer son dossier → Save (cf. A7)✅
  • A12d : A ouverte + dirty → Fetch missing favicons → Save (l'icône est hors payload éditeur)✅
  • A12e : A ouverte + dirty → sync sans changement distant sur A → Save✅

A13 · entrée supprimée ailleurs pendant l'édition ✅

  • ACTION : A ouverte + dirty → un sync distant supprime A (tombstone) → Save
  • ATTENDU : pas de plantage. L'entrée n'est plus en mémoire → le contrôle A11 passe la main ; le PUT échoue proprement avec un toast d'erreur — surtout pas « Saved ».
  • Recette locale, sans second appareil : ouvrir A et la rendre dirty → dans DB Browser, app lancée, DELETE FROM vault_entries WHERE id = <id> puis Write Changes (sans ça le WAL ne publie rien) → dans l'app, déclencher un loadEntries() qui ne concerne pas A (mettre une autre entrée à la corbeille puis Empty trash) → Save.
  • Bug trouvé par ce test : le serveur répondait 200 « Updated » pour un UPDATE n'ayant touché aucune ligne — le client affichait « Saved » alors que rien n'était écrit. Perte de données silencieuse. HandleUpdateEntry teste maintenant RowsAffected et renvoie 404.
  • Même classe, non corrigé : delete / restore / favorite / pin / set-icon répondent aussi OK sur un id inexistant. Assumé — aucun ne prétend avoir enregistré un travail de l'utilisateur, et le client recharge la liste juste après.

A13b · récupérer la saisie quand l'entrée a disparu ✅

  • ACTION : même mise en place qu'A13 → au Save, répondre « Save as new entry ».
  • ATTENDU : une nouvelle entrée est créée avec le contenu de l'éditeur, le panneau se rouvre dessus et la carte clignote. Cancel → rien n'est écrit, l'éditeur reste ouvert avec la saisie.
  • Nouvel uuid, pas une résurrection : le POST en génère un neuf, donc l'entrée recréée n'entre pas en conflit avec le tombstone laissé par la suppression — aucun risque de la voir re-supprimée au sync suivant, ni de ressusciter en boucle ce qu'un autre appareil a effacé volontairement.
  • Piège du déclenchement : le serveur HTTP répond aussi 404 à toute requête qu'il refuse (verrou par token). La bascule teste donc status === 404 et le message « no longer exists » — le statut seul confondrait une requête rejetée avec une entrée supprimée.

B — Fermeture de l'éditeur & message de discard

Le message n'apparaît que si confirmOnUnsaved est actif et que l'entrée est dirty. Tester avec askBeforeDelete ON puis OFF (Settings → Security).

B1 · ✕ sur une autre ligne ✅

  • PRÉ : A ouverte + dirty
  • ACTION : cliquer ✕ (Move to trash) sur la ligne B
  • ATTENDU : B part à la corbeille, l'éditeur de A reste ouvert avec les modifs. Vrai dans les deux réglages de askBeforeDelete.

B2 · ✕ sur la ligne éditée, askBeforeDelete = OFF ✅

  • ACTION : A ouverte + dirty → ✕ sur A
  • ATTENDU : modale « Discard unsaved changes? ». Annuler ⇒ rien n'est supprimé et l'éditeur reste ouvert. Confirmer ⇒ A à la corbeille + éditeur fermé.

B3 · ✕ sur la ligne éditée, askBeforeDelete = ON ✅

  • ATTENDU : une seule modale (« Move to trash »). Pas de double prompt.

B4 · batch Delete incluant l'entrée éditée ✅

  • ACTION : A ouverte + dirty → cocher A + B → Delete
  • ATTENDU : une seule modale (« 2 entries will be moved to trash »), puis l'éditeur se ferme.

B5 · batch Delete excluant l'entrée éditée ✅

  • ATTENDU : l'éditeur reste ouvert, modifs intactes.

B6 · corbeille — l'éditeur ne se ferme pas pour rien ✅

Prémisse corrigée : on ne peut pas ouvrir une entrée de la corbeille dans l'éditeur. openTrashActions (js/app.js) est un corps vide, et le menu kebab de la corbeille n'a pas d'entrée Edit — les actions sont sur la carte. Une entrée purgée n'est donc jamais celle qui est éditée, sauf par le chemin distant décrit plus bas.

  • ACTION : A ouverte + dirty (vue normale) → aller dans la corbeille → Delete forever sur une autre entrée, puis un batch Delete forever, puis Empty trash.
  • ATTENDU : l'éditeur de A reste ouvert avec ses modifs dans les trois cas — la purge ne concerne jamais l'entrée éditée.
  • Cas limite, seul moyen d'y arriver : A ouverte + dirty → un sync distant met A à la corbeille → Empty trash local → là closeEditorForDeleted doit fermer l'éditeur proprement (même famille qu'A13).

B7 · boutons de la barre de sélection ✅

  • ACTION : A ouverte + dirty → cocher B → cliquer successivement Move to folder…, Add tag, Clear
  • ATTENDU : aucun de ces clics ne ferme l'éditeur ni n'affiche de discard.

B8 · bouton Empty trash (barre de titre) ✅

  • ATTENDU : idem, pas de fermeture parasite de l'éditeur.

B9 · chemins de fermeture légitimes (non-régression) ✅

  • Esc, ✕ de l'éditeur, clic sur le fond de la grille, ouverture d'une autre entrée
  • ATTENDU : message de discard à chaque fois si dirty ; annuler garde l'éditeur ouvert sur la bonne entrée.

B10 · verrouillage (comportement assumé) ✅

  • ACTION : A ouverte + dirty → auto-lock ou Lock vault
  • ATTENDU : fermeture sans message, modifs perdues. Volontaire : la sécurité prime.

B11 · Esc au-dessus d'un modal ouvert par-dessus l'éditeur ✅

  • ACTION : A ouverte + dirty → ouvrir un modal → Esc → Esc
  • ATTENDU : 1ᵉʳ Esc ferme le modal seul, l'éditeur reste ouvert ; 2ᵉ Esc traite l'éditeur (discard).
  • Modals à passer un par un : + New folder (sidebar), Customize folder, Change master password, générateur de mot de passe, cheatsheet (?), historique de mot de passe, palette (Ctrl+K), outil TOTP, quick search (Ctrl+Shift+Q), reauth (déclenché par un export), picker d'autofill (Ctrl+Shift+L avec 2 entrées qui matchent).
  • Historique : #folderModal (dynamique) n'était dans aucune liste → Esc fermait l'éditeur derrière. #reauthModal, #quickSearchModal, #autofillPickerModal (statiques) non plus.

B12 · le modal reste fonctionnel après un Esc ✅

  • ACTION : ouvrir reauth (export) → Esc → ré-ouvrir (relancer l'export). Idem quick search et picker d'autofill.
  • ATTENDU : le modal se ré-affiche normalement. Un Esc ne doit jamais supprimer un modal statique du DOM (sinon la fonctionnalité est morte jusqu'au redémarrage).

B13 · clic sur un toast / l'overlay d'attente ✅

  • ACTION : A ouverte + dirty → lancer une action longue (export, sync) → cliquer sur le toast ou sur le spinner
  • ATTENDU : l'éditeur ne se ferme pas, pas de discard.

B15 · Esc pendant une opération bloquante ✅

  • ACTION : A ouverte + dirty → lancer Sync now (ou un export, une rotation de mot de passe maître) → presser Esc pendant que l'overlay « Encrypting… » est affiché.
  • ATTENDU : rien ne se passe. Pas de modale « Discard unsaved changes? ». Elle redevient possible une fois l'overlay parti.
  • Historique : la modale s'ouvrait bel et bien, mais sous l'overlay (z-index 200) — visible en flou, à demi cliquable. Deux problèmes en un : l'affichage, et surtout le fait d'arbitrer une fermeture d'éditeur pendant qu'une opération écrit sur la même entrée. Garde isBusy() posé dans la chaîne Esc de l'éditeur, avec les autres priorités (palette, Settings, modales).
  • Non-régression : le clic extérieur ne ferme déjà pas l'éditeur pendant l'attente — #busyOverlay est dans la liste blanche, et il couvre l'écran de toute façon.
  • Tester les DEUX déclencheurs : Sync now et Export. Il y a deux gestionnaires Esc distincts — celui en phase de capture dédié à l'éditeur, et la chaîne globale « une surface par frappe » (confirm → palette → change-master → cheatsheet → … → éditeur). Le premier correctif n'en couvrait qu'un, et l'export passait par l'autre : le bug semblait persister alors qu'il avait été corrigé sur le chemin du sync.

B16 · hotkeys globaux pendant une opération bloquante ✅

  • ACTION : lancer un Export (ou Sync now) → pendant l'overlay, presser Ctrl+Shift+A, puis Ctrl+Shift+Q.
  • ATTENDU : ni l'éditeur ni la quick search ne s'ouvrent ; toast « Busy — try again in a moment ». Tout redevient normal une fois l'overlay parti.
  • Pourquoi les hotkeys échappent aux gardes UI : ils arrivent de Win32, hors de la fenêtre. L'overlay qui couvre l'application n'intercepte rien : le panneau s'ouvrait derrière lui, inaccessible.
  • Où est posé le garde : dans openSlideOver et openQuickSearchModal, pas sur chaque déclencheur — c'est le point de passage de tous les appelants (hotkey, palette de commandes, bouton Fix du vault health). Sûr parce qu'aucun chemin de sauvegarde n'affiche l'overlay : rien de légitime n'ouvre l'éditeur pendant une opération bloquante.
  • ACTION 2 — autofill : pendant l'overlay, Ctrl+Shift+L, Ctrl+Shift+P, Ctrl+Shift+T, avec un titre à un seul candidat puis à plusieurs.
  • ATTENDU : rien ne se remplit, aucun picker, même toast. Le garde est dans autofillHandleRequest, en amont du choix match-unique / picker, donc il couvre les trois hotkeys et les deux issues. Il appelle Bridge.cancelAutofill(false) pour que Delphi ne garde pas la fenêtre cible capturée en attente d'un remplissage qui ne viendra jamais.

B17 · hotkeys pendant la boîte « Enregistrer sous » de l'export ✅

  • ACTION : lancer un Export → à l'apparition du dialogue natif Enregistrer sous, basculer sur une autre application (VS Code) → presser les hotkeys d'autofill → revenir à l'app.
  • ATTENDU : le dialogue est toujours là, au premier plan de l'application, et répond. L'app n'est cliquable qu'à travers lui — c'est normal, une boîte native modale désactive sa fenêtre propriétaire tant qu'on n'a pas répondu.
  • Historique : le refus « busy » appelait cancelAutofill(), dont le chemin Delphi fait un SetForegroundWindow vers la fenêtre capturée. Le dialogue passait derrière l'autre application, et l'app paraissait figée : bloquée par une modale devenue introuvable. refocus=0 distingue désormais refuser une demande (ne rien déplacer) de terminer une interaction utilisateur (rendre le focus à la cible).
  • Généralisation : un refus ne doit jamais déplacer une fenêtre. Tout nouveau garde qui annule une action doit passer par ce chemin.
  • Deuxième cause, côté Delphi : Ctrl+Shift+Q et Ctrl+Shift+A appellent RestoreWindowFromTray avant que JS ne puisse refuser — un garde JS arrive trop tard par construction. Restaurer la fenêtre propriétaire d'une modale native laisse le dialogue en rade derrière tout le reste : l'app semble gelée alors qu'elle est simplement désactivée par une boîte devenue introuvable (au point qu'il fallait un utilitaire de capture d'écran pour la faire réapparaître).
  • Garde : NativeModalActive teste IsWindowEnabled sur notre fenêtre — Windows la désactive tant qu'une modale native lui appartient. Posé en tête des quatre points d'entrée hotkey (quick search hotkey + menu tray, new entry, autofill). Il ne se contente pas de refuser : il ramène le dialogue au premier plan via GetLastActivePopup, ce qui est précisément ce dont l'utilisateur a besoin.
  • ATTENDU après correctif : pendant l'« Enregistrer sous », n'importe quel hotkey fait réapparaître le dialogue au premier plan, avec le focus, et une notification tray explique que les raccourcis restent inactifs tant qu'il n'a pas répondu.
  • Piège n°1, localiser le dialogue : GetLastActivePopup renvoie le formulaire lui-même — la boîte « Enregistrer sous » n'est pas une popup possédée par notre fenêtre. Elle vit en revanche sur notre thread avec la classe standard #32770, donc EnumThreadWindows la trouve quel que soit son propriétaire. Mesuré : sans ça le bloc de remontée ne s'exécutait jamais, silencieusement.
  • Piège n°2, le focus : SetForegroundWindow est refusé à un processus qui n'est pas déjà devant, et le hotkey vient d'une autre application. Il faut attacher notre file d'entrées au thread de la fenêtre de premier plan (VS Code). Réutiliser le helper d'autofill ne marche pas : il attache au thread de la cible, or le dialogue appartient à notre propre thread — l'attache est un no-op et le dialogue remontait sans jamais recevoir le focus.
  • Vérifié au log : le WM_HOTKEY est bien distribué pendant la boucle modale du dialogue, et IsWindowEnabled détecte correctement l'état. Ces deux points étaient les hypothèses coûteuses à écarter avant de chercher ailleurs.
  • Piège du message : une balloon tray, pas un toast — notre fenêtre est désactivée et derrière le dialogue, rien de rendu dans la page ne serait visible. Elle suit le réglage « alertes tray » : désactivé, tu auras le focus sans le message.

B18 · clavier pendant une opération bloquante ✅

  • ACTION : lancer un Sync now (ou un export) → pendant l'overlay, presser Tab plusieurs fois puis Espace / Entrée.
  • ATTENDU : rien ne se passe. Aucun bouton derrière l'overlay ne prend le focus ni ne s'active. Tout redevient normal ensuite, focus compris.
  • ACTION 2 — les raccourcis : pendant l'overlay, Ctrl+A (tout sélectionner), Ctrl+K (palette), j/k (curseur), Suppr (suppression par lot).
  • ATTENDU : aucun effet. Le plus important est Suppr : supprimer des entrées pendant qu'un sync ou une rotation écrit sur les mêmes lignes est exactement ce que l'overlay doit empêcher.
  • Historique : l'overlay ne bloque que la souris — il couvre la page. Le clavier passait au travers : Tab entrait dans les contrôles derrière et Espace les activait. Corrigé avec l'attribut natif inert (retire un sous-arbre du focus, du hit-testing et de l'arbre d'accessibilité), posé sur chaque enfant de <body> sauf l'overlay.
  • inert ne couvre pas les raccourcis : ils sont écoutés sur le document, donc ils ne traversent aucun élément et rien dans l'arbre ne peut les arrêter. Il a fallu un garde isBusy() explicite sur chacun — même famille que les hotkeys Win32, qui arrivent eux de l'extérieur de la fenêtre.
  • Point de vigilance : hideBusy() retire l'inertie inconditionnellement. Une opération qui échouerait sans le rétablir laisserait une application qui semble gelée.
  • inert ne suffit pas seul : il retire les contrôles du focus, mais pas le défilement. Espace, Page haut/bas, Début/Fin et les flèches font défiler le document sans aucun élément focalisé — la page bougeait encore sous l'overlay. Ces touches sont donc avalées en phase de capture tant que l'opération tourne.
  • Vérifier aussi le défilement : pendant l'overlay, Espace et Page bas ne doivent rien bouger ; après, le défilement clavier doit refonctionner.

B19 · cliquer dans le vide ferme l'éditeur, y compris près des barres ✅

  • ACTION : A ouverte → cliquer dans l'espace vide à droite du titre « All items », puis dans l'espace vide de la barre de pagination.
  • ATTENDU : l'éditeur se ferme (avec la modale de discard s'il est dirty), comme n'importe quel clic hors du panneau.
  • ATTENDU (non-régression) : les commandes de ces barres ne ferment toujours pas l'éditeur — boutons de page, sélecteur 25 / page, tri, barre de sélection, Empty trash.
  • Historique : .content-header et .pagination étaient en liste blanche en entier, pour éviter que la barre de sélection et Empty trash ne ferment l'éditeur. Effet de bord : leurs grandes zones vides devenaient inertes, et un clic à côté du titre ne faisait plus rien. La liste blanche ne retient plus que les contrôles.

B14 · dialogues natifs ouverts par un clic programmatique ✅

  • ACTION : A ouverte + dirty → (a) Settings → Import vault ; (b) télécharger une pièce jointe (fallback web) ; (c) Upload icon ; (d) joindre un fichier.
  • ATTENDU : seul le dialogue natif s'ouvre. Pas de « Discard unsaved changes? » par-dessus, l'éditeur reste ouvert et modifié.
  • Historique : le <input type=file> (et le <a download>) est attaché à <body> puis .click() → le clic synthétique remontait au handler click-outside qui le prenait pour un clic dehors. Gardé par e.isTrusted.

F — Round-trip export / import (drapeaux & provenance)

F1 · favori + épingle survivent au restore

  • F1a — entrée supprimée puis réimportée ✅ : épingler et mettre en favori une entrée → Export vault → supprimer l'entrée (corbeille + purge) → Import vault. ATTENDU : elle revient épinglée et favorite (chemin INSERT / bulk-import).
  • F1b — import en mode écrasement ✅ : même export, mais sans supprimer l'entrée. Retirer l'épingle et le favori localement, réimporter, choisir Overwrite.✅ ATTENDU : les deux drapeaux du fichier sont réappliqués.
  • Historique : pinned n'était dans aucun container ; favorite y était écrit mais jamais relu (ni parser, ni body, ni INSERT) → les deux perdus au restore. Puis F1a a marché sans F1b : le mode écrasement passe par PUT, qui ignore volontairement favorite/pinned pour qu'un sync ne puisse jamais écraser les drapeaux locaux (device-local par design). L'import les applique donc via les endpoints de bascule dédiés, uniquement quand ils diffèrent.

F2 · même chose depuis une auto-backup ✅

  • ACTION : Backup now → supprimer l'entrée → importer le fichier vault-autobackup-*.json.
  • ATTENDU : identique à F1.
  • La présence du fichier ne prouve RIEN : si le mot de passe de backup remontait vide de la pref, le conteneur serait produit quand même — chiffré avec une chaîne vide, donc parfaitement valide et impossible à ouvrir avec le vrai mot de passe. Le seul critère est que l'import le déchiffre : la modale doit lister le bon nombre d'entrées avec leurs sites et identifiants réels.
  • Pourquoi ce test compte depuis 2026-08-02 : c'est le seul chemin qui lit autoBackupPwd depuis la pref DPAPI. Il casserait si loadAutoBackupConfig(true) n'était pas passé là où le chiffrement a lieu (cf. E9).

F3 · CSV étranger → drapeaux à 0

  • ACTION : importer un CSV sans colonnes favorite/pinned.
  • ATTENDU : entrées importées ni épinglées ni favorites (défaut 0), pas d'erreur.

F4 · le sync ne propage PAS les drapeaux (device-local, décidé 2026-07-11)

  • ACTION : épingler/favoriser une entrée sur le device A → Sync now → Sync now sur le device B.
  • ATTENDU : sur B, l'entrée n'est pas épinglée/favorite. Et un sync ne doit jamais retirer un drapeau posé localement sur B (le PUT serveur ne touche pas ces colonnes).

F5 · icône custom : provenance préservée ✅

  • ACTION : upload d'une icône custom → export → supprimer l'entrée → import → puis Re-fetch all.
  • ATTENDU : l'icône custom revient et survit au Re-fetch all (le flag icon_custom a fait le round-trip).

F7 · arbitrage last-write-wins du sync ✅

  • PRÉ : sync configuré (un serveur WebDAV local suffit : python -m wsgidav.server.server_cli --host=127.0.0.1 --port=8080 --root=<dir> --auth=anonymous, URL pointant un fichier, ex. http://127.0.0.1:8080/vault-sync.json).
  • ACTION : sync (le distant porte aa) → changer le username en C, Save → fermer l'app → reculer updated_at de l'entrée sous la date du distant, directement en base → fermer la base → relancer → sync sans ouvrir l'entrée.
  • ATTENDU : le username revient à aa, toast 1 updated. L'inverse (updated_at local plus récent) garde C.
  • Piège n°1 — ne rien toucher entre les deux : ouvrir l'entrée et sauver quoi que ce soit émet un PUT, et tout PUT rebumpe updated_at. Le recul manuel est alors annulé juste avant le sync et le local regagne : le test s'invalide lui-même.
  • Piège n°2 : c'est bien updated_at qui arbitre (app.sync.js:423), pas created_at.
  • Corollaire : quand le distant gagne, la branche PUT réécrit toute l'entrée. Une modification locale non poussée est donc perdue, pas fusionnée champ par champ — c'est le principe du last-write-wins par entrée.
  • Éditeur ouvert pendant le sync : il ne se rafraîchit pas en direct, délibérément (ça écraserait la saisie en cours). Le garde-fou est au Save — modale « Entry changed elsewhere », cf. A11. Fermer sans sauver puis rouvrir montre la version issue du merge.

F6 · champ Notes multiligne — la forme survit ✅

  • ACTION : sur un login, Add note → saisir plusieurs lignes → Save → rouvrir l'entrée.
  • ATTENDU : toujours multiligne et masqué ; l'œil révèle. Le contenu conserve ses retours à la ligne.
  • ACTION 2 : dupliquer l'entrée, puis export → import. ACTION 3 : Sync now (aller-retour vérifié — pousser, supprimer localement, re-synchroniser pour faire revenir l'entrée du distant).
  • Trois choses à vérifier séparément : le contenu avec ses retours à la ligne, la clé multiline (rouvre en zone de texte), la clé is_secret (masqué, œil présent). Une perte se produirait à l'ouverture sur le device receveur, pas pendant le transfert — la sync porte custom_fields comme un blob opaque.
  • ATTENDU : le champ reste multiligne partout — rien à ajouter dans ces chemins, il voyage dans custom_fields.
  • Pourquoi ce test existe : les champs personnalisés sont reconstruits clé par clé à deux endroits, la normalisation à l'ouverture et la reconstruction au Save, et chacun jette ce qu'il ne nomme pas. multiline est donc porté explicitement dans les deux ; sans ça le champ revient sur une ligne à la première réouverture (le contenu survit, mais illisible). Même piège que options pour les combobox.
  • Choix de conception : pas de colonne notes_enc dédiée. Le corps d'une entrée note est encrypted_password ; un vrai champ notes aurait voulu une nouvelle colonne chiffrée et les 7 points de passage de CLAUDE.md. custom_fields est déjà porté par la rotation, la duplication, l'export/import et la sync.

C — withEncryptedMeta sur tout write /entries

Test générique, à rejouer après toute modif touchant un body POST/PUT /entries : faire l'action, relancer l'app (pour relire depuis SQLite), vérifier que nom / site / username / tags s'affichent encore.

Chemins à couvrir : Save éditeur (neuf + existant), duplicate, import (bulk + overwrite), sync (applyRemoteSnapshot), addTagToEntry, batchAddTag, moveEntryToFolder, moveEntriesToFolder, batchMoveToFolder, migrateMetadataAtRest (au unlock), rotation du master password.

Symptôme du bug : après relance, lignes au nom vide / (no name).


D — Vue corbeille

D4 · les cibles de dépôt n'acceptent que nos glissers ✅

  • ACTION : dans la corbeille (vue tableau), sélectionner du texte dans une cellule et le glisser sur un dossier, sur (no folder), sur un tag, sur Trash.
  • ATTENDU : aucune cible ne s'allume. Les lignes de corbeille ne sont pas déplaçables (draggable=false), mais glisser du texte reste possible partout dans le navigateur.
  • Historique : les cibles s'allumaient sur n'importe quel glisser puis lisaient text/plain pour y trouver des identifiants — le texte d'une cellule donnait NaN, filtré, donc rien ne se passait : une invitation à déposer suivie d'aucune action. Nos glissers d'entrées portent désormais leur propre type application/x-pm-entries (comme les dossiers avec application/x-pm-folder), et les quatre cibles le vérifient avant de s'allumer.
  • Non-régression : glisser une carte ou une ligne (hors corbeille) sur un dossier, sur (no folder), sur un tag et sur Trash fonctionne toujours, sélection multiple comprise.

D5 · cocher une carte ne bouge ni la grille ni le défilement ✅

  • ACTION : liste longue → défiler vers le bas → cocher une carte (clic sur la case, ou Ctrl+clic).
  • ATTENDU : la position de défilement ne bouge pas, et la grille ne se décale pas verticalement. La case se coche, la barre de sélection apparaît.
  • Tester les trois vues : cartes, liste et tableau. Le décalage a survécu un premier correctif en vues liste et tableau seulement.
  • Deux causes distinctes, corrigées ensemble : (1) la barre de sélection dépassait la hauteur réservée par .content-header et poussait toute la grille. Fixer la hauteur de la barre n'a pas suffi : min-height sur l'en-tête n'est qu'un plancher, n'importe quel enfant plus haut fait encore grandir la rangée. L'en-tête a donc une hauteur fixe (62px = 24 de padding + 38 de barre ; 54px sur écran étroit), ce qui rend tout débordement purement visuel. (2) toggleChecked appelait renderGrid(), qui reconstruit toutes les cartes — le conteneur de défilement repartait donc à zéro et l'utilisateur était renvoyé en haut de la liste. Le basculement se fait désormais sur place sur la seule carte concernée.
  • À vérifier aussi : le ✓ apparaît / disparaît (il est ajouté au rendu, pas par CSS), l'info-bulle bascule Select / Deselect, et le compteur de la barre est juste. Idem en vue tableau et en vue liste.

D6 · l'espacement de l'en-tête ne change pas au défilement ✅

  • ACTION : liste longue, dans les trois vues → comparer l'écart entre le titre « All items » et la barre de pagination, en haut de liste puis après avoir défilé (en-tête collé).
  • ATTENDU : écart identique dans les deux cas, et aucune ligne visible entre les deux barres. En vue tableau, l'en-tête de colonnes reste garé juste sous la pagination.
  • Historique : l'écart était une marge basse, et --hdr-h — qui dit à la pagination où se garer — est mesuré avec offsetHeight, qui ignore les marges. Une fois collée, la pagination remontait donc de 20 px. Corriger la seule mesure aurait laissé une bande transparente traversée par les lignes ; passer la marge en padding la rend à la fois mesurable et opaque.

D1 · menu kebab en table view ✅

  • ACTION : corbeille + vue table → kebab d'une ligne
  • ATTENDU : Restore / Copy… / Delete forever uniquement. Pas de Pin / Favorite / Edit / Duplicate / Move to trash.

D2 · vues cartes et liste ✅

  • ATTENDU : boutons restore + purge (déjà le cas avant), pas de drag possible.

D3 · clic sur une ligne de corbeille ✅

  • ATTENDU : ouvre Trash actions, jamais l'éditeur (une entrée supprimée ne doit pas pouvoir être sauvegardée).

E — Auth / recovery / mémoire (série 2026-07-25)

Ces flux touchent la dérivation de clé et l'anti-lockout : tester sur un compte argon2id-v2 (défaut courant). Rebuild Delphi requis (fixes serveur) + BuildAssets pour le JS.

E1 · reauth — mauvais master pw reste inline (les 4 flux) ✅

  • ACTION : sur chacun de Set/Change PIN, Enable Quick Unlock, Generate recovery code, Export → taper un mauvais master pw.
  • ATTENDU : le modal reste ouvert avec « Wrong master password. Attempt N / 5. » ; au 5ᵉ échec ou 429 → toast lockout + fermeture. Annuler à tout moment = action abandonnée, rien fait.
  • Historique : PIN / Quick Unlock / recovery fermaient + toast au 1ᵉʳ essai.

E2 · recovery setup sur compte argon2id-v2 ✅

  • ACTION : Generate recovery code → bon master pw.
  • ATTENDU : code généré, pas de « invalid password ».
  • Historique : /recovery-key/setup rejetait tout compte -v2 / argon2id (vérif serveur ne connaissait que pbkdf2 / pbkdf2-sha256).

E3 · recovery generate → annuler n'écrit rien ✅

  • PRÉ : un code de recovery existe déjà.
  • ACTION : Regenerate code → bon master pw → à l'affichage du code, Cancel (ou Esc).
  • ATTENDU : toast « Cancelled — no recovery code was set » ; l'ancien code fonctionne toujours au redeem. Refaire avec I saved it → nouveau code actif, ancien invalidé.
  • Historique : le setup était POSTé avant l'affichage → annuler écrasait quand même l'ancien code (chemin silencieux vers le lockout).

E4 · recovery redeem sur compte argon2id-v2 ✅

  • ACTION : générer un code, se déconnecter, Use a recovery code → username + code.
  • ATTENDU : déverrouillage OK, redirection vers Change master password. Le coffre se déchiffre (clé dérivée avec les bons params Argon2 renvoyés par le serveur).
  • Historique : la réponse redeem n'echoait pas argon2 → params par défaut (correct aujourd'hui, lockout latent si params non-défaut).

E8 · le mode de déverrouillage survit au redémarrage ✅

  • ACTION : créer un PIN → Unlock method = PIN → verrouiller (le PIN est bien demandé) → fermer et relancer l'app.
  • ATTENDU : le PIN est demandé, pas le mot de passe maître.
  • Variantes : mode both → mot de passe puis PIN au démarrage à froid ; retour en mode pw → mot de passe seul ; 5 PIN faux (le blob DPAPI est effacé) → relancer → retour au mot de passe, sans blocage.
  • Historique : unlockMode n'existait que dans localStorage — vidé à chaque lancement, le port éphémère changeant l'origine — et dans settings_json, illisible avant d'être connecté. Au démarrage à froid l'écran d'auth retombait donc toujours sur 'pw', alors que le blob PIN était bien présent. Écrit désormais aussi en pref DPAPI (Bridge.setPref) et relu par showAuth, comme rememberedUsername. Le réglage est device-local par nature (le blob PIN est lié à ce Windows) : il reste dans settings_json pour l'historique, mais c'est la copie DPAPI qui fait autorité au boot.
  • Auto-correction : si la pref dit pin alors que le blob n'existe plus, applyAuthScreenMode force pw — pas de verrouillage possible.

E9 · aucun secret au repos dans la mémoire du renderer ✅

Contrôle par dump mémoire, à refaire après toute modification touchant les prefs ou le déchiffrement.

  • Mise en scène : rebuild complet → lancer → Quick Unlock → ne rien ouvrir (aucune entrée, aucun réglage) → dumper le processus.
  • Quel processus : le renderer, pas le plus gros. Le Gestionnaire des tâches le nomme d'après l'onglet — « WebView2 : Vault » ; dans Process Explorer, ajouter la colonne Command Line et chercher --type=renderer. Le processus GPU est souvent 10× plus volumineux et ne contient aucun JavaScript : un scan qui n'y trouve rien ne prouve rien.
  • Témoin de validité : soState doit apparaître (~120 en UTF-16). À 0, ce n'est pas le renderer et le test est nul.
  • ATTENDU : this_is_password (mot de passe d'entrée) à 0 — le déchiffrement est à la demande — et le mot de passe d'auto-backup à 0. Le nom de la pref (autoBackupPwd) apparaît, c'est un littéral du code source, pas sa valeur.
  • Historique : le mot de passe d'auto-backup était présent en clair dans cette exact mise en scène, visible dans Bridge.onPrefResult("autoBackupPwd","…") et dans les tampons du protocole DevTools — donc deux processus — et il survivait au verrouillage automatique qui efface pourtant la clé du coffre. Cause : loadAutoBackupConfig / loadSyncConfig récupéraient les secrets alors que l'interface n'a besoin que de savoir s'ils existent. Voir prefs/has.
  • Limite assumée : après un backup ou un sync réellement exécuté, le secret est de nouveau en mémoire — il faut bien le passer à la fonction de chiffrement. Ce qui est supprimé, c'est sa présence permanente sans qu'aucune opération ne l'ait demandée.
  • Après le test : supprimer les dumps. Ils contiennent des secrets en clair, sans chiffrement.
  • Validation fonctionnelle obligatoire — les quatre chemins qui lisent encore un secret, à repasser après toute modification de loadAutoBackupConfig / loadSyncConfig : Backup now puis import du fichier produit (le fichier seul ne prouve rien, cf. F2) · Test connection WebDAV avec authentification · Sync now (le compteur updated prouve que l'instantané distant a été déchiffré, pas seulement téléchargé) · le backup planifié au déverrouillage.
  • Monter un WebDAV authentifié pour le test : python -m wsgidav.server.server_cli -c <config.json> avec simple_dc.user_mapping et accept_basic: true, accept_digest: false — notre client n'envoie que du Basic. Le partage ne doit plus être marqué (anonymous) au démarrage. Un premier 401 suivi d'un 200 authentifié dans le journal est normal : WinHTTP n'envoie les identifiants qu'après le défi.
  • Piège de saisie : les champs identifiant / mot de passe n'enregistrent que sur change (perte du focus). Taper puis cliquer aussitôt sur Test connection peut lire la préférence avant son écriture — les deux passent par des navigations cmd://, qui se coalescent. Faire Tab avant de cliquer.

E5 · la clé du coffre n'est plus en clair dans le web storage ✅

  • ACTION : coffre déverrouillé → console WebView2 : sessionStorage.getItem('cryptoKey').
  • ATTENDU : null. (Avant : une string base64 de 44 car = la clé AES.)
  • Vérifié par le code, 2026-08-03 — la console est indisponible en prod (DevTools désactivé), mais le résultat est structurel : plus aucun persistCryptoKey / restoreCryptoKey (seul subsiste le commentaire qui documente leur retrait), et sessionStorage ne porte que username, salt, kdfIterations, hashAlgo, authToken, csrfToken, argon2Params. Ni sel ni itérations ne sont des secrets — /login/challenge les publie.
  • sessionStorage.removeItem('cryptoKey') est volontaire (js/app.js) : il efface le blob laissé par une ancienne version de l'app. Ne pas le supprimer en croyant à du code mort.
  • Ne pas confondre avec E9 : celui-ci porte sur le web storage (fichiers sur disque), E9 sur la mémoire du processus. Une clé peut être absente de l'un et présente dans l'autre.
  • Bonus : dump mémoire du renderer → this_is_password peut persister (strings V8, non corrigeable) ; c'est la clé qu'on a retirée du storage.

E6 · non-régression — les 4 exporteurs de clé marchent toujours ✅

Après le retrait du cache sessionStorage, re-tester que ces flux (qui exportKey la clé) fonctionnent : login, Quick Unlock, PIN, recovery redeem, change master password.

E7 · non-régression — éditeur slideover après suppression du modal mort ✅

Le générateur, les tags et la barre de force étaient partagés. Vérifier dans l'éditeur slideover : créer + éditer une entrée, ajouter/retirer des tags, Generate → Use insère le mot de passe, la barre de force réagit.

  • Bug trouvé par ce test : la barre restait figée sur le mot de passe précédent. Affecter .value en JavaScript ne déclenche aucun événement ; le code appelait soDirtyCheck() à la main, mais la barre de force écoute input et n'était donc jamais prévenue.
  • Correctif : émettre new Event('input') sur le champ plutôt que d'appeler un gestionnaire nommé. Tous les auditeurs du champ sont couverts, y compris ceux qu'on y branchera plus tard — c'est le même piège chaque fois qu'un bouton remplit un champ à la place de l'utilisateur.

G — Vault health (série 2026-07-25)

G1 · bouton Fix quand le changement d'entrée est annulé ✅

  • ACTION : A ouverte + dirty → aller sur Vault health → cliquer Fix sur une autre entrée B → Cancel au message discard.
  • ATTENDU : rien ne bouge. A reste ouverte, son mot de passe reste masqué, aucun clignotement du bouton Generate.
  • Puis refaire → Discard : B s'ouvre, c'est le mot de passe de B qui est démasqué + sélectionné, et c'est le Generate de B qui pulse.
  • Le focus RESTE sur le mot de passe (~1 s après le clic, vérifier que le curseur n'a pas sauté sur le titre) : openSlideOver programme un focus différé de 80 ms sur #soTitle, neutralisé ici par { noFocus: true }.
  • Historique : openSlideOver ne renvoyait rien, donc openEntryForFix continuait après une annulation et démasquait en clair le mot de passe de l'entrée restée ouverte.

G2 · Fix sur une entrée supprimée entre-temps — sans objet

  • ACTION prévue : ouvrir Vault health → mettre à la corbeille une entrée listée → revenir sur health sans Recompute → Fix sur cette entrée.
  • Pourquoi c'est irréalisable : G3 a rendu ce cas impossible à mettre en scène — le cache est invalidé à chaque mutation, donc revenir sur le tableau de bord recalcule toujours et l'entrée supprimée n'y figure plus. Le garde reste en place dans le code (openEntryForFix sort si openSlideOver renvoie false), il n'est simplement plus atteignable par l'interface.
  • Tentative la plus proche, vérifiée : cocher une entrée dans la liste (la barre de sélection apparaît) → passer en Vault health → Delete depuis cette barre. Le tableau de bord se recalcule tout seul et l'entrée disparaît de la liste : toujours pas de liste périmée.
  • Défaut trouvé au passage : la sélection survivait au changement de vue. Visible en santé / authenticator / journal d'audit, qui n'affichent aucune case à cocher tout en gardant la barre d'actions. Bien plus grave vers la corbeille : les identifiants cochés désignent des entrées vivantes, alors que la barre de la corbeille propose Delete forever — on pouvait donc purger définitivement des entrées qui n'y étaient jamais entrées. La sélection est maintenant vidée dès que la vue change, quelle qu'elle soit.

G6 · la sélection ne franchit pas les vues ✅

  • ACTION : cocher deux entrées dans All items → passer à la corbeille.
  • ATTENDU : plus rien de coché, barre d'actions disparue. Surtout pas Delete forever actif sur une sélection d'entrées vivantes.
  • Variantes : vers un dossier, un tag, Notes, Recently used, Vault health, Authenticator, Audit log — même résultat partout. Et au retour dans la vue d'origine, la sélection reste vide (elle n'est pas mémorisée).

G5 · activer / désactiver HIBP depuis la vue santé ✅

  • ACTION : dans Vault health, activer la vérification HIBP → attendre la fin du scan sans quitter la vue.
  • ATTENDU : la carte Pwned et le score se mettent à jour tout seuls quand le scan finit.
  • ACTION 2 : désactiver HIBP → la catégorie Pwned retombe à 0 immédiatement.
  • ACTION 3 : relancer un scan et désactiver HIBP en plein milieu.
  • ATTENDU : la pastille de progression (en bas, à droite de la barre latérale) disparaît aussitôt ; plus aucune requête réseau. Elle continuait à compter après « Breach check disabled », le scan poursuivant un aller-retour réseau par entrée.
  • Historique : le cache santé était calculé avant l'arrivée des résultats et rien ne l'invalidait ensuite. La vue affichait 0 pwned pendant que les cartes de la liste portaient déjà le drapeau ; il fallait sortir et revenir pour voir 2. Symétrique à la désactivation, qui vidait hibpResults en laissant le tableau de bord compter des brèches disparues. hibpCheckAllEntries et le toggle invalident désormais healthCache.

G4 · le score bouge par paliers de 10 — comportement attendu

  • OBSERVÉ : après correction de deux mots de passe, 30 → 40 → 50.
  • C'EST NORMAL : le barème est -5 par mot de passe faible, -10 par groupe de réutilisation, -2 par entrée ancienne, -15 par entrée compromise. Corriger un mot de passe partagé par deux entrées casse le groupe → +10.
  • Effet de bord à connaître : chaque catégorie est plafonnée (-40 faible, -30 réutilisé, -20 ancien, -50 compromis). Au-delà de 8 mots de passe faibles, en corriger un ne change rien au score tant qu'on n'est pas repassé sous le plafond. Le plafond existe pour qu'une seule catégorie ne puisse pas écraser les autres, mais il rend les premières corrections invisibles quand le coffre en compte beaucoup.

G3 · le tableau de bord reflète les mutations ✅

  • ACTION : ouvrir Vault health (noter le score) → revenir à la liste → faire une mutation : import, sync, suppression, batch delete, duplicate, restore, ou changement de master pw → rouvrir Vault health.
  • ATTENDU : score et listes recalculés (les entrées supprimées ont disparu, les importées apparaissent) sans cliquer Recompute.
  • Historique : le cache n'était vidé qu'au lock / au save / via Recompute — après un import ou une suppression le dashboard restait figé sur l'état précédent.

H — Hotkey 2FA (Ctrl+Shift+T, série 2026-07-25)

H1 · remplissage simple ✅

  • ACTION : entrée avec TOTP → ouvrir son site → cliquer le champ 2FA → Ctrl+Shift+T.
  • ATTENDU : le code à 6 chiffres est tapé dans le champ focalisé, toast « 2FA code filled: … ».

H2 · entrée sans TOTP ✅

  • ACTION : fenêtre dont le titre matche une entrée sans secret TOTP → Ctrl+Shift+T.
  • ATTENDU : « No 2FA code stored for "…" » (et pas le « no match » générique, ni un remplissage du mot de passe).

H3 · picker multi-match ✅

  • PRÉ : 2 entrées matchent le titre, une seule a un TOTP.
  • ATTENDU : le picker s'ouvre uniquement s'il reste ≥ 2 candidats avec TOTP ; ici il ne doit pas s'ouvrir (1 seul candidat éligible → remplissage direct). En-tête du picker : « Pick entry (2FA code) — ».

H4 · combo configurable ✅

  • ACTION : Settings → Autofill → cliquer le bouton Ctrl+Shift+T → capturer un autre combo.
  • ATTENDU : accepté et actif immédiatement ; refusé s'il duplique un des 4 autres hotkeys ; Reset to defaults le remet à Ctrl+Shift+T ; le réglage survit à un redémarrage (synced via settings_json).

H5 · coffre verrouillé ✅

  • ACTION : verrouiller → Ctrl+Shift+T.
  • ATTENDU : l'app revient au premier plan avec le focus sur le master password (même comportement que les autres hotkeys), pas de no-op silencieux.

H9 · retour au tray après un pick dans le picker ✅

  • ACTION : app au tray → Ctrl+Shift+L depuis une autre app sur un titre à plusieurs candidats → le picker sort la fenêtre du tray → choisir une entrée.
  • ATTENDU : le remplissage se fait puis l'app repart au tray, comme elle est venue.
  • Variantes : match unique (pas de picker) → reste au tray ; app déjà visible → reste visible ; app minimisée dans la barre des tâches → n'est pas envoyée au tray (not Self.Visible est faux pour une fenêtre iconique) ; picker fermé par Esc sans choisir → l'app repart au tray elle aussi (symétrique de quick-search, qui se cache sur Esc) et le hotkey suivant n'hérite pas de l'état capturé.
  • Historique : seul le chemin quick-search capturait « était cachée » (LWasHidden) et le faisait remonter via hide_after ; JS n'envoie jamais ce paramètre depuis le picker d'autofill, donc l'app restait sur le bureau. Capturé désormais côté Delphi dans BridgeAutofillRequest, au moment du hotkey — après, le picker a déjà détruit l'information.

H10 · le focus revient à l'application précédente ✅

Cacher une fenêtre ne transfère le premier plan à personne (contrairement à une minimisation). On restait donc au premier plan en étant invisible : le hotkey suivant capturait notre propre fenêtre comme cible et tapait le mot de passe dedans, et Ctrl+Shift+A ouvrait un « New entry » au titre vide (le garde refusait, à raison, de pré-remplir avec « Password Manager »).

  • ACTION : depuis une application X → ouvrir l'app (icône du tray, hotkey, ou lancement à froid) → fermer par la croix.
  • ATTENDU : X reprend le focus. Puis un hotkey d'autofill depuis X doit cibler X, et Ctrl+Shift+A pré-remplir son titre.
  • ACTION 2 : enchaîner plusieurs allers-retours en changeant d'application entre deux — c'est toujours la dernière réellement utilisée qui récupère le focus.
  • Non-régression : après un autofill (pick ou Esc), le focus reste sur la fenêtre remplie — ces deux chemins passent AReturnForeground = False, ayant déjà placé le premier plan eux-mêmes.
  • Historique — quatre tentatives, toutes tranchées par le log : (1) rendre le focus après avoir caché la fenêtre — Windows n'autorise que le propriétaire courant du premier plan à le céder, l'appel était silencieusement refusé ; (2) échantillonner le premier plan au moment du besoin — il vaut alors notre fenêtre, la capture se rejetait elle-même et n'apprenait jamais rien (prev=<none> à chaque fois) ; (3) capturer au clic sur l'icône du tray — on obtient une fenêtre du shell (NotifyIconOverflowWindow, « Fenêtre de dépassement de capacité de la barre d'état système ») ; (4) consommer la valeur après usage — le premier masquage marchait, les suivants plus. Solution : un hook EVENT_SYSTEM_FOREGROUND (WINEVENT_OUTOFCONTEXT, aucune injection) note chaque changement et garde le dernier qui n'est ni à nous ni au shell ; la restitution se fait avant de cacher.

H13 · pas de Ctrl+A après le Tab (formulaires type Reddit) ✅

  • ACTION : Ctrl+Shift+L sur un formulaire où Tab depuis le username ne mène pas au champ mot de passe (page de connexion Reddit : le Tab tombe à côté).
  • ATTENDU : plus de sélection de toute la page. Le mot de passe, lui, n'est toujours pas saisi sur ce formulaire — et ce n'est pas corrigeable : vérifié au Tab manuel, sans autofill, le Tab depuis le username va sur le bouton œil (révéler le mot de passe), que Reddit place avant le champ dans l'ordre de tabulation. On envoie des frappes sans jamais voir où le focus atterrit.
  • Contournement, seul chemin fiable sur ces formulaires : cliquer le champ mot de passe puis Ctrl+Shift+P (password seul, aucun Tab, aucune supposition) — testé sur Reddit, fonctionne.
  • Ne pas « corriger » en envoyant deux Tab : l'ordre de tabulation dépend de chaque site, un second Tab casserait tous les formulaires normaux.
  • Historique : la séquence envoyait Ctrl+A + Del avant chaque champ. Après le Tab c'était doublement mauvais : redondant (arriver par Tab sélectionne déjà tout le contenu du champ, cf. K1) et destructeur quand le Tab tombe sur un lien, un bouton ou le document — Ctrl+A sélectionne alors la page entière et la frappe se perd. Retiré ; celui d'avant le username reste (l'utilisateur y a placé le curseur par un clic, rien n'est sélectionné).

H11 · l'application précédente réduite reste réduite ✅

  • ACTION : depuis X, réduire X dans la barre des tâches → ouvrir le coffre (icône du tray) → le fermer par la croix.
  • ATTENDU : X reste réduite. Le focus part où Windows décide ; on ne rouvre pas une fenêtre que l'utilisateur a rangée.
  • Pourquoi c'est fragile : ForceForegroundWindow fait ShowWindow(SW_RESTORE) sur une cible iconique — il est écrit pour l'autofill, où sortir la fenêtre cible est justement le but. Réutilisé tel quel pour la restitution du focus, il changeait l'état de la fenêtre d'une autre application. Garde not IsIconic posé côté MinimizeToTray, pas dans ForceForegroundWindow (l'autofill en a toujours besoin).

H12 · démarrage -tray — l'application qui lance garde le focus ✅

  • ACTION : depuis une console PowerShell, lancer .\PMServer.exe -tray.
  • ATTENDU : le coffre démarre caché et la console garde son focus (rectangle actif dans la barre des tâches).
  • ACTION 2 : « Start with Windows » / redémarrage → rien ne passe au premier plan.
  • Historique : d'abord codé « rien à rendre, on n'a rien pris ». Faux : FMX affiche son proxy de barre des tâches avant qu'on le cache, donc le premier plan est pris un instant, et la console lançeuse restait inactive. La valeur utile est GStartupForeground, capturée à l'initialisation de l'unité — avant toute fenêtre à nous. Au démarrage de Windows, le premier plan est une fenêtre du shell, que la capture filtre : rien n'est activé, ce qui est le comportement voulu là.
  • Les quatre réglages de MinimizeToTray (AReturnForeground) : fermeture par la croix = rend ; quick-search copie-puis-cache = rend (c'est ce qui permet le Ctrl+V dans l'app d'origine) ; démarrage -tray = rend ; autofill et annulation du picker = ne rendent pas, seuls cas où le focus a déjà été placé volontairement (sur la fenêtre remplie).

H7 · combo déjà pris par une autre application ✅

  • ACTION : assigner à un hotkey un combo qu'une autre app tient déjà en global (ex. Ctrl+Shift+Q si Quick Assist / un lanceur l'occupe ; sinon, tester en assignant le même combo dans deux apps).
  • ATTENDU : toast nommant le combo refusé — « Already used by another app: 2FA code — pick another combo » — et un ⚠ ambre persistant sur cette ligne de Settings, et une pastille ambre sur l'entrée Settings de la sidebar (seul indice hors du panneau). Le toast d'avertissement reste ~10 s et se ferme au clic. Les autres hotkeys restent fonctionnels (enregistrement best-effort, pas de tout-ou-rien).
  • ACTION 2 : fermer l'app concurrente → Reset to defaults (ou re-capturer le combo) → le hotkey redevient actif et le ⚠ disparaît.
  • Piège de test : une app qui capture via un hook bas niveau (et non RegisterHotKey) avale la frappe sans faire échouer notre enregistrement → hotkey inerte sans ⚠. Utiliser AutoHotkey (^+t::MsgBox) ou Lightshot pour un vrai conflit.
  • Historique : le message disait « one or both hotkeys » (faux depuis qu'il y en a 3) sans dire lequel ; et un échec sur Quick search ou New entry n'était écrit que dans le panel debug — invisible en prod, le hotkey semblait juste cassé.

H8 · capturer un combo déjà pris par une autre app ✅

  • ACTION : Settings → cliquer le bouton du hotkey 2FA → presser un combo qu'une autre app détient (ex. Ctrl+Shift+T).
  • ATTENDU : au relâchement (pas après un délai), toast « No key registered after Ctrl+Shift. If you did press one, another app already owns that combo — pick a different one. » et le bouton reprend son libellé précédent.
  • ACTION 2 : presser un combo libre (ex. Ctrl+Win+T) → accepté, actif immédiatement, pas de ⚠.
  • ACTION 3 : cliquer le bouton puis ne rien presser pendant 10 s → « Hotkey capture cancelled — no key was pressed. » et le bouton se restaure (sinon la capture avalerait toutes les frappes jusqu'à Échap).
  • ACTION 4 : cliquer le bouton puis presser un combo déjà assigné à un autre de nos slots (ex. Ctrl+Shift+L) → « That combo is already used by another hotkey ». Doit fonctionner sans déclencher l'autofill : nos 5 hotkeys sont libérés pendant la capture.
  • Historique : un combo tenu par RegisterHotKey (le nôtre ou celui d'une autre app) n'atteint jamais WebView2 → la capture restait figée sur « Press combo… » sans message, et la vérif de collision interne était du code mort.

H6 · code en fin de période ✅

  • ACTION : presser le hotkey juste avant la fin du compte à rebours de 30 s.
  • ATTENDU : le code tapé est celui de la période courante au moment de la frappe (généré à la demande, pas mis en cache).

I — État de la fenêtre (maximisé, série 2026-07-25)

L'app démarre maximisée (WindowState = wsMaximized dans la .fmx).

I1 · maximisé survit à Show desktop ✅

  • ACTION : fenêtre maximisée → Win+D (ou Show desktop) → revenir à l'app (barre des tâches, tray ou Alt+Tab).
  • ATTENDU : elle revient maximisée, sans passer par une frame en taille normale.
  • Historique : FMX oublie l'état maximisé quand Windows minimise la fenêtre de l'extérieur, et aucun de nos chemins de restore ne tourne pour un clic sur la barre des tâches.
  • Historique (flash) : une fois l'état final correct, il restait un scintillement — le cadre apparaissait en taille normale puis grandissait. C'est un bug FMX, reproduit sur une application FMX vide : FMX perd le maximisé quand Windows minimise le formulaire de l'extérieur. Windows choisit alors rcNormalPosition comme cible avant d'animer, donc aucune correction réactive ne peut arriver à temps — trois ont été essayées et rejetées par la mesure : ForceQueue (un tour de boucle trop tard), appel inline dans MINIMIZEEND (le hook est délivré après le WM_SIZE), correction dans le WM_SIZE lui-même (l'état devient parfait, le flash reste — preuve que le problème n'était plus l'état). Écarté aussi : poser WPF_RESTORETOMAXIMIZED, que GetWindowPlacement rapporte déjà à 2 sur une fenêtre maximisée (no-op). Statut : non corrigé, assumé. Une cinquième piste a été tentée puis annulée : garer rcNormalPosition et le remplacer par rcWork du moniteur pendant que la fenêtre est en bas, pour que « restaurer en normal » vaille déjà plein écran. Ça supprimait bien le flash, mais tout SetWindowPlacement sur une fenêtre en cours de minimisation — ou déjà iconique — laissait un moignon de fenêtre dans un coin de l'écran, y compris en différant l'écriture jusqu'à IsIconic. Un artefact visible permanent est pire que l'animation. Reverté. Ce qui reste : l'état final est correct (la fenêtre revient bien maximisée), seule l'animation de restauration est laide. Ne pas rouvrir sans une vraie raison — la seule route restante est de sous-classer le WndProc de la fenêtre FMX pour corriger le rectangle dans WM_WINDOWPOSCHANGING, ce qui insère du code dans la boucle de messages d'une fenêtre qu'on ne contrôle pas, avec le tray, l'autofill et le démarrage -tray en aval.

I2 · normal survit aussi ✅

  • ACTION : dé-maximiser la fenêtre → Win+D → revenir.
  • ATTENDU : elle reste en taille normale — le correctif ne doit jamais forcer le maximise.

I3 · maximiser / dé-maximiser reste enregistré ✅

  • ACTION : dé-maximiser → Win+D → retour (normal) → maximiser → Win+D → retour.
  • ATTENDU : chaque retour respecte le dernier geste de l'utilisateur.

I4 · retour depuis le tray ✅

  • ACTION : fenêtre maximisée → fermer (part au tray) → clic sur l'icône du tray.
  • ATTENDU : revient maximisée (chemin distinct : RestoreFromTray rejoue son propre placement).

I5 · le garde de minimisation ne reste pas bloqué (autofill → tray) ✅

Le chemin le plus subtil : ExecuteAutofill minimise réellement la fenêtre (SW_MINIMIZE) avant de taper, ce qui lève le garde ; si elle est ensuite cachée dans le tray au lieu d'être restaurée, MINIMIZEEND peut ne jamais arriver.

  • ACTION : depuis le tray, Ctrl+Shift+Q → choisir une entrée → laisser le remplissage se faire (l'app repart au tray) → rouvrir l'app par l'icône du tray → dé-maximiser (ou maximiser) → Win+D → revenir.
  • ATTENDU : le dernier geste est respecté. S'il ne l'est pas, le garde est resté levé et le suivi de l'état est gelé pour toute la session.
  • Variante : même chose avec Ctrl+Shift+L depuis une autre application.

I6 · le hook n'affecte pas la corbeille ni le tray (non-régression)

  • ACTION : minimiser dans le tray (fermeture), restaurer ; app/focus via le picker d'autofill multi-match ; « Lock vault » depuis le menu du tray.
  • ATTENDU : comportements inchangés. Le hook n'écoute que notre process et MinimizeToTray utilise SW_HIDE, qui n'émet aucun événement de minimisation.

Diagnostic

Avec config.txt contenant debug=true, Ctrl+Shift+D affiche le journal. Lignes utiles : [winstate] minimise hook installed=True au démarrage (un False signifierait que le hook Win32 ne s'installe pas → préservation inactive) et [winstate] re-maximising after restore quand la correction agit.


J — Quick search depuis le tray (série 2026-07-25)

Le flux le plus fragile : la fenêtre revient du tray, un modal s'ouvre, et la fermeture doit renvoyer l'app au tray. Deux cmd:// s'y enchaînent, ce qui est précisément le piège de coalescence documenté dans CLAUDE.md.

J1 · Esc dès l'ouverture, sans cliquer ✅

  • PRÉ : app dans le tray, coffre déverrouillé.
  • ACTION : Ctrl+Shift+Q → ne rien cliquer → Esc immédiatement.
  • ATTENDU : le modal se ferme du premier coup, et l'app repart au tray.
  • Historique : deux bugs distincts ici. (1) WebView2 n'avait pas le focus clavier de l'OS après la restauration → aucune frappe n'atteignait la page, Esc ne faisait rien tant qu'on n'avait pas cliqué dans le champ. (2) En mode fill, cancelAutofill puis minimizeToTray = deux window.location.href consécutifs → coalescés, la minimisation était perdue.

J2 · remplissage puis retour au tray ✅ ✅

  • ACTION : app dans le tray → Ctrl+Shift+Q → choisir une entrée (Enter).
  • ATTENDU : le mot de passe est tapé dans la fenêtre cible et l'app repart au tray — une seule fois (c'est Delphi qui minimise ici, via hide_after, pas le JS : vérifier qu'il n'y a pas de double minimisation).
  • Variantes : Shift+Enter (user+pwd), Ctrl+Enter (username seul).

J3 · copie depuis le menu du tray ✅

  • ACTION : menu du tray → Quick search… → choisir une entrée (Enter).
  • ATTENDU : mot de passe copié, app de retour au tray, et le presse-papiers conserve la valeur (keepclip=1 — c'est le rôle du paramètre).

J4 · app déjà visible (non-régression) ✅

  • ACTION : app ouverte à l'écran → Ctrl+Shift+Q → Esc.
  • ATTENDU : le modal se ferme et l'app reste à l'écran — elle ne doit pas partir au tray (elle n'en venait pas). Idem si elle était seulement minimisée dans la barre des tâches.

K — Autofill : comportements intentionnels (à ne pas « corriger »)

K1 · « Clear the field before typing » désactivé → le password est quand même remplacé

  • ACTION : Settings → Autofill → décocher Clear the field before typing. Puis remplissage complet (Ctrl+Shift+L, ou clic-droit dans la quick search) sur un formulaire dont les deux champs contiennent déjà quelque chose.
  • OBSERVÉ : le username n'est pas effacé (la frappe s'insère), mais l'ancien password disparaît et est remplacé.
  • C'EST NORMAL — vérifié de bout en bout : le JS envoie &clear=0, Delphi met FAutofillPendingClear := False. Notre code n'efface rien. (Depuis le correctif H13 il n'y a de toute façon plus qu'un seul ClearFieldIfWanted, avant le username : celui d'avant le password a été retiré.) La cause est le Tab du pipeline username → Tab → password : arriver dans un champ par Tab sélectionne tout son contenu (comportement standard Windows / navigateur), et la frappe remplace la sélection. Le username, lui, a été focalisé par un clic de l'utilisateur → curseur sans sélection → insertion.
  • Ce que le réglage fait réellement : empêcher l'envoi de Ctrl+A. Il existe pour les terminaux / bureaux distants où Ctrl+A signifie « début de ligne » ou déclenche le préfixe tmux, ce qui casserait le remplissage. Il ne promet pas de préserver le contenu des champs.
  • Ne pas « corriger » : désélectionner après le Tab ferait concaténer le nouveau mot de passe à l'ancien — jamais souhaitable.
  • Test qui isole la cause : Ctrl+Shift+P (password seul) en cliquant directement dans le champ → pas de Tab → l'ancienne valeur reste.

L — Démarrage avec Windows (-tray, série 2026-07-26)

Le flag -tray est ce que « Start with Windows » écrit dans HKCU\…\Run ("<exe>" -tray). Pour tester sans redémarrer : PMServer.exe -tray depuis le dossier de sortie. Ne pas laisser -tray dans Run → Parameters de l'IDE, sinon chaque F9 démarre caché.

L1 · démarrage caché, sans bouton de barre des tâches ✅

  • ACTION : lancer avec -tray (l'app n'étant pas déjà lancée — sinon le mutex single-instance fait sortir la nouvelle instance immédiatement).
  • ATTENDU : aucune fenêtre, aucun bouton dans la barre des tâches, icône présente dans le tray, pas de balloon.
  • Historique : le bouton restait 1-2 s. La fenêtre du formulaire n'était en fait jamais visible — c'est le proxy taskbar FMX (ApplicationHWND) qui s'affichait, et avant même que FormCreate soit atteint, ce qui rendait inopérant tout correctif posé depuis le formulaire. Le masquer ne suffit pas non plus : il faut retirer WS_EX_APPWINDOW et poser WS_EX_TOOLWINDOW, depuis le .dpr, juste après Application.Initialize.

L2 · le bouton revient à la restauration ✅

  • ACTION : après L1, clic sur l'icône du tray.
  • ATTENDU : la fenêtre s'ouvre et le bouton réapparaît dans la barre des tâches.
  • Historique : WS_EX_TOOLWINDOW est permanent — sans le rétablissement (TrayProxyShowInTaskbar dans RestoreWindowFromTray) l'app tournait ensuite sans aucune entrée de barre des tâches.

L3 · lancement normal (non-régression) ✅

  • ACTION : lancer sans -tray.
  • ATTENDU : fenêtre visible et bouton de barre des tâches présents, comme avant. Le correctif est entièrement gardé par le flag.

Reste à traiter (constaté pendant l'audit, non corrigé)

  1. Attachments hors garde A11. Le contrôle « changed elsewhere » couvre le payload de l'entrée, pas ses pièces jointes (table séparée, écrites par leurs propres endpoints). Un sync qui remplace une pièce jointe pendant l'édition n'est pas signalé. Faible risque : l'éditeur ne re-ship jamais les pièces jointes existantes, donc il n'y a rien à écraser.

  2. La corbeille n'est jamais exportée, et rien ne le dit. doExport parcourt state.entries ; la corbeille vit dans state.trashed, une liste séparée — donc absente de l'export manuel, de l'auto-backup et du snapshot de sync. Le choix se défend (la corbeille est une antichambre de suppression), mais il est silencieux : exporter → vider le coffre → réimporter fait disparaître définitivement les entrées en corbeille, alors que l'utilisateur croyait faire une sauvegarde complète.

    • Correctif minimal : une ligne dans le dialogue d'export — « la corbeille n'est pas incluse ». Une phrase, aucun risque.
    • Correctif complet : exporter les entrées supprimées avec leur deleted_at et les réimporter en corbeille. Nouveau champ à porter de bout en bout (parseur, encryptImportEntry, INSERT create et bulk-import), plus le comportement à choisir quand l'entrée existe déjà côté vivant. C'est la classe de modification aux 7 points de passage décrite dans CLAUDE.md.
    • Constaté le 2026-08-03 en testant export → Clear vault → import.