aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests')
-rw-r--r--packages/meshbay-node/tests/golden/cli.json4
-rw-r--r--packages/meshbay-node/tests/golden/dispatch.json842
-rw-r--r--packages/meshbay-node/tests/test_root_availability.py2
-rw-r--r--packages/meshbay-node/tests/test_root_paths_are_operator_only.py2
-rw-r--r--packages/meshbay-node/tests/test_root_pause.py313
-rw-r--r--packages/meshbay-node/tests/test_roots.py13
6 files changed, 1126 insertions, 50 deletions
diff --git a/packages/meshbay-node/tests/golden/cli.json b/packages/meshbay-node/tests/golden/cli.json
index 48d2f74..d069510 100644
--- a/packages/meshbay-node/tests/golden/cli.json
+++ b/packages/meshbay-node/tests/golden/cli.json
@@ -4,7 +4,7 @@
"asked": [],
"exit": 0,
"stderr": "",
- "stdout": "usage: meshbay-node [-h] [--hub-url HUB_URL] [--username USERNAME] [--dir DIR] [--yes]\n [--config CONFIG] [--group GROUP] [--link] [--writable] [--no-writable]\n [--removable] [--no-removable] [--open] [--name NAME]\n [--log-level {DEBUG,INFO,WARNING,ERROR}]\n [{init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}]\n [subcommand] [target] [value]\n\nMeshBay Node daemon\n\npositional arguments:\n {init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}\n init: provision config + keystore | reset: erase all node state | status:\n node state and keys | operator pair: pair a browser with this node |\n member list|invite|cancel|revoke|unpin | group list|add|remove | root\n list|add|remove|set|eject|plug | gek init|rotate | file list|rm | video\n rematch: re-resolve TMDB matches for a group's videos | chat\n status|rotate|encrypt-history|prune | denylist show|clear | stun\n list|add|remove|reset | transfers show|set|max-size|per-member: live\n transfer slots, the node-wide caps, the largest single upload, and how\n many one member may run at once in a group | reload: re-read node.toml\n (hot; systemd or the loopback API) | restart-daemon: restart the node\n (systemd unit, the Windows autostart launcher, or the service task,\n whichever applies) | autostart install|remove|start|stop|status (Windows:\n run meshbay-node at each sign-in, no admin) | service\n install|remove|start|stop|status (Windows: run at boot, before sign-in,\n needs admin once to install) | calibrate-argon2: benchmark\n subcommand 'pair' for operator; list|invite|revoke|unpin for member; list|add|remove\n for group; list|add|remove|set|eject|plug for root; init|rotate for gek;\n list|rm for file; rematch for video; show|clear for denylist;\n list|add|remove|reset for stun; show|set|max-size|per-member for\n transfers; install|remove|start|stop|status for autostart and for service\n target username for member invite|revoke|unpin (an optional e-mail label with\n --link, a link id for member cancel); group name for group add; file id\n for file rm; identifier for denylist clear; download cap for transfers\n set; size in GB for transfers max-size\n value the second value where a verb takes two: the upload cap for transfers set\n\noptions:\n -h, --help show this help message and exit\n --hub-url HUB_URL hub URL, for init (e.g. https://meshbay.org)\n --username USERNAME hub username, for init\n --dir DIR shared directory, for group add\n --yes skip the confirmation for destructive commands\n --config CONFIG Config file path\n --group GROUP group id (optional if only one is configured)\n --link member invite: an invitation link, for someone who may have no account yet\n (valid 7 days, single use)\n --writable root accepts member uploads (root add/set)\n --no-writable root is read-only (root add/set, group add)\n --removable mark root as removable (root set/add)\n --no-removable mark root as not removable (root set)\n --open group add: anyone the hub lists the group to may join (default: by\n invitation only)\n --name NAME root name (root add; defaults to directory basename)\n --log-level {DEBUG,INFO,WARNING,ERROR}\n",
+ "stdout": "usage: meshbay-node [-h] [--hub-url HUB_URL] [--username USERNAME] [--dir DIR] [--yes]\n [--config CONFIG] [--group GROUP] [--link] [--writable] [--no-writable]\n [--removable] [--no-removable] [--open] [--name NAME]\n [--log-level {DEBUG,INFO,WARNING,ERROR}]\n [{init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}]\n [subcommand] [target] [value]\n\nMeshBay Node daemon\n\npositional arguments:\n {init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}\n init: provision config + keystore | reset: erase all node state | status:\n node state and keys | operator pair: pair a browser with this node |\n member list|invite|cancel|revoke|unpin | group list|add|remove | root\n list|add|remove|set|eject|plug|pause|resume | gek init|rotate | file\n list|rm | video rematch: re-resolve TMDB matches for a group's videos |\n chat status|rotate|encrypt-history|prune | denylist show|clear | stun\n list|add|remove|reset | transfers show|set|max-size|per-member: live\n transfer slots, the node-wide caps, the largest single upload, and how\n many one member may run at once in a group | reload: re-read node.toml\n (hot; systemd or the loopback API) | restart-daemon: restart the node\n (systemd unit, the Windows autostart launcher, or the service task,\n whichever applies) | autostart install|remove|start|stop|status (Windows:\n run meshbay-node at each sign-in, no admin) | service\n install|remove|start|stop|status (Windows: run at boot, before sign-in,\n needs admin once to install) | calibrate-argon2: benchmark\n subcommand 'pair' for operator; list|invite|revoke|unpin for member; list|add|remove\n for group; list|add|remove|set|eject|plug|pause|resume for root;\n init|rotate for gek; list|rm for file; rematch for video; show|clear for\n denylist; list|add|remove|reset for stun; show|set|max-size|per-member for\n transfers; install|remove|start|stop|status for autostart and for service\n target username for member invite|revoke|unpin (an optional e-mail label with\n --link, a link id for member cancel); group name for group add; file id\n for file rm; identifier for denylist clear; download cap for transfers\n set; size in GB for transfers max-size\n value the second value where a verb takes two: the upload cap for transfers set\n\noptions:\n -h, --help show this help message and exit\n --hub-url HUB_URL hub URL, for init (e.g. https://meshbay.org)\n --username USERNAME hub username, for init\n --dir DIR shared directory, for group add\n --yes skip the confirmation for destructive commands\n --config CONFIG Config file path\n --group GROUP group id (optional if only one is configured)\n --link member invite: an invitation link, for someone who may have no account yet\n (valid 7 days, single use)\n --writable root accepts member uploads (root add/set)\n --no-writable root is read-only (root add/set, group add)\n --removable mark root as removable (root set/add)\n --no-removable mark root as not removable (root set)\n --open group add: anyone the hub lists the group to may join (default: by\n invitation only)\n --name NAME root name (root add; defaults to directory basename)\n --log-level {DEBUG,INFO,WARNING,ERROR}\n",
"systemctl": []
},
"autostart no-such-sub": {
@@ -550,7 +550,7 @@
"asked": [],
"exit": 1,
"stderr": "",
- "stdout": "usage: meshbay-node root list|add|remove|set|eject|plug [name] [--group NAME]\n",
+ "stdout": "usage: meshbay-node root list|add|remove|set|eject|plug|pause|resume [name] [--group NAME]\n",
"systemctl": []
},
"root plug media": {
diff --git a/packages/meshbay-node/tests/golden/dispatch.json b/packages/meshbay-node/tests/golden/dispatch.json
index a39c57d..384c38f 100644
--- a/packages/meshbay-node/tests/golden/dispatch.json
+++ b/packages/meshbay-node/tests/golden/dispatch.json
@@ -144,6 +144,14 @@
"_admin_exec_root_eject"
]
},
+ "root_pause": {
+ "audit": [],
+ "log": [],
+ "sent": [],
+ "spawned": [
+ "_admin_exec_root_pause"
+ ]
+ },
"root_plug": {
"audit": [],
"log": [],
@@ -160,6 +168,14 @@
"_admin_exec_root_remove"
]
},
+ "root_resume": {
+ "audit": [],
+ "log": [],
+ "sent": [],
+ "spawned": [
+ "_admin_exec_root_resume"
+ ]
+ },
"search_listed": {
"audit": [],
"log": [],
@@ -2020,7 +2036,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -2343,7 +2359,7 @@
"subject": "gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -2362,7 +2378,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -2381,7 +2397,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -2400,7 +2416,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -2670,7 +2686,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -2684,7 +2700,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -2698,7 +2714,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -2712,7 +2728,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -2726,7 +2742,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -2740,7 +2756,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -2754,7 +2770,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -2768,7 +2784,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -3983,7 +3999,7 @@
"subject": "gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -4002,7 +4018,7 @@
"subject": "gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -4021,7 +4037,7 @@
"subject": "gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -4040,7 +4056,7 @@
"subject": "gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -10159,7 +10175,7 @@
"subject": "link:gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -12258,7 +12274,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -12277,7 +12293,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -12296,7 +12312,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -13397,7 +13413,7 @@
"req_id": 4242,
"token": null,
"type": "pong",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -13412,7 +13428,7 @@
"x"
],
"type": "pong",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -13425,7 +13441,7 @@
"req_id": 4242,
"token": 7,
"type": "pong",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -13438,7 +13454,7 @@
"req_id": 4242,
"token": "x",
"type": "pong",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -13451,7 +13467,7 @@
"req_id": 4242,
"token": null,
"type": "pong",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -13466,7 +13482,7 @@
"x"
],
"type": "pong",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -13479,7 +13495,7 @@
"req_id": 4242,
"token": 7,
"type": "pong",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -13492,7 +13508,7 @@
"req_id": 4242,
"token": "x",
"type": "pong",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -13827,7 +13843,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -13846,7 +13862,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -13865,7 +13881,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -14030,6 +14046,379 @@
"sent": [],
"spawned": []
},
+ "root_pause | challenged | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause | challenged | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause | challenged | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause | challenged | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause | fresh | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause | fresh | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause | fresh | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause | fresh | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause | member | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Missing group_id or root_name",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause | member | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "No authorized key for this",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause | member | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "No authorized key for this",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause | member | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "No authorized key for this",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause | operator | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Missing group_id or root_name",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause | operator | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "group_id": "['x']",
+ "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
+ "nonce": "<volatile>",
+ "op": "root_pause",
+ "op_id": "<volatile>",
+ "req_id": 4242,
+ "subject": "['x']",
+ "ts": "<volatile>",
+ "type": "admin_challenge",
+ "v": "6.2"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause | operator | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "group_id": "7",
+ "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
+ "nonce": "<volatile>",
+ "op": "root_pause",
+ "op_id": "<volatile>",
+ "req_id": 4242,
+ "subject": "7",
+ "ts": "<volatile>",
+ "type": "admin_challenge",
+ "v": "6.2"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause | operator | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "group_id": "x",
+ "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
+ "nonce": "<volatile>",
+ "op": "root_pause",
+ "op_id": "<volatile>",
+ "req_id": 4242,
+ "subject": "x",
+ "ts": "<volatile>",
+ "type": "admin_challenge",
+ "v": "6.2"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause_ack | challenged | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause_ack | challenged | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause_ack | challenged | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause_ack | challenged | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause_ack | fresh | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause_ack | fresh | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause_ack | fresh | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause_ack | fresh | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_pause_ack | member | bare": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "root_pause_ack | member | lists": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "root_pause_ack | member | numbers": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "root_pause_ack | member | strings": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "root_pause_ack | operator | bare": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "root_pause_ack | operator | lists": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "root_pause_ack | operator | numbers": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "root_pause_ack | operator | strings": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
"root_plug | challenged | bare": {
"audit": [],
"log": [],
@@ -14200,7 +14589,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -14219,7 +14608,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -14238,7 +14627,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -14573,7 +14962,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -14592,7 +14981,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -14611,7 +15000,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -14776,6 +15165,379 @@
"sent": [],
"spawned": []
},
+ "root_resume | challenged | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume | challenged | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume | challenged | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume | challenged | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume | fresh | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume | fresh | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume | fresh | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume | fresh | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume | member | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Missing group_id or root_name",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume | member | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "No authorized key for this",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume | member | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "No authorized key for this",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume | member | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "No authorized key for this",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume | operator | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Missing group_id or root_name",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume | operator | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "group_id": "['x']",
+ "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
+ "nonce": "<volatile>",
+ "op": "root_resume",
+ "op_id": "<volatile>",
+ "req_id": 4242,
+ "subject": "['x']",
+ "ts": "<volatile>",
+ "type": "admin_challenge",
+ "v": "6.2"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume | operator | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "group_id": "7",
+ "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
+ "nonce": "<volatile>",
+ "op": "root_resume",
+ "op_id": "<volatile>",
+ "req_id": 4242,
+ "subject": "7",
+ "ts": "<volatile>",
+ "type": "admin_challenge",
+ "v": "6.2"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume | operator | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "group_id": "x",
+ "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
+ "nonce": "<volatile>",
+ "op": "root_resume",
+ "op_id": "<volatile>",
+ "req_id": 4242,
+ "subject": "x",
+ "ts": "<volatile>",
+ "type": "admin_challenge",
+ "v": "6.2"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume_ack | challenged | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume_ack | challenged | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume_ack | challenged | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume_ack | challenged | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume_ack | fresh | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume_ack | fresh | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume_ack | fresh | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume_ack | fresh | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "root_resume_ack | member | bare": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "root_resume_ack | member | lists": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "root_resume_ack | member | numbers": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "root_resume_ack | member | strings": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "root_resume_ack | operator | bare": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "root_resume_ack | operator | lists": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "root_resume_ack | operator | numbers": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "root_resume_ack | operator | strings": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
"search_listed | challenged | bare": {
"audit": [],
"log": [],
@@ -17238,7 +18000,7 @@
"subject": "{\"language\":null,\"token\":null}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
@@ -17281,7 +18043,7 @@
"subject": "{\"language\":\"x\",\"token\":\"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881\"}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.1"
+ "v": "6.2"
}
],
"spawned": []
diff --git a/packages/meshbay-node/tests/test_root_availability.py b/packages/meshbay-node/tests/test_root_availability.py
index 7cc890f..d31e11b 100644
--- a/packages/meshbay-node/tests/test_root_availability.py
+++ b/packages/meshbay-node/tests/test_root_availability.py
@@ -120,7 +120,7 @@ async def test_members_are_told_which_roots_are_unavailable(tmp_path):
idx = await _indexer(_roots(films))
assert idx.index.roots == [
{"name": "Films", "kind": "generic", "available": True,
- "writable": True, "removable": False, "ejected": False}]
+ "writable": True, "removable": False, "ejected": False, "paused": False}]
(films / "a.mkv").unlink()
films.rmdir()
diff --git a/packages/meshbay-node/tests/test_root_paths_are_operator_only.py b/packages/meshbay-node/tests/test_root_paths_are_operator_only.py
index 646c7b4..edbc62f 100644
--- a/packages/meshbay-node/tests/test_root_paths_are_operator_only.py
+++ b/packages/meshbay-node/tests/test_root_paths_are_operator_only.py
@@ -102,7 +102,7 @@ def test_the_cli_only_reads_fields_the_payload_carries(monkeypatch, tmp_path, ca
path = Path("/tmp/x")
name = "x"
kind = "generic"
- writable = removable = ejected = False
+ writable = removable = ejected = paused = False
available = True
offered = RootSet(roots=[_Any()]).describe(with_paths=True)[0]
diff --git a/packages/meshbay-node/tests/test_root_pause.py b/packages/meshbay-node/tests/test_root_pause.py
new file mode 100644
index 0000000..d9a4fcb
--- /dev/null
+++ b/packages/meshbay-node/tests/test_root_pause.py
@@ -0,0 +1,313 @@
+"""
+Pausing a root's indexing, and the walk that has to notice it.
+
+A scan of a large drive runs for hours, and nothing could stop one: the hashing
+loop never looked at the root again once it had the file list. Removing the
+directory let it read the whole drive anyway and threw the result away at the
+end; ejecting it went on reading a drive the operator was about to unplug.
+
+A pause has to outlast the walk it stops. Reconcile indexes every file the index
+lacks, so a walk that was merely cancelled was redone at the next tick; the flag
+is what reconcile, the watcher and a restart all read. And it keeps what was
+indexed: the index is rebuilt at every start, so a paused root is listed from the
+hash cache rather than left empty.
+"""
+
+import asyncio
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_node import ops
+from meshbay_node.config import GroupConfig, RootSpec
+from meshbay_node.indexer import indexer as indexer_mod
+from meshbay_node.indexer.cache import IndexCache
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.indexer.indexer import DirectoryIndexer
+from meshbay_node.roots import RootSet
+from meshbay_node.roster import Roster
+
+pytestmark = pytest.mark.asyncio
+
+GID = "g" * 32
+
+
+def _films(tmp_path: Path, n: int = 6) -> Path:
+ films = tmp_path / "Films"
+ films.mkdir()
+ for i in range(n):
+ (films / f"f{i}.mkv").write_bytes(f"film {i}".encode())
+ return films
+
+
+def _indexer(roots: RootSet, **kw) -> DirectoryIndexer:
+ return DirectoryIndexer(roots=roots, group_id=GID,
+ sk_node=Ed25519PrivateKey.generate(), gek=None, **kw)
+
+
+def _names(idx: DirectoryIndexer) -> set[str]:
+ return {e.name for e in idx.index.entries}
+
+
+def _count_reads(monkeypatch, after=None, at: int = 0) -> list[str]:
+ """Record every file actually read; run `after` once `at` have been."""
+ read: list[str] = []
+ real = indexer_mod._scan_file
+
+ def counting(root, path):
+ read.append(path.name)
+ if after is not None and len(read) == at:
+ after()
+ return real(root, path)
+
+ monkeypatch.setattr(indexer_mod, "_scan_file", counting)
+ return read
+
+
+# ── The walk notices ─────────────────────────────────────────────────────────
+
+async def test_a_pause_stops_the_walk_at_the_next_file(tmp_path, monkeypatch):
+ roots = RootSet.build([{"path": str(_films(tmp_path))}])
+ idx = _indexer(roots)
+ read = _count_reads(monkeypatch, lambda: setattr(roots.roots[0], "paused", True), at=2)
+
+ await idx.initial_scan()
+
+ assert len(read) == 2, f"read {len(read)} files after the pause"
+ assert len(_names(idx)) == 2, "the files hashed before the pause stay listed"
+ assert idx.progress.scanning is False
+
+
+async def test_a_root_removed_mid_walk_is_not_read_to_the_end(tmp_path, monkeypatch):
+ films = _films(tmp_path)
+ other = tmp_path / "Other"
+ other.mkdir()
+ roots = RootSet.build([{"path": str(films)}])
+ idx = _indexer(roots)
+
+ def remove():
+ idx.roots = RootSet.build([{"path": str(other)}])
+
+ read = _count_reads(monkeypatch, remove, at=1)
+ await idx.initial_scan()
+
+ assert len(read) == 1, f"went on reading a removed root: {read}"
+
+
+async def test_a_root_ejected_mid_walk_is_not_read_to_the_end(tmp_path, monkeypatch):
+ roots = RootSet.build([{"path": str(_films(tmp_path)), "removable": True}])
+ idx = _indexer(roots)
+ read = _count_reads(monkeypatch, lambda: idx.eject_root("Films"), at=1)
+
+ await idx.initial_scan()
+
+ assert len(read) == 1, f"went on reading an ejected drive: {read}"
+
+
+async def test_a_retarget_does_not_hide_the_pause_from_a_running_walk(
+ tmp_path, monkeypatch):
+ """The walk holds the Root it started with; a reload replaces every one."""
+ films = _films(tmp_path)
+ roots = RootSet.build([{"path": str(films)}])
+ idx = _indexer(roots)
+
+ def reload_then_pause():
+ idx.roots = RootSet.build([{"path": str(films)}])
+ idx.roots.roots[0].paused = True
+
+ read = _count_reads(monkeypatch, reload_then_pause, at=1)
+ await idx.initial_scan()
+
+ assert len(read) == 1
+
+
+# ── The pause outlasts the walk ──────────────────────────────────────────────
+
+async def test_reconcile_leaves_a_paused_root_alone(tmp_path):
+ films = _films(tmp_path, n=1)
+ roots = RootSet.build([{"path": str(films)}])
+ idx = _indexer(roots)
+ await idx.initial_scan()
+ await idx.pause_root("Films")
+
+ (films / "new.mkv").write_bytes(b"new film")
+ await idx.reconcile()
+
+ assert "new.mkv" not in _names(idx), "reconcile redid the work the pause stopped"
+
+
+async def test_the_watcher_leaves_a_paused_root_alone(tmp_path):
+ films = _films(tmp_path, n=1)
+ idx = _indexer(RootSet.build([{"path": str(films)}]))
+ await idx.initial_scan()
+ await idx.pause_root("Films")
+
+ new = films / "new.mkv"
+ new.write_bytes(b"new film")
+ await idx._update_entry(new, deleted=False)
+
+ assert "new.mkv" not in _names(idx)
+
+
+async def test_a_paused_root_is_listed_from_the_cache_and_nothing_is_read(
+ tmp_path, monkeypatch):
+ """
+ After a restart the index is empty until the walk refills it. Skipping a
+ paused root would empty it for members; reading it would undo the pause.
+ """
+ films = _films(tmp_path, n=3)
+ cache = IndexCache(db_path=tmp_path / "cache.db")
+ await cache.open()
+ try:
+ await _indexer(RootSet.build([{"path": str(films)}]), cache=cache).initial_scan()
+ (films / "unhashed.mkv").write_bytes(b"never hashed")
+
+ roots = RootSet.build([{"path": str(films), "paused": True}])
+ idx = _indexer(roots, cache=cache)
+ read = _count_reads(monkeypatch)
+ await idx.initial_scan()
+
+ assert read == []
+ assert _names(idx) == {"f0.mkv", "f1.mkv", "f2.mkv"}
+ assert idx.progress.scanning is False
+ finally:
+ await cache.close()
+
+
+async def test_a_pause_mid_rescan_keeps_the_entries_it_had(tmp_path, monkeypatch):
+ """A rescan drops the root's entries before it walks; stopping it there
+ would leave the operator with half a library."""
+ films = _films(tmp_path, n=4)
+ cache = IndexCache(db_path=tmp_path / "cache.db")
+ await cache.open()
+ try:
+ roots = RootSet.build([{"path": str(films)}])
+ idx = _indexer(roots, cache=cache)
+ await idx.initial_scan()
+ for f in films.iterdir():
+ f.write_bytes(f.read_bytes() + b" edited") # every cache row stale
+
+ read = _count_reads(monkeypatch, lambda: setattr(roots.roots[0], "paused", True),
+ at=1)
+ await idx._rescan_root(roots.roots[0])
+
+ assert len(read) == 1
+ assert len(_names(idx)) == 1
+ finally:
+ await cache.close()
+
+
+# ── Resume ───────────────────────────────────────────────────────────────────
+
+async def test_resume_indexes_what_is_missing_and_keeps_what_was_enriched(tmp_path):
+ films = _films(tmp_path, n=2)
+ idx = _indexer(RootSet.build([{"path": str(films)}]))
+ await idx.initial_scan()
+ for e in idx.index.entries:
+ e.artist = "Some Artist"
+ await idx.pause_root("Films")
+ (films / "new.mkv").write_bytes(b"new film")
+
+ await idx.resume_root("Films")
+ await asyncio.gather(*idx._scan_tasks)
+
+ assert "new.mkv" in _names(idx)
+ kept = [e for e in idx.index.entries if e.name != "new.mkv"]
+ assert all(e.artist == "Some Artist" for e in kept), \
+ "resume replaced entries it should have left alone"
+
+
+async def test_resume_returns_before_the_walk_ends(tmp_path):
+ films = _films(tmp_path, n=1)
+ roots = RootSet.build([{"path": str(films)}])
+ idx = _indexer(roots)
+ await idx.initial_scan()
+ await idx.pause_root("Films")
+
+ async with idx._scan_lock:
+ await asyncio.wait_for(idx.resume_root("Films"), timeout=1)
+ assert idx.progress.queued == ["Films"]
+ await asyncio.gather(*idx._scan_tasks)
+ assert idx.progress.queued == []
+
+
+# ── The flag is the operator's, and survives ─────────────────────────────────
+
+def _state(tmp_path: Path, roots: RootSet, roster: Roster) -> dict:
+ index = GroupIndex(group_id=GID, sk_node=Ed25519PrivateKey.generate())
+ return {
+ "groups_ctx": {GID: {"index": index, "roots": roots, "gek": None}},
+ "config": SimpleNamespace(groups=[SimpleNamespace(id=GID, roots=[])]),
+ "roster": roster,
+ "node_user_id": "operator",
+ }
+
+
+async def test_pause_and_resume_persist_through_the_roster(tmp_path):
+ films = _films(tmp_path, n=1)
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ state = _state(tmp_path, RootSet.build([{"path": str(films)}]), roster)
+
+ out = await ops.pause_root(state, GID, "films")
+ assert out["status"] == "paused"
+ assert out["roots"][0]["paused"] is True
+ assert out["roots"][0]["available"] is True, "a paused root is still served"
+ assert await roster.paused_roots(GID) == {"films"}
+ assert (await ops.pause_root(state, GID, "Films"))["status"] == "already_paused"
+
+ out = await ops.resume_root(state, GID, "Films")
+ assert out["status"] == "resumed"
+ assert await roster.paused_roots(GID) == set()
+ finally:
+ await roster.close()
+
+
+async def test_removing_a_root_forgets_its_pause(tmp_path):
+ """A directory added back later under the same name starts indexed."""
+ films = _films(tmp_path, n=1)
+ keep = tmp_path / "Keep"
+ keep.mkdir()
+ conf = tmp_path / "node.toml"
+ conf.write_text(
+ f'[[groups]]\nid = "{GID}"\nname = "g"\n\n'
+ f'[[groups.roots]]\npath = "{films.as_posix()}"\n\n'
+ f'[[groups.roots]]\npath = "{keep.as_posix()}"\n')
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ state = _state(tmp_path, RootSet.build(
+ [{"path": str(films)}, {"path": str(keep)}]), roster)
+ state["config_path"] = str(conf)
+ state["config"] = SimpleNamespace(groups=[GroupConfig(
+ id=GID, roots=[RootSpec(path=str(films)), RootSpec(path=str(keep))])])
+ await ops.pause_root(state, GID, "Films")
+
+ await ops.remove_root(state, GID, "Films")
+
+ assert await roster.paused_roots(GID) == set()
+ finally:
+ await roster.close()
+
+
+async def test_a_new_group_can_be_paused_during_its_first_scan(tmp_path):
+ """A group added while the node runs has an indexer, and no context until
+ that first scan ends — the scan most worth pausing."""
+ films = _films(tmp_path, n=1)
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ roots = RootSet.build([{"path": str(films)}])
+ idx = _indexer(roots)
+ state = _state(tmp_path, roots, roster)
+ state["groups_ctx"] = {}
+ state["indexers"] = {GID: idx}
+
+ out = await ops.pause_root(state, GID, "Films")
+
+ assert out["status"] == "paused"
+ assert roots.roots[0].paused is True
+ finally:
+ await roster.close()
diff --git a/packages/meshbay-node/tests/test_roots.py b/packages/meshbay-node/tests/test_roots.py
index 0fe9f03..30bbc8a 100644
--- a/packages/meshbay-node/tests/test_roots.py
+++ b/packages/meshbay-node/tests/test_roots.py
@@ -290,9 +290,9 @@ def test_describe_reports_what_a_member_needs(tmp_path):
described = roots.describe()
assert described == [
{"name": "Media", "kind": "generic", "available": True,
- "writable": True, "removable": False, "ejected": False},
+ "writable": True, "removable": False, "ejected": False, "paused": False},
{"name": "Music", "kind": "audio", "available": True,
- "writable": False, "removable": True, "ejected": False},
+ "writable": False, "removable": True, "ejected": False, "paused": False},
]
# Deliberately no paths: a member is told what exists and whether it is
# readable, not where on the operator's disk it lives.
@@ -301,15 +301,16 @@ def test_describe_reports_what_a_member_needs(tmp_path):
def test_describe_names_a_root_and_never_a_path(tmp_path):
"""
- What a member is told about a root: that it exists, and whether it is
- readable and writable. Never where on the operator's disk it lives — that
- is the operator's own view (`with_paths`), over their own channel.
+ What a member is told about a root: that it exists, whether it is
+ readable and writable, and whether its indexing is paused. Never where on
+ the operator's disk it lives — that is the operator's own view
+ (`with_paths`), over their own channel.
"""
(tmp_path / "Media").mkdir()
roots = RootSet.build([_spec(tmp_path / "Media", writable=True)])
described = roots.describe()[0]
assert set(described) == {"name", "kind", "available", "writable",
- "removable", "ejected"}
+ "removable", "ejected", "paused"}
# ── SAFE_UPLOAD_NAME ────────────────────────────────────────────────────────