summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_node_link_banner.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 09:33:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 09:33:28 +0200
commit1e8eae1f3de765fcd8163716de6e5fe5aab5a034 (patch)
treedfdb004229c8b0f57ea1a64a726ed2752c3d18e5 /packages/meshbay-hub/tests/test_node_link_banner.py
parentadda9613fad48a68239e2dbd93321818a835c286 (diff)
downloadmeshbay-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.py84
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