""" A node that cannot serve the signed-in account says so, and the account can let go of any of its nodes from anywhere. An account links one key per machine it runs a node on. Signing in on a desktop links this machine's node (main.js `ensureNode`) beside the others, never a node set up for another account. Found on a real install (2026-10-10): the node read "Running" while the hub refused it in a loop, and nothing said why. And the only way to unlink was the linked machine's own Node page, of no use once that machine is gone -- the Profile page lists every linked node. """ import json import re import shutil import subprocess from pathlib import Path import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" NODE_PAGE = STATIC / "node-page.js" PROFILE_PAGE = STATIC / "profile-page.js" def _src(path: Path) -> str: return path.read_text(encoding="utf-8") @pytest.mark.skipif(shutil.which("node") is None, reason="node is not available") def test_the_problem_is_worked_out_from_the_node_and_the_hub(tmp_path): m = re.search(r"^export function nodeLinkProblem\(.*?^\}", _src(NODE_PAGE), re.M | re.S) assert m, "node-page.js no longer has nodeLinkProblem" mine = {"username": "alice", "hub_url": "https://hub.example/", "pk_node_ed25519": "K1"} cases = [ [mine, ["K1"], "alice", "https://hub.example"], # linked here: fine [mine, ["K2", "K1"], "alice", "https://hub.example"], # linked beside another: fine [mine, None, "alice", "https://hub.example"], # hub not answered yet [mine, [], "alice", "https://hub.example"], # nothing linked [mine, ["K2"], "alice", "https://hub.example"], # only another machine linked [{**mine, "username": "bob"}, ["K1"], "alice", "https://hub.example"], [mine, ["K1"], "alice", "https://other.example"], # set up for another hub [mine, ["K2"], "alice", ""], # browser: same-origin hub [None, ["K2"], "alice", "https://hub.example"], # node not answering ] script = tmp_path / "case.js" script.write_text( m.group(0).replace("export ", "") + f"\nconsole.log(JSON.stringify({json.dumps(cases)}" ".map(c => nodeLinkProblem(...c))));", encoding="utf-8") out = subprocess.run(["node", str(script)], capture_output=True, encoding="utf-8", check=True) assert json.loads(out.stdout) == [ None, None, None, "not_linked", "not_linked", "other_account", "other_account", "not_linked", None] def test_the_node_page_shows_the_banner_and_offers_the_link(): src = _src(NODE_PAGE) page = src.split("export function NodePage(", 1)[1] assert "<${NodeLinkBanner} info=${nodeInfo} token=${token} username=${username}" in page banner = src.split("function NodeLinkBanner(", 1)[1].split("\nfunction ", 1)[0] # Asked of the hub, not remembered from sign-in. assert "linkedNodeKeys(token)" in banner assert "nodeLinkProblem(info, linkedKeys, username, HUB)" in banner take_over = banner.split("const takeOver = async () => {", 1)[1].split("\n };", 1)[0] # This node's key is added beside the account's others, so nothing is cut # off and nothing needs asking. node:start left a node answering "running" # alone (found clicking it on a real install). For another account # node:start asks itself before switching, and takeOver gets past "running". assert "ask(" not in take_over assert "method: 'PUT', token, body: { pk_node_ed25519: nodeKey }" in take_over assert "platform.node.start({ hubUrl: HUB, username, token, takeOver: true })" in take_over for key in ("node.link_not_linked", "node.link_other_account", "node.link_here", "node.link_take_over", "node.link_linking"): assert f"'{key}'" in banner, key def test_node_start_switches_a_running_node_when_asked_to_take_it_over(): main_js = (Path(__file__).resolve().parents[3] / "packages" / "meshbay-client" / "src" / "main.js").read_text(encoding="utf-8") start = main_js.split("handle('node:start', async (_e, opts) => {", 1)[1] start = start.split("\n });\n", 1)[0] assert "already.status === 'running' && !(opts && opts.takeOver)" in start def test_the_profile_page_lists_every_node_and_unlinks_one(): src = _src(PROFILE_PAGE) assert "linkedNodeKeys(user.token)" in src unlink = src.split("const unlinkNodeKey = useCallback(async (pk) => {", 1)[1] unlink = unlink.split("}, [", 1)[0] assert "await ask(t('settings.node_key_unlink_confirm', { key: shortNodeKey(pk) }))" in unlink # The one named, never the account's whole set. assert "method: 'DELETE', token: user.token, body: { pk_node_ed25519: pk }" in unlink assert "filter(k => k.pk_node_ed25519 !== pk)" in unlink row = src.split("${nodeKeys.map(k => html`", 1)[1].split("`)}", 1)[0] assert "onClick=${() => unlinkNodeKey(k.pk_node_ed25519)}" in row assert "settings.node_keys_this_machine" in row def test_the_profile_page_says_nothing_before_the_hub_answers(): src = _src(PROFILE_PAGE) assert "const [nodeKeys, setNodeKeys] = useState(null);" in src assert "${nodeKeys !== null && nodeKeys.length === 0 && html`" in src def test_the_desktop_app_links_beside_other_machines(): main_js = (Path(__file__).resolve().parents[3] / "packages" / "meshbay-client" / "src" / "main.js").read_text(encoding="utf-8") ensure = main_js.split("async function ensureNode(opts) {", 1)[1].split("\n }\n", 1)[0] assert "other_node" not in ensure assert "/pubkeys" not in ensure assert "linkNodeKeyAndAwaitRunning(" in ensure