diff options
9 files changed, 436 insertions, 41 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index ecf51fd..b37de8e 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -37,5 +37,11 @@ __version__ = "0.7.0" # way a TMDB poster or MusicBrainz cover is cached. Additive: an older # client never sends the request, and simply can't play those two formats, # same as before this existed. -MNP_VERSION = "0.9" +# 0.10: added `audio_root`/`_ack` — the Music app's own entry point, same +# shape as `video_root`/`_ack`. Reverses musicbay.md's original "no root, +# whole shared tree" call: a real messy library showed that call was +# wrong, and Music now requires one before showing/enriching anything, +# exactly like Videos. Additive at the protocol level: an older client +# never sends the op and never expects the field. +MNP_VERSION = "0.10" MHP_VERSION = "0.1" diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index f0a79fe..d09b95e 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -92,6 +92,10 @@ OP_MUSICBRAINZ_CONFIG = "musicbrainz_config" # the lesson was already learned once). Signed for the same reason as # tmdb_enabled. OP_MUSICBRAINZ_ENABLED = "musicbrainz_enabled" +# Which folder is the Music app's entry point for this group — same shape as +# OP_VIDEO_ROOT above, added later once a real messy library showed the +# "no root, whole shared tree" simplification didn't hold up. +OP_AUDIO_ROOT = "audio_root" OP_ROOT_ADD = "root_add" OP_ROOT_REMOVE = "root_remove" OP_GROUP_ATTACH = "group_attach" diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 8f29c38..24fea8f 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -119,6 +119,10 @@ class MNP: # TMDB poster or a MusicBrainz cover. AUDIO_TRANSCODE_REQ = "audio_transcode_req" # client → node: transcode this file id AUDIO_TRANSCODE_RESP = "audio_transcode_resp" # node → client: cache hash/size/mime + # Which folder is the Music app's entry point for this group — same + # shape as VIDEO_ROOT above. + AUDIO_ROOT = "audio_root" # operator → node: which folder is the Music entry point + AUDIO_ROOT_ACK = "audio_root_ack" # Device linking. A new device files a request bound to a code it displays; # an already-pinned device of the same account approves it. Neither the hub # nor the node can produce the countersignature. diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index ae4e1f0..0015f1a 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -73,6 +73,12 @@ def _under_video_root(path: str, video_root: str) -> bool: return path == video_root or path.startswith(video_root + "/") +def _under_audio_root(path: str, audio_root: str) -> bool: + """Mirrors music-app.js's underAudioRoot — same shape as _under_video_root.""" + path = path or "" + return path == audio_root or path.startswith(audio_root + "/") + + # ── Argon2id calibration ────────────────────────────────────────────────────── def calibrate_argon2(target_ms: int = 500) -> None: @@ -340,6 +346,9 @@ class NodeDaemon: # group — "" means the whole group index. "video_root": await self._roster.video_root( group_cfg.id) if self._roster else "", + # Same shape, Music app's own entry point. + "audio_root": await self._roster.audio_root( + group_cfg.id) if self._roster else "", # Whether TMDB lookups run for this group at all — # per-group (2026-08-24, used to be node-wide), same # "read once, kept current in place by the signed op" @@ -541,7 +550,7 @@ class NodeDaemon: self._state["hub"] = hub self._state["reload_fn"] = self._reload_config self._state["enrich_video_root_fn"] = self._enrich_video_root_now - self._state["enrich_music_now_fn"] = self._enrich_music_now + self._state["enrich_audio_root_fn"] = self._enrich_audio_root_now # Rotating a key has to reach every transport holding a copy of it, # and clearing the denylist has to reach the one the handshake # consults — so both are published rather than reachable only @@ -750,6 +759,9 @@ class NodeDaemon: "video_root": ( await self._roster.video_root(group_cfg.id) if self._roster else ""), + "audio_root": ( + await self._roster.audio_root(group_cfg.id) + if self._roster else ""), "tmdb_enabled": ( await self._roster.tmdb_enabled(group_cfg.id) if self._roster else True), @@ -988,11 +1000,9 @@ class NodeDaemon: # INDEX_DELTA update (_on_enriched below). new_entries = delta.additions if delta is not None else list(idx.entries) asyncio.ensure_future(self._enrich_new_video_entries(indexer, new_entries)) - # Music app (docs/musicbay.md §6): same shape, gated on the group's - # enabled_apps rather than a root (Music has no video_root analogue — - # see musicbay.md §2.1's note on why that scoping wasn't carried - # over). Tag/cover extraction is local and cheap either way; the gate - # exists to not do it at all for a group that never turned Music on. + # Music app (docs/musicbay.md §6): same shape, gated on audio_root + # exactly like video_root above (added later — musicbay.md's + # original "no root, whole shared tree" call didn't hold up). asyncio.ensure_future(self._enrich_new_audio_entries(indexer, new_entries)) # A rename/move changes the very filename (or season folder) that @@ -1147,49 +1157,49 @@ class NodeDaemon: async def _enrich_new_audio_entries(self, indexer: DirectoryIndexer, entries: list) -> None: """ Music app (docs/musicbay.md §2.1, §6): fire (never await further) - tag/cover enrichment for unattempted audio entries, gated on the - group having "music" in its enabled_apps — there is no video_root - analogue for Music (musicbay.md §2.1 deliberately didn't add one: - tag reads are free/local, unlike ffprobe+ffmpeg thumbnailing, so the - cost this gate protects against is smaller, and most personal MP3 - libraries want their whole shared tree available rather than one - scoped subfolder). `_enriched_attempted` is shared with the video - path — content-addressed ids never collide across the two. + tag/cover enrichment for unattempted audio entries under the + group's configured audio_root — same gate as + `_enrich_new_video_entries` above (musicbay.md's original "no root, + whole shared tree" call turned out wrong against a real messy + library: everything under every shared folder got mixed together + with no way to scope it down). `_enriched_attempted` is shared with + the video path — content-addressed ids never collide across the two. """ if not self._audio_enricher or not self._roster: return - enabled_apps = await self._roster.enabled_apps(indexer.group_id) - if "music" not in enabled_apps: + audio_root = await self._roster.audio_root(indexer.group_id) + if not audio_root: return + root_boundary = indexer.roots.resolve(audio_root, require_available=False) for entry in entries: if entry.type != "audio" or entry.id in self._enriched_attempted: continue + if not _under_audio_root(entry.path, audio_root): + continue file_path = entry_abs_path(indexer.roots, entry) if not file_path or not file_path.exists(): continue self._enriched_attempted.add(entry.id) - # The entry's own named root, so the ancestor walk - # (enrich_audio._artist_album_from_ancestors) can tell "a - # top-level folder under this root" from "one level deeper" — - # without this boundary it used to read the root's own - # directory name as an artist for every flat top-level folder. - split = indexer.roots.split(entry.path) - root_path = split[0].path if split else None async def on_done(file_id: str, fields: dict, _indexer=indexer) -> None: await self._on_enriched(_indexer, file_id, fields) - self._audio_enricher.spawn(entry, file_path, on_done, root_path) + # `root_boundary` — audio_root itself, not the shared root it + # lives under — so the ancestor walk + # (enrich_audio._artist_album_from_ancestors) treats a flat + # top-level folder right under the *configured* Music root as + # ambiguous (artist-or-release, §2.1), not one level too shallow + # if audio_root is itself a subfolder of a larger shared root. + self._audio_enricher.spawn(entry, file_path, on_done, root_boundary) - async def _enrich_music_now(self, group_id: str) -> None: + async def _enrich_audio_root_now(self, group_id: str) -> None: """ - Music app: sweep a group's existing index right after "music" is - added to its enabled_apps (ops.set_enabled_apps) — the ordinary path - above only ever looks at entries new since the last broadcast, so a - library that was already sitting there before Music was turned on - would otherwise never get enriched. Mirrors - `_enrich_video_root_now`, triggered from a different setting because - Music has no root of its own to key off. + Music app: sweep a group's existing index right after its + audio_root is set or changed (ops.set_audio_root). Mirrors + `_enrich_video_root_now` exactly — the ordinary path above only + ever looks at entries new since the last broadcast, so a folder + that already had files in it before it became the audio_root would + otherwise never get enriched at all. """ indexer = self._state.get("indexers", {}).get(group_id) if not indexer: diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index d439ec3..1da6928 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -728,14 +728,6 @@ async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict: set_by=state.get("node_user_id", "")) ctx["enabled_apps"] = apps log.info("Enabled apps for group %s: %s", group_id[:8], ",".join(sorted(apps))) - if "music" in apps: - # Music has no root of its own to key a sweep off (unlike - # set_video_root's enrich_video_root_fn) — turning the app on at all - # is the trigger, mirroring that same "sweep what's already there" - # need (docs/musicbay.md §2.1, daemon._enrich_music_now). - enrich_fn = state.get("enrich_music_now_fn") - if enrich_fn: - asyncio.ensure_future(enrich_fn(group_id)) return {"apps": apps, "group_id": group_id} @@ -850,6 +842,27 @@ async def set_video_root(state: dict, group_id: str, path: str) -> dict: return {"path": path, "group_id": group_id} +async def set_audio_root(state: dict, group_id: str, path: str) -> dict: + """ + Same shape as set_video_root above — the Music app's own entry point, + added later (docs/musicbay.md's original "no root, works over the + whole shared tree" simplification didn't hold up against a real messy + library). `path=""` clears it — the Music tab then asks for one to be + chosen before anything (including tag/cover enrichment) runs, rather + than defaulting to the whole shared index. + """ + roster = _roster(state) + ctx = _group_ctx(state, group_id) + await roster.set_audio_root(group_id, path, set_by=state.get("node_user_id", "")) + ctx["audio_root"] = path + log.info("Music root for group %s: %r", group_id[:8], path) + if path: + enrich_fn = state.get("enrich_audio_root_fn") + if enrich_fn: + asyncio.ensure_future(enrich_fn(group_id)) + return {"path": path, "group_id": group_id} + + # ── Scan settings ──────────────────────────────────────────────────────────── async def set_scan_settings(state: dict, group_id: str, reconcile_interval_secs: float, diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 24424ab..91e18cd 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -649,6 +649,21 @@ class Roster: await self.set_setting(group_id, self.SETTING_VIDEO_ROOT, path or "", set_by) return path or "" + # Same shape as SETTING_VIDEO_ROOT — the Music app's own entry point, + # added later (docs/musicbay.md's original "no root, works over the + # whole shared tree" simplification turned out not to hold up against a + # real messy library: the operator asked for the same scoping Videos + # already had). Empty/unset means Music shows nothing yet, exactly like + # an unset video_root — see daemon.py's enrichment gate. + SETTING_AUDIO_ROOT = "audio_root" + + async def audio_root(self, group_id: str) -> str: + return await self.get_setting(group_id, self.SETTING_AUDIO_ROOT, "") or "" + + async def set_audio_root(self, group_id: str, path: str, set_by: str = "") -> str: + await self.set_setting(group_id, self.SETTING_AUDIO_ROOT, path or "", set_by) + return path or "" + # Whether TMDB lookups run for this group at all — per-group, unlike the # token/language above: one node process can share a real media library # group and several test/demo groups, and outbound TMDB traffic (and API diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 1629801..a5cccaa 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -72,6 +72,7 @@ from meshbay_common.adminop import ( OP_TMDB_OVERRIDE, OP_MUSICBRAINZ_CONFIG, OP_MUSICBRAINZ_ENABLED, + OP_AUDIO_ROOT, OP_ROOT_ADD, OP_ROOT_REMOVE, OP_GROUP_ATTACH, @@ -398,6 +399,8 @@ class WebRTCPeerSession: self._do_tmdb_enabled(msg) elif mtype == MNP.VIDEO_ROOT: self._do_video_root(msg) + elif mtype == MNP.AUDIO_ROOT: + self._do_audio_root(msg) elif mtype == MNP.MEDIA_META_REQ: self._spawn(self._do_media_meta_request(msg)) elif mtype == MNP.SEASON_META_REQ: @@ -685,6 +688,10 @@ class WebRTCPeerSession: "musicbrainz_enabled": bool(self._group_ctx().get("musicbrainz_enabled", True)), "musicbrainz_contact_configured": bool( self._ctx.get("daemon_state", {}).get("musicbrainz_contact_configured", False)), + # Which folder the Music app treats as its entry point for this + # group — same shape as video_root above, "" means unset (the + # Music tab shows nothing yet). + "audio_root": self._group_ctx().get("audio_root") or "", # So a client that connects mid-scan shows the indexing state # immediately, instead of waiting for the next periodic # INDEX_PROGRESS push. Never a path or filename — see @@ -1852,6 +1859,46 @@ class WebRTCPeerSession: except Exception: pass + def _do_audio_root(self, msg: dict) -> None: + """Same shape as _do_video_root above — the Music app's own entry point.""" + path = msg.get("path") + if not isinstance(path, str): + self._send({"type": "error", "detail": "Missing or invalid 'path'"}) + return + path = path.strip("/") + if path: + ctx = self._group_ctx() + resolved = ctx["roots"].resolve(path) if ctx.get("roots") else None + if not resolved or not resolved.is_dir(): + self._send({"type": "error", "detail": "Not a directory in this group"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge(OP_AUDIO_ROOT, path) + + async def _admin_exec_audio_root( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + path = pending["subject"] + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"audio_root:{path}") + return + try: + await self._run_op(ops.set_audio_root, self._group_id or "", path) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("audio_root", path) + + notice = {"type": MNP.AUDIO_ROOT_ACK, "v": MNP_VERSION, "path": path} + for uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + def _do_musicbrainz_config(self, msg: dict) -> None: """ Set (or clear) the node-wide MusicBrainz User-Agent contact string @@ -3551,6 +3598,9 @@ class WebRTCPeerSession: elif pending["op"] == OP_MUSICBRAINZ_ENABLED: self._spawn( self._admin_exec_musicbrainz_enabled(pending, transcript, sig_bytes)) + elif pending["op"] == OP_AUDIO_ROOT: + self._spawn( + self._admin_exec_audio_root(pending, transcript, sig_bytes)) elif pending["op"] == OP_ROOT_ADD: self._spawn( self._admin_exec_root_add(pending, transcript, sig_bytes)) diff --git a/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py b/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py new file mode 100644 index 0000000..f088f5e --- /dev/null +++ b/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py @@ -0,0 +1,156 @@ +""" +Music-app enrichment (tag/cover extraction, docs/musicbay.md §2.1/§6) only +ever runs for a group that has an audio_root configured, and only for files +under it — see daemon.py's _enrich_new_audio_entries. Same reasoning as +Videos' video_root gate (test_video_root_gates_enrichment.py), added later: +musicbay.md's original "no root, whole shared tree" call turned out wrong +against a real messy library, where everything under every shared folder +got mixed together with no way to scope Music down to just the actual +music library. + +Setting or changing the root (ops.set_audio_root) fires a one-off sweep +(_enrich_audio_root_now) of whatever it already contains — same shape as +_enrich_video_root_now. +""" + +import asyncio +import os + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_common.crypto import generate_gek +from meshbay_node import ops +from meshbay_node.config import Config, HubConfig, NodeConfig, GroupConfig, KeystoreConfig +from meshbay_node.daemon import NodeDaemon +from meshbay_node.indexer import DirectoryIndexer +from meshbay_node.indexer.enrich_audio import AudioEnricher +from meshbay_node.media_cache import MediaCache +from meshbay_node.roster import Roster + +from conftest import one_root + +pytestmark = pytest.mark.asyncio + +# Above indexer.py's MIN_AUDIO_SIZE_BYTES gate — otherwise these fixture +# files would never even be indexed at all, regardless of audio_root. +_AUDIO_BYTES = os.urandom(60 * 1024) + + +def _free_port() -> int: + import socket + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +async def _make_daemon(tmp_path, shared, group_id): + config = Config( + hub=HubConfig(url="http://localhost:9999", username="testuser"), + node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()), + groups=[GroupConfig( + id=group_id, name="test-group", shared_dir=str(shared), + visibility="private", quic_port=29015, + )], + keystore=KeystoreConfig(path=tmp_path / "keystore.enc"), + data_dir=tmp_path / "data", + ) + daemon = NodeDaemon(config) + daemon._broadcast_coalesce_secs = 0.01 + daemon._media_cache = MediaCache(db_path=tmp_path / "media_cache.db") + await daemon._media_cache.open() + daemon._audio_enricher = AudioEnricher(daemon._media_cache) + daemon._roster = Roster(db_path=tmp_path / "roster.db") + await daemon._roster.open() + return daemon + + +async def _teardown(daemon): + await daemon._media_cache.close() + await daemon._roster.close() + + +async def test_no_audio_root_means_no_enrichment_at_all(tmp_path): + group_id = "a" * 32 + shared = tmp_path / "shared" + shared.mkdir() + (shared / "track.mp3").write_bytes(_AUDIO_BYTES) + + daemon = await _make_daemon(tmp_path, shared, group_id) + try: + indexer = DirectoryIndexer( + roots=one_root(shared), group_id=group_id, + sk_node=Ed25519PrivateKey.generate(), gek=generate_gek()) + await indexer.initial_scan() + await daemon._on_index_change(indexer) + await asyncio.sleep(0.05) + + assert not daemon._enriched_attempted, ( + "a group with no audio_root configured must not enrich anything, " + "not even fall back to the whole index") + finally: + await _teardown(daemon) + + +async def test_only_entries_under_the_configured_root_are_enriched(tmp_path): + group_id = "a" * 32 + shared = tmp_path / "shared" + shared.mkdir() + (shared / "Music").mkdir() + (shared / "Music" / "in-root.mp3").write_bytes(_AUDIO_BYTES) + (shared / "outside.mp3").write_bytes(os.urandom(60 * 1024)) + + daemon = await _make_daemon(tmp_path, shared, group_id) + try: + await daemon._roster.set_audio_root(group_id, "shared/Music", set_by="op") + indexer = DirectoryIndexer( + roots=one_root(shared), group_id=group_id, + sk_node=Ed25519PrivateKey.generate(), gek=generate_gek()) + await indexer.initial_scan() + await daemon._on_index_change(indexer) + await asyncio.sleep(0.05) + + by_name = {e.name: e for e in indexer.index.entries} + assert by_name["in-root.mp3"].id in daemon._enriched_attempted + assert by_name["outside.mp3"].id not in daemon._enriched_attempted, ( + "a file outside the configured audio_root must never be enriched") + finally: + await _teardown(daemon) + + +async def test_setting_the_audio_root_sweeps_what_it_already_contains(tmp_path): + group_id = "a" * 32 + shared = tmp_path / "shared" + shared.mkdir() + (shared / "Music").mkdir() + (shared / "Music" / "already-there.mp3").write_bytes(_AUDIO_BYTES) + + daemon = await _make_daemon(tmp_path, shared, group_id) + try: + indexer = DirectoryIndexer( + roots=one_root(shared), group_id=group_id, + sk_node=Ed25519PrivateKey.generate(), gek=generate_gek()) + await indexer.initial_scan() + daemon._state["indexers"][group_id] = indexer + + # Broadcast once with nothing configured — nothing should be scheduled. + await daemon._on_index_change(indexer) + await asyncio.sleep(0.05) + assert not daemon._enriched_attempted + + # Now the operator points audio_root at the folder that already held + # this file all along. + state = { + "roster": daemon._roster, + "groups_ctx": {group_id: {}}, + "enrich_audio_root_fn": daemon._enrich_audio_root_now, + } + await ops.set_audio_root(state, group_id, "shared/Music") + await asyncio.sleep(0.05) # let the fire-and-forget sweep actually run + + entry = next(iter(indexer.index.entries)) + assert entry.id in daemon._enriched_attempted, ( + "a file already sitting in the newly-chosen root must be picked " + "up by the sweep, not wait for some unrelated future change") + finally: + await _teardown(daemon) diff --git a/packages/meshbay-node/tests/test_audio_root_policy.py b/packages/meshbay-node/tests/test_audio_root_policy.py new file mode 100644 index 0000000..73bbe1f --- /dev/null +++ b/packages/meshbay-node/tests/test_audio_root_policy.py @@ -0,0 +1,137 @@ +""" +Which folder (possibly a subfolder of a shared root) is the Music app's +entry point for a group. Same shape as test_video_root_policy.py — a +signed operator instruction, per-group, stored via roster.py's +group_settings table, added later once a real messy library showed +musicbay.md's original "no root, whole shared tree" call was wrong. +""" + +from pathlib import Path + +import pytest + +from meshbay_common.adminop import OP_AUDIO_ROOT +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import Roster +from meshbay_node.transport.webrtc_server import WebRTCPeerSession +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from conftest import one_root + +pytestmark = pytest.mark.asyncio + + +def _session(tmp_path: Path, user_id: str, *, operator: str | None = None) -> WebRTCPeerSession: + shared_root = tmp_path / "shared" + shared_root.mkdir(exist_ok=True) + (shared_root / "Music").mkdir() + (shared_root / "Podcasts").mkdir() + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + ctx = { + "roots": one_root(shared_root), + "index": index, + "sk_node": index.sk_node, + "node_user_id": operator, + } + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = ctx + session._group_id = None + session._user_id = user_id + session._pk_user = "" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +# ── Refused before a challenge is even issued ─────────────────────────────── + +async def test_missing_path_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_audio_root({}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_a_nonexistent_folder_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_audio_root({"path": "shared/Nonexistent"}) + + assert not issued, "a mistyped path must be refused before a signature round trip" + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_path_traversal_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_audio_root({"path": "../../etc"}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): + session = _session(tmp_path, "member-1", operator="the-operator") + session._has_admin_authority = lambda: False + + session._do_audio_root({"path": "shared/Music"}) + + assert [m for m in session.sent if m.get("type") == "error"] + + +# ── Accepted cases ─────────────────────────────────────────────────────────── + +async def test_an_empty_path_is_always_accepted(tmp_path): + """Empty means 'unset' — Music shows nothing yet, always valid to clear.""" + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_audio_root({"path": ""}) + + assert issued == [(OP_AUDIO_ROOT, "")] + + +async def test_a_real_subfolder_is_accepted_and_signed(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_audio_root({"path": "shared/Music"}) + + assert issued == [(OP_AUDIO_ROOT, "shared/Music")] + + +# ── Where it is stored ────────────────────────────────────────────────────── + +async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + assert await roster.audio_root("g1") == "", "absent must mean unset" + await roster.set_audio_root("g1", "shared/Music", set_by="op") + assert await roster.audio_root("g1") == "shared/Music" + finally: + await roster.close() + + reopened = Roster(db_path=tmp_path / "roster.db") + await reopened.open() + try: + assert await reopened.audio_root("g1") == "shared/Music" + assert await reopened.audio_root("g2") == "", "one group's setting must not answer for another" + finally: + await reopened.close() |