diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/test_app_settings_plugin.py | 5 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_search_unlisted.py | 96 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_transport_contracts.py | 28 |
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 |