diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 09:33:28 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 09:33:28 +0200 |
| commit | 1e8eae1f3de765fcd8163716de6e5fe5aab5a034 (patch) | |
| tree | dfdb004229c8b0f57ea1a64a726ed2752c3d18e5 /packages/meshbay-hub/tests/test_node_link_banner.py | |
| parent | adda9613fad48a68239e2dbd93321818a835c286 (diff) | |
| download | meshbay-1e8eae1f3de765fcd8163716de6e5fe5aab5a034.tar.gz | |
feat(hub): one account, several linked nodes
An account links up to MAX_NODES_PER_ACCOUNT node keys (node_keys table)
instead of the one users.pk_node_ed25519; the migration carries each
account's key over and the downgrade keeps the last one linked.
/v1/nodes/auth accepts any linked key, and its token names the key that
won it (nk). PUT /me/node_key adds a key, idempotently, 409 past the
limit; DELETE /me/node_key names the key: a browser session any of its
own, a node only its own. Unlinking a key closes that node's socket and
refuses its tokens at once, on REST and on the socket; the account's other
nodes are untouched. /pubkeys no longer gives a node key to other accounts,
and gives the caller its last linked one, which installed desktop apps read.
The node reports waiting_for_node_key while its key is unlinked and signs
in again as soon as the hub says so, so a key linked again is back within
seconds; unlinking itself signs in first to hold a token naming its key.
Profile -> Linked nodes lists each node (connected, last seen, IP, linked
since, This machine in the desktop app) with Copy and Unlink, and adds a
key by hand. The Node page banner, Create group and the desktop app add
this machine's key instead of replacing another's. Messages that pointed
at Settings -> Link Node point at Profile -> Linked nodes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_node_link_banner.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_node_link_banner.py | 84 |
1 files changed, 52 insertions, 32 deletions
diff --git a/packages/meshbay-hub/tests/test_node_link_banner.py b/packages/meshbay-hub/tests/test_node_link_banner.py index eef29bf..b043a5b 100644 --- a/packages/meshbay-hub/tests/test_node_link_banner.py +++ b/packages/meshbay-hub/tests/test_node_link_banner.py @@ -1,13 +1,13 @@ """ A node that cannot serve the signed-in account says so, and the account can -let go of a node from anywhere. +let go of any of its nodes from anywhere. -Signing in on a desktop links this machine's node to the account (main.js -`ensureNode`), but never over a key already linked -- that would cut off the -user's other machine -- nor 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. +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 @@ -33,13 +33,15 @@ def test_the_problem_is_worked_out_from_the_node_and_the_hub(tmp_path): 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, None, "alice", "https://hub.example"], # nothing linked: sign-in links it - [mine, "K2", "alice", "https://hub.example"], # another node's key - [{**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 + [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( @@ -49,7 +51,8 @@ def test_the_problem_is_worked_out_from_the_node_and_the_hub(tmp_path): encoding="utf-8") out = subprocess.run(["node", str(script)], capture_output=True, encoding="utf-8", check=True) assert json.loads(out.stdout) == [ - None, None, "other_node", "other_account", "other_account", "other_node", None] + 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(): @@ -58,18 +61,17 @@ def test_the_node_page_shows_the_banner_and_offers_the_link(): 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 "hubFetch(`/v1/users/${encodeURIComponent(username)}/pubkeys`" in banner - assert "nodeLinkProblem(info, linkedKey, username, HUB)" in banner + 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] - # Replacing another machine's link is asked first, then this node's key - # replaces it directly: node:start left a node answering "running" alone, - # and a node signed in before the account was linked elsewhere does - # (found clicking it on a real install). For another account node:start - # asks itself before switching, and takeOver gets past "running". - assert "problem === 'other_node' && !await ask(t('node.link_here_confirm'))" in take_over + # 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_other_node", "node.link_other_account", "node.link_here", + 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 @@ -82,12 +84,30 @@ def test_node_start_switches_a_running_node_when_asked_to_take_it_over(): assert "already.status === 'running' && !(opts && opts.takeOver)" in start -def test_the_profile_page_can_unlink_the_node(): +def test_the_profile_page_lists_every_node_and_unlinks_one(): src = _src(PROFILE_PAGE) - unlink = src.split("const unlinkNodeKey = useCallback(async () => {", 1)[1].split("}, [", 1)[0] - assert "if (!await ask(t('settings.node_key_unlink_confirm'))) return;" in unlink - assert "hubFetch('/v1/users/me/node_key', { method: 'DELETE', token: user.token })" in unlink - assert "setCurrentNodeKey(null)" in unlink - # Offered only where there is a key to let go of. - shown = src.split("${currentNodeKey && html`", 1)[1].split("`}", 1)[0] - assert "onClick=${unlinkNodeKey}" in shown + 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 |