feat(msg-bus): notify the deleted folder himself
cas where a client is browsing a folder being deleted
This commit is contained in:
@@ -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
|
The four MVP scenarios sketched below expanded to **S1–S11** as
|
||||||
`tests/api/run.sh` after the main hurl block. Follows the
|
Slice C, D, and F shipped. All orchestrated by
|
||||||
`refcount_cascade` / `thumb_import_check` patterns already in place.
|
`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.<uuid>` 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)
|
**Scenario 1 — Positive delivery** (fan-out works)
|
||||||
|
|
||||||
|
|||||||
@@ -476,7 +476,27 @@
|
|||||||
onFolderCreated: (d) => scheduleLiveReload(d.actor),
|
onFolderCreated: (d) => scheduleLiveReload(d.actor),
|
||||||
onFolderRenamed: (d) => scheduleLiveReload(d.actor),
|
onFolderRenamed: (d) => scheduleLiveReload(d.actor),
|
||||||
onFolderMoved: (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) => {
|
onRevoked: (params) => {
|
||||||
// The subscription is already gone server-side. Notify the
|
// The subscription is already gone server-side. Notify the
|
||||||
// user and send them back to their home so they don't sit
|
// user and send them back to their home so they don't sit
|
||||||
|
|||||||
@@ -304,17 +304,33 @@ impl TrashUseCase for TrashService {
|
|||||||
// doesn't panic here.
|
// doesn't panic here.
|
||||||
if let (Some(bus), Some(parent_uuid)) = (&self.bus, parent_snapshot) {
|
if let (Some(bus), Some(parent_uuid)) = (&self.bus, parent_snapshot) {
|
||||||
debug!(
|
debug!(
|
||||||
"publishing FolderDeleted folder={} parent={} actor={}",
|
"publishing FolderDeleted folder={} parent={} actor={} (2 topics)",
|
||||||
folder_id, parent_uuid, user_id
|
folder_id, parent_uuid, user_id
|
||||||
);
|
);
|
||||||
bus.publish(
|
let event = MessageBusEvent::FolderDeleted {
|
||||||
&Topic::Folder(parent_uuid),
|
|
||||||
MessageBusEvent::FolderDeleted {
|
|
||||||
folder_id,
|
folder_id,
|
||||||
parent_id: parent_uuid,
|
parent_id: parent_uuid,
|
||||||
actor: user_id,
|
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 {
|
} else {
|
||||||
debug!(
|
debug!(
|
||||||
"trash-folder publish skipped: bus={} parent={:?}",
|
"trash-folder publish skipped: bus={} parent={:?}",
|
||||||
|
|||||||
Reference in New Issue
Block a user