From 758b1e0d6ea70ab2e86eb1f1200c04a8f5b6a7d5 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 11 Sep 2026 03:31:27 +0200 Subject: [PATCH] feat(msg-bus): notify the deleted folder himself cas where a client is browsing a folder being deleted --- docs/plan/message-bus.md | 52 +++++++++++++++++-- .../src/routes/files/[...path]/+page.svelte | 22 +++++++- src/application/services/trash_service.rs | 34 ++++++++---- 3 files changed, 94 insertions(+), 14 deletions(-) diff --git a/docs/plan/message-bus.md b/docs/plan/message-bus.md index c28c96b7..04a361da 100644 --- a/docs/plan/message-bus.md +++ b/docs/plan/message-bus.md @@ -992,11 +992,55 @@ Output JSON schema (for post-mortem assertions in shell): } ``` -### Coverage — four scenarios, each in the same test file +### Coverage — eleven scenarios, all green -Orchestrated by a single `tests/api/rt_bus_check.sh` invoked from -`tests/api/run.sh` after the main hurl block. Follows the -`refcount_cascade` / `thumb_import_check` patterns already in place. +The four MVP scenarios sketched below expanded to **S1–S11** as +Slice C, D, and F shipped. All orchestrated by +`tests/api/rt_bus_check.sh` invoked from `tests/api/run.sh` after the +main hurl block. Follows the `refcount_cascade` / +`thumb_import_check` patterns already in place. + +Scenarios live today: + +- **S1** — Positive delivery: subscribe A, upload into A, one + `file_created`. +- **S2** — Topic isolation: subscribe A, upload into B then A; + observe A's event only. +- **S3** — AuthZ denial: user2 subscribes to A without a grant → + `no_read`. +- **S4** — Anti-enumeration parity: subscribe to a nonexistent + folder returns the SAME `no_read` as S3. +- **S5** — Server keepalive: 3 s idle surfaces multiple RFC 6455 + Pings; session still delivers afterwards. +- **S6** — `file_deleted`: DELETE fires the publish hook. +- **S7** — Move fan-out: subscribe A+B, MOVE A→B, observe two + `file_moved` (one per topic). +- **S8** — Grant-revoke eviction (Slice C): user2 subscribes to + A+B (both granted); user1 revokes only A → `rt.revoked` for A, + upload to B still delivers. Session survives. +- **S9** — Cross-user identity gate: user1 subscribes to + `user:{user2_id}:authz` → `topic_forbidden` (identity mismatch; + audit reason `identity_mismatch`; wire response indistinguishable + from unknown topic per anti-enum). +- **S10** — Ticket happy path (Slice F): `POST /api/rt/ticket`, + open WS with `oxi.ticket.` subprotocol, subscribe + + deliver. +- **S11** — Ticket single-use (Slice F): reusing a redeemed + ticket fails the upgrade with 401 + audit + `message_bus.upgrade_rejected reason=ticket_invalid`. + +**Ready-file race fix**: the shell script uses a `wait_ready` +function that blocks on the helper's `--ready-file` (touched the +instant every requested subscribe is ack'd) instead of a +`sleep 0.4` heuristic that flaked on cold-cache runs. See +`rt-hurl-helper::Args::ready_file` and the wait_ready doc in the +shell script. + +**Always rebuild the helper** — the guard `[[ ! -x $HELPER_BIN ]]` +was removed 2026-09-11 because it silently reused stale binaries +whenever the helper's source changed without touching the caller +shell. Cargo incremental short-circuits in ~50 ms; the cost is +negligible, the trap-free experience is worth it. **Scenario 1 — Positive delivery** (fan-out works) diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index b7e11afa..c68e1750 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -476,7 +476,27 @@ onFolderCreated: (d) => scheduleLiveReload(d.actor), onFolderRenamed: (d) => scheduleLiveReload(d.actor), onFolderMoved: (d) => scheduleLiveReload(d.actor), - onFolderDeleted: (d) => scheduleLiveReload(d.actor), + onFolderDeleted: (d) => { + // Two cases fanned out from the server-side publish: + // * `d.folder_id !== currentId` — a SUBFOLDER of the + // current view was deleted. Refetch the listing so + // the row disappears (existing behavior). + // * `d.folder_id === currentId` — the VIEWED folder + // itself just got trashed. The FolderService trashes + // the subtree (soft-delete cascade); staying here + // would show a zombie view. Toast + navigate to + // `/files`, same UX as `onRevoked` for grant + // eviction. See `TrashService::move_to_trash` and + // `docs/plan/message-bus.md § Status` for the + // dual-topic publish rationale. + if (d.folder_id === currentId) { + ui.notify(t('files.folder_was_deleted', 'This folder was moved to trash.'), 'warning'); + busLog.warn('viewed folder was deleted', { folder_id: d.folder_id }); + void goto(resolve('/files')); + return; + } + scheduleLiveReload(d.actor); + }, onRevoked: (params) => { // The subscription is already gone server-side. Notify the // user and send them back to their home so they don't sit diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 576c71b9..0a62cb51 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -304,17 +304,33 @@ impl TrashUseCase for TrashService { // doesn't panic here. if let (Some(bus), Some(parent_uuid)) = (&self.bus, parent_snapshot) { debug!( - "publishing FolderDeleted folder={} parent={} actor={}", + "publishing FolderDeleted folder={} parent={} actor={} (2 topics)", folder_id, parent_uuid, user_id ); - bus.publish( - &Topic::Folder(parent_uuid), - MessageBusEvent::FolderDeleted { - folder_id, - parent_id: parent_uuid, - actor: user_id, - }, - ); + let event = MessageBusEvent::FolderDeleted { + folder_id, + parent_id: parent_uuid, + actor: user_id, + }; + // Publish on BOTH the parent's topic AND the deleted + // folder's own topic: + // + // * Parent topic — viewers of the parent see the + // child disappear from their listing (existing + // behavior, verified by tests). + // * Deleted-folder topic — viewers INSIDE the + // folder that just got trashed are stranded on a + // folder that no longer exists. Delivering the + // same `folder_deleted` event on this topic lets + // the FE `onFolderDeleted` handler detect + // `data.folder_id === currentId` and navigate + // away with a toast (same UX as `onRevoked` + // surfaces for grant-revocation eviction). + // Otherwise the sub would silently stop + // receiving events and the tab would sit on a + // zombie view. + bus.publish(&Topic::Folder(parent_uuid), event.clone()); + bus.publish(&Topic::Folder(folder_id), event); } else { debug!( "trash-folder publish skipped: bus={} parent={:?}",