diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/__main__.py | 3 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/remove.py | 45 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/session.py | 9 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/commands/rm.py | 111 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/manpage.py | 25 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/tui/screens.py | 59 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/fake_node.py | 38 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/test_cli_actions.py | 32 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/test_e2e_local.py | 44 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/test_interactive.py | 49 |
10 files changed, 403 insertions, 12 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/__main__.py b/packages/meshbay-cli/src/meshbay_cli/__main__.py index 91e523a..a29b528 100644 --- a/packages/meshbay-cli/src/meshbay_cli/__main__.py +++ b/packages/meshbay-cli/src/meshbay_cli/__main__.py @@ -24,6 +24,7 @@ from .commands import ( logout, ls, put, + rm, trust, ) from .commands.context import Context @@ -31,7 +32,7 @@ from .errors import INTERRUPTED, USAGE, CliError ACTIONS = {"login": login, "logout": logout, "groups": groups, "invitations": invitations, "join": join, "decline": decline, "leave": leave, "ls": ls, "get": get, - "put": put, "devices": devices, "trust": trust, + "put": put, "rm": rm, "devices": devices, "trust": trust, "browser-access": browser_access} diff --git a/packages/meshbay-cli/src/meshbay_cli/client/remove.py b/packages/meshbay-cli/src/meshbay_cli/client/remove.py new file mode 100644 index 0000000..370cf95 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/client/remove.py @@ -0,0 +1,45 @@ +""" +Deleting a file from a group (docs/MESHBAY_DESIGN.md §5.4). + +The node decides, and on two grounds only: a key paired with it as operator, or +a live device of the account that uploaded the file — any of them, so a file +sent from a phone is deleted from the laptop. Being the group's owner on the hub +is neither. The request is answered with a challenge the client signs with its +key on the node; the node rebuilds what was signed itself. +""" + +from meshbay_common import MNP_VERSION +from meshbay_common.adminop import OP_FILE_DELETE, admin_transcript + +from ..errors import NODE, CliError +from .keys import b64, unb64 +from .session import GroupSession + + +def may_delete(session: GroupSession, entry: dict, user_id: str) -> bool: + """What the node will accept, known before asking: the operator, or the + file's uploader. The node checks again; this only avoids asking in vain.""" + return session.role == "operator" or entry.get("uploader_id") == user_id + + +async def delete_file(session: GroupSession, file_id: str) -> None: + """Delete one file. Raises `NodeRefusal` with the node's reason.""" + reply = await session.ch.request({"type": "file_delete", "v": MNP_VERSION, + "file_id": file_id}, + "admin_challenge", "file_delete_ack") + if reply["type"] == "file_delete_ack": + return + # What is signed is checked against what was asked, never taken from the + # node: a challenge for another operation, file, group or node is refused. + if (reply.get("op") != OP_FILE_DELETE or reply.get("subject") != file_id + or reply.get("group_id") != session.group_id + or reply.get("node_pk") != session.node_pk): + raise CliError(NODE, "The node asked to sign something other than this deletion; " + "nothing was signed.") + transcript = admin_transcript(op=OP_FILE_DELETE, node_pk_b64=session.node_pk, + group_id=session.group_id, subject=file_id, + nonce=unb64(reply["nonce"]), ts=int(reply["ts"])) + await session.ch.request({"type": "admin_response", "v": MNP_VERSION, + "op_id": reply["op_id"], "op": OP_FILE_DELETE, + "signature": b64(session.identity.sk_ed.sign(transcript))}, + "file_delete_ack") diff --git a/packages/meshbay-cli/src/meshbay_cli/client/session.py b/packages/meshbay-cli/src/meshbay_cli/client/session.py index d5264a2..41bf0d6 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/session.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/session.py @@ -197,7 +197,8 @@ class GroupSession: def __init__(self, pc: RTCPeerConnection, ch: Channel, group_id: str, node_id: str, node_pk: str, gek: bytes, ack: dict, - identity: Identity | None = None, nonce_node: bytes = b""): + identity: Identity | None = None, nonce_node: bytes = b"", + role: str = ""): self.pc = pc self.ch = ch self.group_id = group_id @@ -209,6 +210,10 @@ class GroupSession: # and the nonce that ties a signature to this connection. self.identity = identity self.nonce_node = nonce_node + # What the node's roster says this key is here: "operator" when it was + # paired with the node — the authority to delete anyone's file. Not the + # hub's word on who owns the group (docs/MESHBAY_DESIGN.md §3.3). + self.role = role async def index(self) -> dict: """`{version, entries, dirs, roots}`, opened under the group key.""" @@ -351,7 +356,7 @@ async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id: await _device_hello(account, ch, identity, node_pk, group_id, nonce_s) await _settle_bundle(account, ch, identity, node_pk, bundle) return GroupSession(pc, ch, group_id, node_id, node_pk, gek, config, - identity, nonce_s) + identity, nonce_s, str(joined.get("role") or "")) async def _fetch_bundle(ch: Channel) -> str: diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/rm.py b/packages/meshbay-cli/src/meshbay_cli/commands/rm.py new file mode 100644 index 0000000..6f5e869 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/rm.py @@ -0,0 +1,111 @@ +""" +`rm`: delete files from groups. Asked first; `--yes` answers for a script. + +Paths and patterns are resolved against each group's index, as `get` resolves +them, and the whole run is checked before anything is asked or sent: a file +this account may not delete — not uploaded by it, and this machine's key not +the node's operator — refuses the run (exit 8), as a folder does. The node +checks again on every file; one it refuses is reported and the others go on +(exit 4). +""" + +import json + +from ..client.groups import my_groups +from ..client.paths import handle, parse, resolve_group +from ..client.pool import Pool +from ..client.remove import delete_file, may_delete +from ..client.tree import Tree +from ..errors import NODE, NOT_FOUND, PARTIAL, REFUSED, USAGE, CliError +from ..output import confirm, err, safe, sentence +from .context import Context + +HELP = "delete files you uploaded (or any, from the node's operator key)" + +# How many names the confirmation shows before it counts the rest. +SHOWN = 10 + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("rm", parents=[parent], help=HELP, description=sentence(HELP) + + ". Wildcards (* ? [...]) are matched against the group, so " + "quote them. Being the group's owner is not enough: the node " + "lets its operator's key, and the uploader's devices, delete.") + p.add_argument("paths", nargs="+", metavar="PATH", help="group[@owner]/root/.../file") + p.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation") + + +async def run(ctx: Context) -> int: + await ctx.hub.check_version() + account = ctx.account() + pool = Pool(account) + try: + return await _run(ctx, account, pool) + finally: + await pool.close() + await account.close() + + +async def _run(ctx: Context, account, pool: Pool) -> int: + a = ctx.args + groups = await my_groups(account) + trees: dict[str, Tree] = {} + targets = [] # (session, entry, shown) + missing, folders, not_yours = [], [], [] + for text in a.paths: + remote = parse(text) + if remote is None or not remote.parts: + raise CliError(USAGE, "Name the files to delete, inside a group.") + group = resolve_group(groups, remote.group) + session = await pool.session(group) + if group["id"] not in trees: + trees[group["id"]] = Tree.from_index(await session.index()) + tree = trees[group["id"]] + found = tree.expand(remote.parts) + if not found and tree.file(remote.inner): + found = [remote.inner] # a literal name with brackets in it + if not found: + missing.append(text) + for path in found: + shown = f"{handle(group)}/{path}" + entry = tree.file(path) + if entry is None: + folders.append(shown) + elif not may_delete(session, entry, account.user_id): + not_yours.append(shown) + elif all(e["id"] != entry["id"] for _, e, _ in targets): + targets.append((session, entry, shown)) + if missing: + raise CliError(NOT_FOUND, "Nothing in your groups matches: " + + ", ".join(safe(m) for m in missing)) + if folders: + raise CliError(USAGE, "rm deletes files; these are folders: " + + ", ".join(safe(f) for f in folders)) + if not_yours: + raise CliError(REFUSED, "Not deleted — you did not upload these, and this machine's " + "key is not the node's operator:\n " + + "\n ".join(safe(n) for n in not_yours)) + + names = [shown for _, _, shown in targets] + listed = "\n ".join(safe(n) for n in names[:SHOWN]) + more = f"\n ... and {len(names) - SHOWN} more" if len(names) > SHOWN else "" + confirm(f"Delete {len(names)} file{'s' if len(names) != 1 else ''} for every member " + f"of the group?\n {listed}{more}\n", a.yes) + + deleted, failed = [], [] + for session, entry, shown in targets: + try: + await delete_file(session, entry["id"]) + deleted.append(shown) + except CliError as e: + if e.code not in (NODE, REFUSED): + raise + failed.append(f"{shown}: {e.message}") + if a.json: + print(json.dumps({"deleted": deleted, "failed": failed}, ensure_ascii=False)) + elif not a.quiet: + n = len(deleted) + err(f"Deleted {n} file{'s' if n != 1 else ''}.") + for f in failed: + err(f"meshbay-cli: {safe(f)}") + return PARTIAL if failed else 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/manpage.py b/packages/meshbay-cli/src/meshbay_cli/manpage.py index 7ee3d33..8316fc5 100644 --- a/packages/meshbay-cli/src/meshbay_cli/manpage.py +++ b/packages/meshbay-cli/src/meshbay_cli/manpage.py @@ -35,7 +35,8 @@ EXIT_CODES = { "for the hub or the node.", errors.REFUSED: "Refused before anything was sent: a name already taken or not " "accepted by the node, a folder read-only or unavailable, leaving a " - "group you own, revoking this machine.", + "group you own, revoking this machine, deleting a file you may not " + "delete.", errors.INTERRUPTED: "Interrupted (Ctrl+C). A download resumes where it stopped " "on the next run.", } @@ -43,8 +44,8 @@ EXIT_CODES = { DESCRIPTION = r""" .B meshbay-cli is a member's view of their MeshBay groups from a terminal: sign in, join and -leave groups, list them and their folders, download and upload files, and -approve this account's other devices. +leave groups, list them and their folders, download, upload and delete files, +and approve this account's other devices. It talks to the hub only to sign in and to find a group's nodes; files go directly between this machine and the node, end-to-end encrypted, never through the hub. @@ -119,6 +120,12 @@ select an entry (several can be) copy the selection, or the entry under the cursor, to the other pane's folder; a name already there is asked about once for the whole batch .TP +.B Delete +delete the selection, or the file under the cursor, from the group; files only, +each of them yours to delete (see +.BR rm ), +asked once for the batch +.TP .B / filter by name; .B Esc @@ -211,6 +218,15 @@ the code it shows, and revokes one. Exactly one waiting device must match a code: a code that matches several, which somebody who saw it on your screen could cause, approves none. .PP +.B rm +deletes files for every member of the group. +The node allows it to the account that uploaded a file, from any of its +devices, and to the node's operator; being the group's owner on the hub counts +only when the owner's account is the operator of the node serving the group, +as it is when the owner hosts it with the desktop application. +A run that names a file this account may not delete, or a folder, is refused +before anything is asked or sent. +.PP .B browser\-access on makes this machine leave, on each node it opens a group on, a copy of its keys sealed under your passphrase, so that a browser can open the group; @@ -282,6 +298,7 @@ meshbay\-cli get 'Family/music/*.flac' meshbay\-cli put \-R ~/scans Family/documents tar c notes | meshbay\-cli put \- Family/documents/notes.tar meshbay\-cli ls \-\-json \-R / | jq '.[] | .path' +meshbay\-cli rm 'Family/documents/draft*.odt' meshbay\-cli devices approve Family@bob ZX4P\-7QRT meshbay\-cli leave \-\-yes Club@carol .EE @@ -381,7 +398,7 @@ def render() -> str: out += [".SH REMOTE PATHS", PATHS.strip(), ".SH INTERACTIVE MODE", INTERACTIVE.strip(), ".SH SESSION, DEVICES AND NODE KEYS", SESSION.strip(), - ".SH GROUPS: JOINING, LEAVING, DEVICES", MEMBERSHIP.strip(), + ".SH GROUPS: JOINING, LEAVING, DELETING, DEVICES", MEMBERSHIP.strip(), ".SH EXIT STATUS"] for code, text in sorted(EXIT_CODES.items()): out += [".TP", f".B {code}", text] diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/screens.py b/packages/meshbay-cli/src/meshbay_cli/tui/screens.py index e1ab620..0306325 100644 --- a/packages/meshbay-cli/src/meshbay_cli/tui/screens.py +++ b/packages/meshbay-cli/src/meshbay_cli/tui/screens.py @@ -6,6 +6,10 @@ with the rules `get` and `put` follow: an upload is decided whole before it starts (names the node refuses stop it, names already there are asked about once for the batch), nothing on either side is replaced without being asked, and a name from a group is made writable here before it is written. + +Delete removes the selection from a group with the rules `rm` follows: files +only, every one of them this account's to delete (uploaded by it, or this key +the node's operator) or none is, then asked once for the batch. """ import asyncio @@ -24,6 +28,7 @@ from ..client.account import Account from ..client.names import UnsafeName, local_path from ..client.paths import handle from ..client.pool import Pool +from ..client.remove import delete_file, may_delete from ..client.store import FILE_KEY_NOTICE, KEY_FILE from ..client.transfers import PART_SUFFIX, TransferError, create_dir, download, upload from ..client.tree import Tree @@ -95,6 +100,7 @@ class SignInScreen(Screen): class MainScreen(Screen): BINDINGS = [ Binding("f5", "copy", "Copy"), + Binding("delete", "delete", "Delete"), Binding("q", "app.quit", "Quit"), Binding("tab", "app.focus_next", "Switch", show=False), ] @@ -331,6 +337,59 @@ class MainScreen(Screen): except TransferError as e: self.strip.update_job(job, state="failed", note=e.message) + # ── Delete ─────────────────────────────────────────────────────────────── + + def action_delete(self) -> None: + focused = self.app.focused + if focused is None or self.remote not in focused.ancestors_with_self: + self.app.notify("Delete removes files from a group: select them on the " + "right.", timeout=4) + return + self.run_worker(self._delete(), group="delete") + + async def _delete(self) -> None: + remote = self.remote + rows = remote.selection() + if remote.group is None or not rows: + self.app.notify("Open a group and choose the files to delete.", timeout=4) + return + group = remote.group + try: + session = await self.pool.session(group) + except CliError as e: + self.app.notify(e.message, severity="error", markup=False) + return + folders = [r.name for r in rows if r.is_dir] + not_yours = [r.name for r in rows if not r.is_dir + and not may_delete(session, r.data["entry"], self.account.user_id)] + if folders or not_yours: + why = (["Folders are not deleted here: " + ", ".join(map(safe, folders))] + if folders else []) + if not_yours: + why.append("You did not upload these, and this machine's key is not the " + "node's operator: " + ", ".join(map(safe, not_yours))) + await self._ask("Nothing was deleted.\n" + "\n".join(why), [("ok", "OK")]) + return + names = ", ".join(safe(r.name) for r in rows[:5]) + ( + f" and {len(rows) - 5} more" if len(rows) > 5 else "") + answer = await self._ask( + f"Delete {len(rows)} file(s) from {safe(handle(group))} for every member? " + f"{names}", [("delete", "Delete"), ("cancel", "Cancel")]) + if answer != "delete": + return + remote.marks.clear() + deleted = 0 + for row in rows: + try: + await delete_file(session, row.data["entry"]["id"]) + deleted += 1 + except CliError as e: + self.app.notify(f"{safe(row.name)}: {e.message}", severity="error", + markup=False, timeout=8) + if deleted: + self.app.notify(f"Deleted {deleted} file(s).", timeout=4) + self._changed(group) + async def on_unmount(self) -> None: with contextlib.suppress(Exception): await self.pool.close() diff --git a/packages/meshbay-cli/tests/fake_node.py b/packages/meshbay-cli/tests/fake_node.py index f53eb01..46ad68e 100644 --- a/packages/meshbay-cli/tests/fake_node.py +++ b/packages/meshbay-cli/tests/fake_node.py @@ -22,6 +22,7 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from fake_hub import USER_ID from meshbay_cli.client.keys import Identity from meshbay_cli.client.session import GroupSession, NodeRefusal +from meshbay_common.adminop import OP_FILE_DELETE, admin_transcript from meshbay_common.device import ( device_add_transcript, device_code_hash, @@ -57,11 +58,14 @@ class FakeNode: "label": "", "pinned_at": "2026-10-01T10:00:00+00:00", "pinned_via": "code"}] self.requests: list[dict] = [] + self.role = "" # "operator": this key was paired here + self.admin_ops: dict[str, dict] = {} - def add(self, path: str, name: str, data: bytes) -> dict: + def add(self, path: str, name: str, data: bytes, uploader_id: str = "") -> dict: fid = hashlib.blake2b(data + path.encode() + name.encode(), digest_size=32).hexdigest() entry = {"id": fid, "name": name, "path": path, "size": len(data), - "type": "document", "added_at": 1_700_000_000, "data": data} + "type": "document", "added_at": 1_700_000_000, "data": data, + "uploader_id": uploader_id} self.files[fid] = entry parts = path.split("/") for i in range(1, len(parts) + 1): @@ -70,7 +74,7 @@ class FakeNode: def session(self) -> GroupSession: return GroupSession(None, FakeChannel(self), GROUP_ID, "node-1", NODE_PK, self.gek, - {}, self.identity, self.nonce) + {}, self.identity, self.nonce, self.role) def request_device(self, code: str) -> Identity: """Another machine files a request with `code`, as it would before its proof.""" @@ -194,6 +198,34 @@ class FakeChannel: dir=folder) + # ── Deleting: a challenge, signed with this machine's key ────────────── + + def _file_delete(self, msg): + n = self.node + if msg["file_id"] not in n.files: + return {"type": "error", "detail": "File not found"} + op_id, nonce = secrets.token_hex(8), secrets.token_bytes(32) + n.admin_ops[op_id] = {"subject": msg["file_id"], "nonce": nonce, "ts": 1_700_000_000} + return {"type": "admin_challenge", "op_id": op_id, "op": OP_FILE_DELETE, + "subject": msg["file_id"], "nonce": base64.b64encode(nonce).decode(), + "ts": 1_700_000_000, "node_pk": NODE_PK, "group_id": GROUP_ID} + + def _admin_response(self, msg): + n = self.node + op = n.admin_ops.pop(msg["op_id"], None) + if op is None: + return {"type": "error", "detail": "No pending admin operation"} + entry = n.files.get(op["subject"]) + transcript = admin_transcript(op=OP_FILE_DELETE, node_pk_b64=NODE_PK, + group_id=GROUP_ID, subject=op["subject"], + nonce=op["nonce"], ts=op["ts"]) + signed = _signed_by_a_device(n, transcript, msg["signature"]) + allowed = n.role == "operator" or (entry and entry["uploader_id"] == USER_ID) + if not (signed and allowed): + return {"type": "error", "detail": "Signature verification failed"} + del n.files[op["subject"]] + return {"type": "file_delete_ack", "file_id": op["subject"]} + # ── Devices ────────────────────────────────────────────────────────────── def _device_list(self, msg): diff --git a/packages/meshbay-cli/tests/test_cli_actions.py b/packages/meshbay-cli/tests/test_cli_actions.py index 4fffc4e..e0b5518 100644 --- a/packages/meshbay-cli/tests/test_cli_actions.py +++ b/packages/meshbay-cli/tests/test_cli_actions.py @@ -425,6 +425,38 @@ def test_browser_access_is_set_here_and_mirrored_never_taken_from_the_hub( assert store.state["browser_access"] == "off" +def test_rm_deletes_only_what_this_account_may_and_asks_first(node, monkeypatch, capsys): + from fake_hub import USER_ID + mine = node.add("share", "mine.txt", b"1", uploader_id=USER_ID) + node.add("share", "also-mine.txt", b"2", uploader_id=USER_ID) + theirs = node.add("share", "theirs.txt", b"3", uploader_id="someone-else") + node.dirs.add("share/sub") + + # Somebody else's file, a folder: refused whole, before any question. + assert main("rm", "--yes", "Family/share/mine.txt", "Family/share/theirs.txt") == 8 + assert "Family@alice-test/share/theirs.txt" in capsys.readouterr().err + assert main("rm", "--yes", "Family/share/sub") == 1 + assert main("rm", "--yes", "Family/share/nothing*") == 3 + assert mine["id"] in node.files + + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + assert main("rm", "Family/share/mine.txt") == 1 # not confirmed + monkeypatch.setattr("sys.stdin", _Terminal("n\n")) + assert main("rm", "Family/share/mine.txt") == 1 + assert mine["id"] in node.files + monkeypatch.setattr("sys.stdin", _Terminal("y\n")) + assert main("rm", "Family/share/*mine.txt") == 0 + assert "Deleted 2 files" in capsys.readouterr().err + assert set(node.files) == {theirs["id"]} + + # From the node's operator key, anyone's file. + node.role = "operator" + assert main("--json", "rm", "--yes", "Family/share/theirs.txt") == 0 + assert json.loads(capsys.readouterr().out)["deleted"] == [ + "Family@alice-test/share/theirs.txt"] + assert not node.files + + def test_every_action_is_run_here(): """Runs last in this module: every action the parser knows was run above.""" assert RUN == set(cli.ACTIONS), f"never run: {set(cli.ACTIONS) - RUN}" diff --git a/packages/meshbay-cli/tests/test_e2e_local.py b/packages/meshbay-cli/tests/test_e2e_local.py index 9cac392..988fb38 100644 --- a/packages/meshbay-cli/tests/test_e2e_local.py +++ b/packages/meshbay-cli/tests/test_e2e_local.py @@ -529,12 +529,14 @@ def test_a_machine_with_no_identity_there_is_linked_as_a_device(world): async def test_the_interactive_mode_against_the_real_node(world, monkeypatch, tmp_path): - """The panes, opened on a real group, and F5 bringing a file back.""" + """The panes, opened on a real group: F5 bringing a file back, F5 sending one, + and Delete taking that one away again.""" from meshbay_cli import __main__ as main_module from meshbay_cli.commands.context import Context from meshbay_cli.tui.app import MeshBayApp - from meshbay_cli.tui.panes import RemotePane + from meshbay_cli.tui.panes import LocalPane, RemotePane from meshbay_cli.tui.screens import MainScreen + from meshbay_cli.tui.widgets import Choice # Browser access off, whichever test ran before: this machine must be linked. await world["app"].withdraw_bundle() @@ -585,6 +587,26 @@ async def test_the_interactive_mode_against_the_real_node(world, monkeypatch, tm await until(pilot, lambda: (tmp_path / "notes.txt").exists() and (tmp_path / "notes.txt").read_text() == "hello\n") + # Sent from the left, then deleted from the right: it was this + # account's upload, so the node lets it go, from its disk too. + (tmp_path / "tui-upload.txt").write_text("sent from the panes\n") + local = app.screen.query_one(LocalPane) + local.action_refresh() + await until(pilot, lambda: "tui-upload.txt" in names(local)) + local.table.move_cursor(row=names(local).index("tui-upload.txt")) + local.table.focus() + await pilot.press("f5") + await until(pilot, lambda: "tui-upload.txt" in names(remote)) + assert (world["share"] / "tui-upload.txt").exists() + remote.table.move_cursor(row=names(remote).index("tui-upload.txt")) + remote.table.focus() + await pilot.press("delete") + await until(pilot, lambda: isinstance(app.screen, Choice)) + await pilot.click("#choice-delete") + await until(pilot, lambda: "tui-upload.txt" not in names(remote)) + assert not (world["share"] / "tui-upload.txt").exists() + assert (world["share"] / "notes.txt").exists() + # ── The application's half of linking, in a real Chrome ────────────────────── # @@ -833,6 +855,24 @@ def test_a_member_joins_links_devices_shares_with_browsers_and_leaves(world): assert r.returncode == 0, r.stderr assert bob(world, "bob3", "ls", group).returncode == 5 # a stranger again + # rm: what bob uploaded, from another of his machines too; nothing of the + # owner's, whose share bob did not upload. + up = world["root"] / "bob-upload.txt" + up.write_text("from bob\n") + assert bob(world, "bob1", "put", str(up), f"{group}/share").returncode == 0 + deadline = time.monotonic() + 30 # indexed once the node has written it + while "bob-upload.txt" not in bob(world, "bob1", "ls", f"{group}/share").stdout: + assert time.monotonic() < deadline, "the upload never reached the index" + time.sleep(1) + r = bob(world, "bob1", "rm", f"{group}/share/bob-upload.txt") + assert r.returncode == 1 and "--yes" in r.stderr # no terminal to ask on + r = bob(world, "bob1", "rm", "--yes", f"{group}/share/notes.txt") + assert r.returncode == 8 and "did not upload" in r.stderr, r.stderr + r = bob(world, "bob2", "rm", "--yes", f"{group}/share/bob-upload.txt") + assert r.returncode == 0, r.stderr + assert "bob-upload.txt" not in bob(world, "bob1", "ls", f"{group}/share").stdout + assert (world["share"] / "notes.txt").exists() + # Leaving: asked first, then the group is gone from the account. assert bob(world, "bob1", "leave", group).returncode == 1 r = bob(world, "bob1", "leave", "--yes", group) diff --git a/packages/meshbay-cli/tests/test_interactive.py b/packages/meshbay-cli/tests/test_interactive.py index 680b9e0..49180d5 100644 --- a/packages/meshbay-cli/tests/test_interactive.py +++ b/packages/meshbay-cli/tests/test_interactive.py @@ -157,3 +157,52 @@ async def test_a_read_only_folder_takes_no_upload(world): await pilot.pause(0.3) assert node.stored("share/mine.txt") is None assert any("does not accept uploads" in n.message for n in app._notifications) + + +async def test_delete_removes_what_is_yours_and_asks_first(world): + from fake_hub import USER_ID + hub, node, work = world + mine = node.add("share", "mine.txt", b"1", uploader_id=USER_ID) + theirs = node.add("share", "theirs.txt", b"2", uploader_id="someone-else") + assert await asyncio.to_thread(cli.main, ["--hub", "https://hub.test", "-u", USERNAME, + "--password", PASSPHRASE, "login"]) == 0 + app = _app() + async with app.run_test(size=(120, 30)) as pilot: + await _until(pilot, lambda: isinstance(app.screen, MainScreen)) + remote = app.screen.query_one(RemotePane) + await _open(pilot, remote, f"Family@{USERNAME}/") + await _open(pilot, remote, "share/") + await _until(pilot, lambda: "theirs.txt" in _names(remote)) + + # Somebody else's file, marked with one's own: nothing is deleted. + for name in ("mine.txt", "theirs.txt"): + remote.table.move_cursor(row=_names(remote).index(name)) + await pilot.press("space") + await pilot.press("delete") + await _until(pilot, lambda: isinstance(app.screen, Choice)) + assert "theirs.txt" in str(app.screen.message) + await pilot.click("#choice-ok") + assert mine["id"] in node.files and theirs["id"] in node.files + + # One's own: asked, cancelled, asked again, deleted, and gone from the pane. + await _until(pilot, lambda: isinstance(app.screen, MainScreen)) + remote.marks.clear() + remote.redraw() + remote.table.move_cursor(row=_names(remote).index("mine.txt")) + await pilot.press("delete") + await _until(pilot, lambda: isinstance(app.screen, Choice)) + await pilot.click("#choice-cancel") + assert mine["id"] in node.files + await _until(pilot, lambda: isinstance(app.screen, MainScreen)) + await pilot.press("delete") + await _until(pilot, lambda: isinstance(app.screen, Choice)) + await pilot.click("#choice-delete") + await _until(pilot, lambda: mine["id"] not in node.files) + await _until(pilot, lambda: "mine.txt" not in _names(remote)) + assert theirs["id"] in node.files + + # On the left, Delete removes nothing of this machine's. + app.screen.query_one(LocalPane).table.focus() + await pilot.press("delete") + await pilot.pause(0.2) + assert any("select them on the right" in n.message for n in app._notifications) |