1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
|
"""
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("</li>`)}", 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
|