summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
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