docs: manual test scenarios for the concurrent-write / editor bugs

Covers the four classes found this session (stale editor payload, editor
dismissed without the discard gate, missing withEncryptedMeta, view-blind
UI) with the surrounding cases, not just the ones reported.

Also fixes one more instance found while auditing: deleting a folder
reassigns its entries to All server-side, but an editor open on one of them
kept pointing at the deleted folder and re-created it on Save.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-22 23:51:12 +01:00
parent d146fbe796
commit 9ea26be422
2 changed files with 176 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
# 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).
### A11 · sync / import pendant que l'éditeur est ouvert — **trou connu**
- **ACTION** : `A` ouverte + dirty → lancer *Sync now* (ou importer un JSON en mode overwrite) qui met `A` à jour → Save
- **ATTENDU AUJOURD'HUI** : le Save de l'éditeur écrase la version venue du sync/import. **Non corrigé** — voir « Reste à traiter » plus bas.
---
## 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
- **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** : `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.
---
## 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 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).
---
## Reste à traiter (constaté pendant l'audit, non corrigé)
1. **Sync / import pendant l'édition** (scénario A11). `editorAbsorbChange`
ne couvre que les écritures locales tag/dossier. Un vrai filet serait de
mémoriser `updated_at` à l'ouverture de l'éditeur et, au Save, de comparer
avec la valeur courante en mémoire : si elle a bougé, demander
« cette entrée a changé ailleurs — écraser ? ». Ça couvre d'un coup sync,
import, et tout futur chemin d'écriture.
2. **Modale d'entrée héritée = code mort.** `openEntryModal` n'a **aucun
appelant** ; `saveEntry`, `closeEntryModal`, le markup `#entryModal` et
leurs listeners ne sont plus atteignables. Danger : `saveEntry` fait un PUT
**partiel** (ni `kind`, ni `totp_secret`, ni `custom_fields`, ni
`template`) — exactement le bug que `moveEntryToFolder` documente comme
à ne jamais refaire. À supprimer (JS + HTML) tant qu'il n'est pas recâblé
par erreur.
3. **Click-outside** : `#busyOverlay` et `.toast` ne sont pas dans la liste
blanche. Cliquer un toast ou l'overlay d'attente pendant une opération
longue ferme l'éditeur. Impact faible, correctif = 2 lignes dans la même
liste.
+5
View File
@@ -5526,6 +5526,11 @@ async function deleteFolder(name, entryCount) {
headers: authHeaders(), headers: authHeaders(),
}); });
if (state.view === 'folder:' + name) state.view = 'all'; if (state.view === 'folder:' + name) state.view = 'all';
// The server reassigned the folder's entries to All. An editor left
// open on one of them still points at the deleted folder and would
// re-create it on Save (its <option> is gone, so the select reads '').
if (soState && soState.original.folder === name)
editorAbsorbChange(soState.id, { folder: 'All' });
await loadFolders(); await loadFolders();
await loadEntries(); await loadEntries();
render(); render();