aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-14 21:45:53 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-14 21:45:53 +0200
commitcd2745cecff12e894e0dfa702bff6a90f0e8734e (patch)
tree8e864603cfd4c49cde535c8c4f96c5151ed4276c /packages/meshbay-hub/tests
parentdf3b808792daa745b5b0d5b9896ddca8849fe8b1 (diff)
downloadmeshbay-cd2745cecff12e894e0dfa702bff6a90f0e8734e.tar.gz
feat: a group can be left out of Search, and Search tries every node
`search_listed` is a per-group setting on the node, changed by a signed operator op and carried in the sealed handshake ack. Search reads it after the handshake and stops there: no index is fetched, cached or merged, in any of the four views, and the page says how many groups it left out. The switch is a "Search" section in the group's settings, shown to the operator. Absent means listed, at every layer: roster default, ack default, and the client only drops a group on an explicit `false` — so an upgrade or an older node removes nothing from anyone's Search. It is a listing preference and protects nothing: the node serves the same index to Search and to the group page and cannot tell them apart, every member lists the group by opening it, and a client that ignores the flag lists it in Search too. Design §9.11 says so, so it is never described as private. The cost is one handshake per unlisted group, because only the node knows the setting. Search also took `nodes[0]` twice — for the index and for the pooled connection — the defect 4cce50f fixed on the group page only. One `connectToGroup` now walks the list the same way: a refusal about this browser stops, `not_hosted` or a failed connection moves on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_app_settings_plugin.py5
-rw-r--r--packages/meshbay-hub/tests/test_search_unlisted.py96
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py28
3 files changed, 127 insertions, 2 deletions
diff --git a/packages/meshbay-hub/tests/test_app_settings_plugin.py b/packages/meshbay-hub/tests/test_app_settings_plugin.py
index 940d975..027fee6 100644
--- a/packages/meshbay-hub/tests/test_app_settings_plugin.py
+++ b/packages/meshbay-hub/tests/test_app_settings_plugin.py
@@ -224,8 +224,9 @@ def test_the_page_performs_exactly_one_app_specific_operation():
"GroupSettingsPanel")
calls = set(re.findall(r"transport\.(set\w+)\(", panel))
# The page's own settings, which belong to no app: which apps are enabled
- # at all, and how hard the node works watching its disk.
- page_level = {"setAppsEnabled", "setScanSettings"}
+ # at all, how hard the node works watching its disk, and whether members'
+ # cross-group Search lists the group.
+ page_level = {"setAppsEnabled", "setScanSettings", "setSearchListed"}
# One generic operation, keyed by the app's own name: adding an app adds
# no message type and no call site here.
generic = {"setAppDirectories"}
diff --git a/packages/meshbay-hub/tests/test_search_unlisted.py b/packages/meshbay-hub/tests/test_search_unlisted.py
new file mode 100644
index 0000000..4ffec32
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_search_unlisted.py
@@ -0,0 +1,96 @@
+"""
+A group the operator left out of Search is not listed there, in any view.
+
+`search_listed` is a presentation preference, not an access control (design
+§9.11): the node serves the same index to Search and to the group page. What
+this file holds is the client half — that Search stops at the handshake ack for
+such a group, so nothing of its index is fetched, cached, merged or shown — and
+that the switch in Settings reaches the page that renders it.
+
+Source-reading, like the other Search tests: weak evidence, and the only kind
+available for the SPA. Each check is a one-line edit away from failing, which is
+what a source-reading test catches well.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+SEARCH_PAGE = STATIC / "search-page.js"
+TRANSPORT = STATIC / "transport.js"
+GROUP_PAGE = STATIC / "group-page.js"
+GROUP_SETTINGS = STATIC / "group-settings.js"
+
+pytestmark = pytest.mark.skipif(
+ not SEARCH_PAGE.exists(), reason="the SPA sources are not available")
+
+
+def _function(src: str, name: str) -> str:
+ m = re.search(r"^async function " + re.escape(name) + r"\(.*?^\}", src, re.M | re.S)
+ assert m, f"{name} is no longer where this test reads it"
+ return m.group(0)
+
+
+def test_search_stops_before_asking_for_the_index():
+ body = _function(SEARCH_PAGE.read_text(encoding="utf-8"), "fetchGroupIndex")
+ stop = body.find("ack.search_listed === false")
+ fetch = body.find("transport.fetchIndex()")
+ assert stop != -1, "fetchGroupIndex no longer reads search_listed off the ack"
+ assert fetch != -1
+ assert stop < fetch, (
+ "the unlisted check comes after the index is fetched — the index of a "
+ "group the operator left out of Search is then held by the page")
+
+
+def test_an_unlisted_group_is_neither_indexed_nor_cached_nor_unreachable():
+ body = _function(SEARCH_PAGE.read_text(encoding="utf-8"), "fetchAllIndexes")
+ branch = body[body.index("result.unlisted"):]
+ branch = branch[:branch.index("} else if (result)")]
+ assert "unlisted.push" in branch
+ for forbidden in ("results.set", "cacheGroupIndex", "unreachable.push"):
+ assert forbidden not in branch, (
+ f"an unlisted group reaches `{forbidden}` — it would be shown, "
+ "cached, or reported as down")
+
+
+def test_every_search_view_is_built_from_the_indexed_groups_only():
+ """
+ The four views read `indexedGroups`, which only `fetchAllIndexes`'s results
+ fill. If a view ever read the group list directly, an unlisted group would
+ come back through it.
+ """
+ src = SEARCH_PAGE.read_text(encoding="utf-8")
+ for memo in ("fileEntries", "videoEntries", "musicEntries", "photoEntries"):
+ m = re.search(r"^ const " + memo + r" = useMemo\(\(\) => \{.*?^ \}, \[",
+ src, re.M | re.S)
+ assert m, f"{memo} is no longer a useMemo"
+ assert "for (const [groupId, data] of indexedGroups)" in m.group(0)
+ assert "groups" not in m.group(0).replace("indexedGroups", ""), (
+ f"{memo} reads the raw group list")
+
+
+def test_the_operator_hears_back_from_their_own_change():
+ """Same failure as Chat's directory: an admin ack swallowed by its request."""
+ transport = TRANSPORT.read_text(encoding="utf-8")
+ block = transport[transport.index("const BROADCAST_ACK_TYPES"):]
+ assert "'search_listed_ack'" in block[:block.index("]);")]
+ replay = transport[transport.index("function _replayBroadcast"):]
+ replay = replay[:replay.index("\n}") + 2]
+ assert "_onSearchListed" in replay
+
+ page = GROUP_PAGE.read_text(encoding="utf-8")
+ assert "transport.onSearchListed = " in page
+ assert "setSearchListed(ack.search_listed !== false)" in page, (
+ "absent must read as listed, or every group on an older node vanishes "
+ "from Search")
+
+
+def test_the_switch_is_offered_to_the_operator_only():
+ settings = GROUP_SETTINGS.read_text(encoding="utf-8")
+ at = settings.index("settings_node.search_listed_title")
+ guard = settings.rfind("isNodeAdmin && connected", 0, at)
+ assert guard != -1 and at - guard < 600, (
+ "the Search listing switch is rendered outside the operator's section")
+ assert "transport.setSearchListed(next, adminSignFn)" in settings
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index c506ff1..ab942e4 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -506,3 +506,31 @@ def test_the_refusal_the_loop_keys_on_has_a_message(transport):
refusals = transport[transport.index("const HANDSHAKE_REFUSALS"):]
refusals = refusals[:refusals.index("};")]
assert "not_hosted:" in refusals
+
+
+# Search had the same `nodes[0]` twice — once to read a group's index, once for
+# the pooled connection its thumbnails and playback use — and was not part of
+# the fix above, so a group with a second, working node counted as unreachable
+# there while it opened fine from the sidebar.
+
+SEARCH_PAGE = STATIC / "search-page.js"
+
+
+def test_search_tries_every_node_the_hub_offers():
+ code = _code_only(SEARCH_PAGE.read_text(encoding="utf-8"))
+ assert "nodes[0]" not in code, "Search takes the head of the node list again"
+ walk = code[code.index("for (const n of nodesData.nodes)"):]
+ walk = walk[:walk.index("throw (lastErr")]
+ assert "not_hosted" in walk and "throw e" in walk, (
+ "the walk must stop for a refusal about this browser and move on "
+ "for one about this node")
+
+
+def test_search_connects_in_one_place():
+ """Two call sites with their own connect is how one of them kept `nodes[0]`."""
+ code = _code_only(SEARCH_PAGE.read_text(encoding="utf-8"))
+ assert code.count("transport.connect(") == 1
+ pool = code[code.index("async _doConnect("):code.index("_evict() {")]
+ index = code[code.index("async function fetchGroupIndex("):
+ code.index("async function fetchAllIndexes(")]
+ assert "connectToGroup(" in pool and "connectToGroup(" in index