B14 (native dialogs must not stack a discard prompt) and a new section F covering the export/import round-trip: favorite + pinned restored, foreign CSV defaults, sync staying device-local, custom-icon provenance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
16 KiB
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É :
Aouverte 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
sharedapparaît immédiatement dans l'éditeur ; après Save,Aporteold, sharedet 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É :
Aouverte + dirty - ACTION : cocher
B+Cseulement → Add tag →x→ Save dans l'éditeur - ATTENDU : l'éditeur ne bouge pas (aucun chip ajouté),
Aconserve ses tags,B/Ctaguées.✅
A3 · batch Move to folder sur l'entrée éditée
- ACTION :
Aouverte + dirty → cocherA→ Move to folder… →Perso→ Save - ATTENDU : le select Folder de l'éditeur bascule sur
Persotout seul ; après Save l'entrée est bien dansPerso.✅
A4 · dossier choisi dans l'éditeur puis move extérieur
- ACTION :
Aouverte, changer Folder →Archive(ne pas sauver) → cocherA→ Move to folder… →Perso→ Save - ATTENDU :
Persogagne (action extérieure = intention la plus récente). Comportement documenté, pas un bug. ✅
A5 · glisser une entrée sur un dossier (moveEntryToFolder)
- ACTION :
Aouverte + dirty → glisser la ligneAsurPersodans la sidebar → Save - ATTENDU : identique à A3.✅
A6 · glisser une multi-sélection sur un dossier (moveEntriesToFolder) ⚠️
- ACTION : cocher
A+B→ glisser surPerso→ 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(dossierWork) ouverte + dirty → sidebar → supprimer le dossierWork→ Save - ATTENDU : le select de l'éditeur passe à
All; après Save l'entrée est dansAll, le dossier supprimé ne réapparaît pas.✅
A8 · tags édités des deux côtés
- ACTION :
Aouverte, ajouter le chipminedans l'éditeur (ne pas sauver) → batch Add tagsharedsurA→ Save - ATTENDU :
old, mine, shared— aucun des trois perdu.✅
A9 · favori / épingle pendant l'édition (non-régression)
- ACTION :
Aouverte + dirty → cliquer ★ et 📌 sur la ligneA→ 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 :
Aouverte + 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 :
Aouverte → 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 :
Aouverte + dirty → lancer Sync now (ou importer un JSON en mode overwrite) qui metAà 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 :
Aouverte + dirty → Save → modifier encore → Save (le rechargement post-save re-cale la référence) - A12b :
Aouverte + dirty → batch Add tag / Move to folder surA→ Save (l'éditeur a absorbé le changement) - A12c :
Aouverte + dirty → supprimer son dossier → Save (cf. A7) - A12d :
Aouverte + dirty → Fetch missing favicons → Save (l'icône est hors payload éditeur) - A12e :
Aouverte + dirty → sync sans changement distant surA→ Save
A13 · entrée supprimée ailleurs pendant l'édition
- ACTION :
Aouverte + dirty → un sync distant supprimeA(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.
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É :
Aouverte + dirty - ACTION : cliquer ✕ (Move to trash) sur la ligne
B - ATTENDU :
Bpart à la corbeille, l'éditeur deAreste ouvert avec les modifs. Vrai dans les deux réglages deaskBeforeDelete.
B2 · ✕ sur la ligne éditée, askBeforeDelete = OFF
- ACTION :
Aouverte + dirty → ✕ surA - 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 :
Aouverte + dirty → cocherA+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
- ACTION : ouvrir une entrée de la corbeille via Trash actions, puis : (a) Delete forever dessus, (b) batch purge la contenant, (c) Empty trash
- ATTENDU : dans les trois cas l'éditeur se ferme s'il portait une entrée purgée, sinon il reste.
B7 · boutons de la barre de sélection
- ACTION :
Aouverte + dirty → cocherB→ 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 :
Aouverte + 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 :
Aouverte + 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+Lavec 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 :
Aouverte + 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.
B14 · dialogues natifs ouverts par un clic programmatique
- ACTION :
Aouverte + 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é pare.isTrusted.
F — Round-trip export / import (drapeaux & provenance)
F1 · favori + épingle survivent au restore
- ACTION : épingler et mettre en favori une entrée → Export vault → supprimer l'entrée (corbeille + purge) → Import vault du fichier.
- ATTENDU : l'entrée revient épinglée et favorite.
- Historique :
pinnedn'était dans aucun container ;favoritey était écrit mais jamais relu (ni parser, ni body, ni INSERT) → les deux perdus au restore.
F2 · même chose depuis une auto-backup
- ACTION : Backup now → supprimer l'entrée → importer le fichier
vault-autobackup-*.json. - ATTENDU : identique à F1.
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_customa fait le round-trip).
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
D1 · menu kebab en table view
- ACTION : corbeille + vue table → kebab d'une ligne
- ATTENDU :
Restore/Copy…/Delete foreveruniquement. 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/setuprejetait 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).
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.) - Bonus : dump mémoire du renderer →
this_is_passwordpeut 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.
Reste à traiter (constaté pendant l'audit, non corrigé)
- 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.