diff --git a/TEST_SCENARIOS.md b/TEST_SCENARIOS.md index d3b35aa..7ba0a8d 100644 --- a/TEST_SCENARIOS.md +++ b/TEST_SCENARIOS.md @@ -233,6 +233,14 @@ plus bas. - **ACTION** : importer un CSV sans colonnes favorite/pinned. - **ATTENDU** : entrées importées ni épinglées ni favorites (défaut 0), pas d'erreur. +### F8 · mettre à la corbeille puis synchroniser ne duplique pas ✅ +- **ACTION** : synchroniser une première fois (l'instantané distant contient les entrées) → mettre **plusieurs** entrées à la corbeille → **Sync now**. +- **ATTENDU** : les entrées restent en corbeille, et **rien n'est recréé** dans la liste vivante. Le résumé ne doit pas annoncer `pulled N new`. +- **Le bug** : 30 entrées mises à la corbeille puis un sync → 30 **doublons vivants** à côté des originaux en corbeille, soit deux lignes partageant un même `uuid`. +- **Cause** : la corbeille est une suppression **douce**, sans tombstone (seul le hard-delete en écrit un), et les entrées supprimées sont absentes de `state.entries`. Le merge voyait donc leurs uuid comme « absents en local » et les repostait comme des entrées neuves. +- **Correctif** : `applyRemoteSnapshot` charge aussi la corbeille et saute tout uuid qui s'y trouve. La décision locale de mettre à la corbeille l'emporte ; c'est le **vidage** de la corbeille qui écrit le tombstone et propage la suppression. +- **Nettoyage si le bug a déjà frappé** : les doublons vivants sont à supprimer à la main. Vérifier aussi l'autre appareil, où ils ont pu être poussés. + ### 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). @@ -366,7 +374,7 @@ ou le déchiffrement. - **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 ` 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. +- **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. **python -m wsgidav.server.server_cli -c "e:\webdav.json"** ### E5 · la clé du coffre n'est plus en clair dans le web storage ✅ - **ACTION** : coffre déverrouillé → console WebView2 : diff --git a/js/app.sync.js b/js/app.sync.js index 43fa426..4f1f315 100644 --- a/js/app.sync.js +++ b/js/app.sync.js @@ -349,6 +349,17 @@ async function applyRemoteSnapshot(remote) { const byUuid = new Map(); for (const e of state.entries) if (e.uuid) byUuid.set(e.uuid, e); + // Entries sitting in the TRASH are soft-deleted: no tombstone is written + // (that only happens on a hard delete), and they are absent from + // state.entries. Without this set the merge sees their uuids as "missing + // locally" and POSTs them back as fresh live rows — moving 30 entries to + // the trash then syncing produced 30 duplicates, live copies alongside the + // trashed originals, two rows sharing one uuid. The local decision to trash + // wins; emptying the trash writes the tombstone that propagates the delete. + await loadTrash(); + const trashedUuids = new Set(); + for (const e of (state.trashed || [])) if (e.uuid) trashedUuids.add(e.uuid); + // Local tombstones must veto ANY remote entry with a matching uuid — // otherwise a perm-delete on this device gets undone by the next // pull ("48 added" after wiping the vault, because the remote @@ -401,6 +412,9 @@ async function applyRemoteSnapshot(remote) { // Skip entries this device has already tombstoned — never // resurrect a deleted entry. if (localTombstones.has(r.uuid)) continue; + // Same for the trash: the entry exists locally, just not in the live + // list. Re-creating it would duplicate it, not restore it. + if (trashedUuids.has(r.uuid)) continue; const local = byUuid.get(r.uuid); if (!local) { // New entry on remote — encrypt locally + POST keeping the uuid.