summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
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
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')
-rw-r--r--packages/meshbay-hub/tests/test_account_deletion.py1
-rw-r--r--packages/meshbay-hub/tests/test_node_auth.py6
-rw-r--r--packages/meshbay-hub/tests/test_node_keys_several.py440
-rw-r--r--packages/meshbay-hub/tests/test_node_link_banner.py84
-rw-r--r--packages/meshbay-hub/tests/test_node_ws_auth.py8
-rw-r--r--packages/meshbay-hub/tests/test_sidebar_node_section.py4
6 files changed, 505 insertions, 38 deletions
diff --git a/packages/meshbay-hub/tests/test_account_deletion.py b/packages/meshbay-hub/tests/test_account_deletion.py
index 632c133..2673df6 100644
--- a/packages/meshbay-hub/tests/test_account_deletion.py
+++ b/packages/meshbay-hub/tests/test_account_deletion.py
@@ -49,7 +49,6 @@ async def test_owner_can_delete_their_account(client, db_session):
assert user.username.startswith("deleted-")
assert user.email == ""
assert user.pw_hash == b""
- assert user.pk_node_ed25519 is None
@pytest.mark.asyncio
diff --git a/packages/meshbay-hub/tests/test_node_auth.py b/packages/meshbay-hub/tests/test_node_auth.py
index fb05f9b..7b51d14 100644
--- a/packages/meshbay-hub/tests/test_node_auth.py
+++ b/packages/meshbay-hub/tests/test_node_auth.py
@@ -225,9 +225,11 @@ async def test_node_scope_allows_pubkey_lookup(client):
r = await client.get("/v1/users/op4_test/pubkeys",
headers={"Authorization": f"Bearer {node_token}"})
assert r.status_code == 200
- # An account id and the node's linking key — no user identity keys (H3).
+ # An account id and no user identity keys (H3). Its own latest node key, for
+ # the desktop applications already installed; nobody else sees it.
assert "pk_ed25519" not in r.json()
- assert r.json()["pk_node_ed25519"] is not None
+ assert r.json()["username"] == "op4_test"
+ assert r.json()["pk_node_ed25519"]
@pytest.mark.asyncio
diff --git a/packages/meshbay-hub/tests/test_node_keys_several.py b/packages/meshbay-hub/tests/test_node_keys_several.py
new file mode 100644
index 0000000..8334ff8
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_node_keys_several.py
@@ -0,0 +1,440 @@
+"""
+One account, several nodes: one linked key per machine the person runs a node on.
+
+The hub used to keep one node key per account, so linking a second machine
+unlinked the first, which then failed its next sign-in with nothing said. These
+pin the rules that replaced it: every linked key signs in, unlinking one cuts
+that machine off at once and no other, a node token can neither link nor unlink,
+and no other account learns which keys an account has.
+"""
+
+import base64
+import json
+import time
+from pathlib import Path
+
+import pytest
+from alembic import command
+from alembic.config import Config
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from sqlalchemy import create_engine, text
+
+HUB = Path(__file__).resolve().parents[1]
+
+
+def _keypair():
+ sk = Ed25519PrivateKey.generate()
+ pk = sk.public_key().public_bytes(serialization.Encoding.Raw,
+ serialization.PublicFormat.Raw)
+ return sk, base64.b64encode(pk).decode()
+
+
+def _h(token):
+ return {"Authorization": f"Bearer {token}"}
+
+
+async def _account(client, username):
+ _, pk_ed = _keypair()
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@test.local",
+ "password": "testpass99", "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_ed,
+ })
+ assert r.status_code == 201, r.text
+ r = await client.post("/v1/users/login", json={
+ "username": username, "password": "testpass99"})
+ assert r.status_code == 200, r.text
+ return r.json()["access_token"]
+
+
+async def _link(client, token, pk):
+ return await client.put("/v1/users/me/node_key", json={"pk_node_ed25519": pk},
+ headers=_h(token))
+
+
+async def _unlink(client, token, pk):
+ return await client.request("DELETE", "/v1/users/me/node_key",
+ json={"pk_node_ed25519": pk}, headers=_h(token))
+
+
+async def _node_auth(client, username, sk):
+ ts = int(time.time())
+ sig = sk.sign(f"meshbay:node_auth:{username}:{ts}".encode())
+ return await client.post("/v1/nodes/auth", json={
+ "username": username, "timestamp": ts,
+ "signature": base64.b64encode(sig).decode()})
+
+
+async def _announce(client, node_token, user_id, sk, pk):
+ ts = int(time.time())
+ sig = sk.sign(f"meshbay:node_announce:{user_id}:{pk}:{ts}".encode())
+ r = await client.post("/v1/nodes/announce", json={
+ "pk_node": pk, "timestamp": ts, "signature": base64.b64encode(sig).decode()},
+ headers=_h(node_token))
+ assert r.status_code == 201, r.text
+ return r.json()["node_id"]
+
+
+def _claims(token):
+ body = token.split(".")[1]
+ return json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))
+
+
+async def _two_nodes(client, username):
+ token = await _account(client, username)
+ sk_a, pk_a = _keypair()
+ sk_b, pk_b = _keypair()
+ assert (await _link(client, token, pk_a)).status_code == 200
+ assert (await _link(client, token, pk_b)).status_code == 200
+ return token, (sk_a, pk_a), (sk_b, pk_b)
+
+
+class _FakeSocket:
+ def __init__(self):
+ self.sent, self.closed = [], None
+
+ async def send_text(self, data):
+ self.sent.append(json.loads(data))
+
+ async def close(self, code=1000):
+ self.closed = code
+
+
+# ── Two machines, one account ────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_two_nodes_of_one_account_both_sign_in(client):
+ _, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "twonodes")
+
+ ra = await _node_auth(client, "twonodes", sk_a)
+ rb = await _node_auth(client, "twonodes", sk_b)
+ assert ra.status_code == 200, ra.text
+ assert rb.status_code == 200, rb.text
+ # Each token names the key that signed in, which is what unlinking cuts.
+ assert _claims(ra.json()["access_token"])["nk"] == pk_a
+ assert _claims(rb.json()["access_token"])["nk"] == pk_b
+
+
+@pytest.mark.asyncio
+async def test_linking_a_second_key_keeps_the_first(client):
+ token, (_, pk_a), (_, pk_b) = await _two_nodes(client, "keepsfirst")
+ r = await client.get("/v1/users/me/node_keys", headers=_h(token))
+ assert r.status_code == 200
+ assert [k["pk_node_ed25519"] for k in r.json()["keys"]] == [pk_a, pk_b]
+
+
+@pytest.mark.asyncio
+async def test_an_unlinked_key_is_told_to_link_not_to_register(client):
+ """The daemon reads "No node key" to wait for its link (`waiting_for_node_key`)."""
+ token = await _account(client, "notlinked")
+ _, pk_a = _keypair()
+ await _link(client, token, pk_a)
+ sk_other, _ = _keypair()
+ r = await _node_auth(client, "notlinked", sk_other)
+ assert r.status_code == 401
+ assert "No node key" in r.json()["detail"]
+
+
+@pytest.mark.asyncio
+async def test_linking_the_same_key_twice_changes_nothing(client):
+ token = await _account(client, "twice_test")
+ _, pk = _keypair()
+ assert (await _link(client, token, pk)).status_code == 200
+ assert (await _link(client, token, pk)).status_code == 200
+ r = await client.get("/v1/users/me/node_keys", headers=_h(token))
+ assert len(r.json()["keys"]) == 1
+
+
+@pytest.mark.asyncio
+async def test_a_key_is_stored_in_the_spelling_nodes_announce(client):
+ """Pasted with spaces around it, it must still match the node's own announce."""
+ token = await _account(client, "spelling")
+ _, pk = _keypair()
+ r = await _link(client, token, f" {pk}\n")
+ assert r.status_code == 200
+ assert r.json()["pk_node_ed25519"] == pk
+
+
+# ── Unlinking one machine ────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_unlinking_one_node_cuts_it_off_and_leaves_the_other(client):
+ token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "unlinkone")
+ tok_a = (await _node_auth(client, "unlinkone", sk_a)).json()["access_token"]
+ tok_b = (await _node_auth(client, "unlinkone", sk_b)).json()["access_token"]
+
+ r = await _unlink(client, token, pk_a)
+ assert r.status_code == 200, r.text
+
+ # A's token stops working now, not in an hour…
+ r = await client.get("/v1/users/unlinkone/pubkeys", headers=_h(tok_a))
+ assert r.status_code == 401
+ # …and A cannot sign in again until it is linked again.
+ r = await _node_auth(client, "unlinkone", sk_a)
+ assert r.status_code == 401
+ assert "No node key" in r.json()["detail"]
+
+ # B is untouched.
+ r = await client.get("/v1/users/unlinkone/pubkeys", headers=_h(tok_b))
+ assert r.status_code == 200
+ assert (await _node_auth(client, "unlinkone", sk_b)).status_code == 200
+
+ # Linking A again lets it back in.
+ assert (await _link(client, token, pk_a)).status_code == 200
+ assert (await _node_auth(client, "unlinkone", sk_a)).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_unlinking_closes_that_nodes_socket_only(client):
+ from meshbay_hub.api import revocation
+
+ token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "sockets_test")
+ me = (await client.get("/v1/users/sockets_test/pubkeys", headers=_h(token))).json()["user_id"]
+ tok_a = (await _node_auth(client, "sockets_test", sk_a)).json()["access_token"]
+ tok_b = (await _node_auth(client, "sockets_test", sk_b)).json()["access_token"]
+ id_a = await _announce(client, tok_a, me, sk_a, pk_a)
+ id_b = await _announce(client, tok_b, me, sk_b, pk_b)
+
+ ws_a, ws_b = _FakeSocket(), _FakeSocket()
+ revocation._connected_nodes[id_a] = ws_a
+ revocation._connected_nodes[id_b] = ws_b
+ try:
+ listed = (await client.get("/v1/users/me/node_keys", headers=_h(token))).json()
+ assert [k["connected"] for k in listed["keys"]] == [True, True]
+ assert all(k["last_seen"] for k in listed["keys"])
+
+ assert (await _unlink(client, token, pk_a)).status_code == 200
+ assert ws_a.closed == 4003
+ assert ws_b.closed is None
+ assert id_a not in revocation._connected_nodes
+ assert revocation._connected_nodes.get(id_b) is ws_b
+ finally:
+ revocation.forget_node(id_a)
+ revocation.forget_node(id_b)
+
+
+@pytest.mark.asyncio
+async def test_a_node_token_registers_its_own_node_only(client):
+ """A token names its key: it cannot register the socket of the account's other machine."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "ownnode_test")
+ me = (await client.get("/v1/users/ownnode_test/pubkeys", headers=_h(token))).json()["user_id"]
+ tok_a = (await _node_auth(client, "ownnode_test", sk_a)).json()["access_token"]
+ tok_b = (await _node_auth(client, "ownnode_test", sk_b)).json()["access_token"]
+ id_a = await _announce(client, tok_a, me, sk_a, pk_a)
+ id_b = await _announce(client, tok_b, me, sk_b, pk_b)
+
+ assert (await _authorize_node_ws(tok_a, id_a, []))[0] == id_a
+ assert (await _authorize_node_ws(tok_b, id_b, []))[0] == id_b
+ assert (await _authorize_node_ws(tok_a, id_b, []))[0] is None
+
+ await _unlink(client, token, pk_a)
+ refused, why = await _authorize_node_ws(tok_a, id_a, [])
+ assert refused is None and "no longer linked" in why
+ assert (await _authorize_node_ws(tok_b, id_b, []))[0] == id_b
+
+
+@pytest.mark.asyncio
+async def test_a_node_token_from_before_the_claim_still_works(client):
+ """A token without `nk` predates it; it expires within the hour on its own."""
+ from meshbay_hub.auth import issue_access_token
+
+ token = await _account(client, "legacy_test")
+ me = (await client.get("/v1/users/legacy_test/pubkeys", headers=_h(token))).json()["user_id"]
+ old = issue_access_token(me, scope="node")
+ r = await client.get("/v1/users/legacy_test/pubkeys", headers=_h(old))
+ assert r.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_a_token_from_before_the_claim_cannot_bring_an_unlinked_node_back(client):
+ """Found in the real run: the node held its pre-upgrade token and re-registered at once."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+ from meshbay_hub.auth import issue_access_token
+
+ token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "legacyws_test")
+ me = (await client.get("/v1/users/legacyws_test/pubkeys",
+ headers=_h(token))).json()["user_id"]
+ tok_a = (await _node_auth(client, "legacyws_test", sk_a)).json()["access_token"]
+ tok_b = (await _node_auth(client, "legacyws_test", sk_b)).json()["access_token"]
+ id_a = await _announce(client, tok_a, me, sk_a, pk_a)
+ id_b = await _announce(client, tok_b, me, sk_b, pk_b)
+ old = issue_access_token(me, scope="node")
+
+ assert (await _authorize_node_ws(old, id_a, []))[0] == id_a
+ await _unlink(client, token, pk_a)
+ refused, why = await _authorize_node_ws(old, id_a, [])
+ assert refused is None and "no longer linked" in why
+ assert (await _authorize_node_ws(old, id_b, []))[0] == id_b
+
+
+# ── Who may link, unlink and see ─────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_a_node_token_cannot_link_list_or_unlink_a_sibling(client):
+ token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "nodescope")
+ tok_a = (await _node_auth(client, "nodescope", sk_a)).json()["access_token"]
+ _, pk_evil = _keypair()
+ assert (await _link(client, tok_a, pk_evil)).status_code == 403
+ assert (await _unlink(client, tok_a, pk_b)).status_code == 403
+ r = await client.get("/v1/users/me/node_keys", headers=_h(tok_a))
+ assert r.status_code == 403
+ keys = (await client.get("/v1/users/me/node_keys", headers=_h(token))).json()["keys"]
+ assert [k["pk_node_ed25519"] for k in keys] == [pk_a, pk_b]
+
+
+@pytest.mark.asyncio
+async def test_a_node_may_unlink_itself(client):
+ """`meshbay-node setup --reset` and the Node page: the node lets go of its own key."""
+ token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "selfunlink")
+ tok_a = (await _node_auth(client, "selfunlink", sk_a)).json()["access_token"]
+ tok_b = (await _node_auth(client, "selfunlink", sk_b)).json()["access_token"]
+
+ assert (await _unlink(client, tok_a, pk_a)).status_code == 200
+ assert (await _node_auth(client, "selfunlink", sk_a)).status_code == 401
+
+ # A node from before the key was named in the request asks with no body.
+ r = await client.request("DELETE", "/v1/users/me/node_key", headers=_h(tok_b))
+ assert r.status_code == 200, r.text
+ assert r.json()["pk_node_ed25519"] == pk_b
+ keys = (await client.get("/v1/users/me/node_keys", headers=_h(token))).json()["keys"]
+ assert keys == []
+
+
+@pytest.mark.asyncio
+async def test_a_node_token_without_its_key_cannot_unlink(client):
+ from meshbay_hub.auth import issue_access_token
+
+ token, (_, pk_a), _ = await _two_nodes(client, "legacyunl")
+ me = (await client.get("/v1/users/legacyunl/pubkeys", headers=_h(token))).json()["user_id"]
+ old = issue_access_token(me, scope="node")
+ assert (await _unlink(client, old, pk_a)).status_code == 403
+ r = await client.request("DELETE", "/v1/users/me/node_key", headers=_h(old))
+ assert r.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_another_account_cannot_unlink_or_see_my_keys(client):
+ token, (sk_a, pk_a), _ = await _two_nodes(client, "victim_test")
+ mallory = await _account(client, "mallory_test")
+
+ r = await _unlink(client, mallory, pk_a)
+ assert r.status_code == 404
+ assert (await _node_auth(client, "victim_test", sk_a)).status_code == 200
+
+ r = await client.get("/v1/users/victim_test/pubkeys", headers=_h(mallory))
+ assert r.status_code == 200
+ assert "pk_node_ed25519" not in r.json()
+ assert "keys" not in r.json()
+ mine = (await client.get("/v1/users/me/node_keys", headers=_h(mallory))).json()
+ assert mine["keys"] == []
+
+
+@pytest.mark.asyncio
+async def test_installed_desktop_apps_still_see_their_own_latest_key(client):
+ """Their interface shows the Node entries from `/pubkeys`; only for yourself."""
+ token, (_, pk_a), (_, pk_b) = await _two_nodes(client, "compatapp_test")
+ r = await client.get("/v1/users/compatapp_test/pubkeys", headers=_h(token))
+ assert r.json()["pk_node_ed25519"] == pk_b
+ await _unlink(client, token, pk_b)
+ r = await client.get("/v1/users/compatapp_test/pubkeys", headers=_h(token))
+ assert r.json()["pk_node_ed25519"] == pk_a
+ await _unlink(client, token, pk_a)
+ r = await client.get("/v1/users/compatapp_test/pubkeys", headers=_h(token))
+ assert "pk_node_ed25519" not in r.json()
+
+
+@pytest.mark.asyncio
+async def test_the_same_key_on_another_account_signs_in_only_as_that_account(client):
+ """Linking someone's public key gains nothing: the signature names the username."""
+ alice = await _account(client, "keyowner")
+ bob = await _account(client, "keycopier")
+ sk, pk = _keypair()
+ await _link(client, alice, pk)
+ await _link(client, bob, pk)
+ await _unlink(client, bob, pk)
+ assert (await _node_auth(client, "keyowner", sk)).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_unlinking_a_key_that_is_not_linked_says_so(client):
+ token = await _account(client, "nothing_test")
+ _, pk = _keypair()
+ assert (await _unlink(client, token, pk)).status_code == 404
+ r = await client.request("DELETE", "/v1/users/me/node_key", headers=_h(token))
+ assert r.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_the_limit_is_the_announce_limit(client):
+ from meshbay_hub.api.nodes import MAX_NODES_PER_ACCOUNT
+
+ token = await _account(client, "manynodes")
+ keys = [_keypair()[1] for _ in range(MAX_NODES_PER_ACCOUNT)]
+ for pk in keys:
+ assert (await _link(client, token, pk)).status_code == 200
+ r = await _link(client, token, _keypair()[1])
+ assert r.status_code == 409
+ assert "Unlink one" in r.json()["detail"]
+ # Linking one already there still answers, at the limit.
+ assert (await _link(client, token, keys[0])).status_code == 200
+ listed = (await client.get("/v1/users/me/node_keys", headers=_h(token))).json()
+ assert listed["limit"] == MAX_NODES_PER_ACCOUNT
+ assert len(listed["keys"]) == MAX_NODES_PER_ACCOUNT
+
+
+@pytest.mark.asyncio
+async def test_deleting_the_account_unlinks_every_key(client, db_session):
+ import hashlib
+
+ from meshbay_hub.db.models import NodeKey
+ from sqlalchemy import select
+
+ name, passphrase = "goneacct_test", "a-long-enough-passphrase"
+ salt = hashlib.sha256(f"meshbay:auth:v1:{name}".encode()).digest()
+ auth_key = base64.b64encode(
+ hashlib.pbkdf2_hmac("sha512", passphrase.encode(), salt, 600_000, 32)).decode()
+ r = await client.post("/v1/users/register", json={
+ "username": name, "email": f"{name}@example.com", "auth_key": auth_key})
+ assert r.status_code in (200, 201), r.text
+ token = (await client.post("/v1/users/login", json={
+ "username": name, "auth_key": auth_key})).json()["access_token"]
+ sk_a, pk_a = _keypair()
+ await _link(client, token, pk_a)
+ await _link(client, token, _keypair()[1])
+
+ r = await client.request("DELETE", "/v1/users/me", headers=_h(token),
+ json={"auth_key": auth_key})
+ assert r.status_code == 200, r.text
+ left = (await db_session.execute(select(NodeKey))).scalars().all()
+ assert left == []
+ assert (await _node_auth(client, name, sk_a)).status_code == 401
+
+
+# ── The upgrade ──────────────────────────────────────────────────────────────
+
+def test_the_upgrade_keeps_the_key_each_account_had_linked(tmp_path, monkeypatch):
+ db = tmp_path / "hub.db"
+ monkeypatch.setenv("MESHBAY_DATABASE_URL", f"sqlite+aiosqlite:///{db}")
+ cfg = Config(str(HUB / "alembic.ini"))
+ command.upgrade(cfg, "e7f8a9b0c1d2")
+
+ engine = create_engine(f"sqlite:///{db}")
+ with engine.begin() as conn:
+ for uid, pk in (("u-linked", "PKLINKED"), ("u-none", None)):
+ conn.execute(text(
+ "INSERT INTO users (id, username, email, pw_hash, pw_salt, hub_id, "
+ "pk_node_ed25519) VALUES (:id, :id, '', x'00', x'00', 'h', :pk)"),
+ {"id": uid, "pk": pk})
+
+ command.upgrade(cfg, "f8a9b0c1d2e3")
+ with engine.begin() as conn:
+ rows = conn.execute(text(
+ "SELECT user_id, pk_node_ed25519 FROM node_keys")).fetchall()
+ assert rows == [("u-linked", "PKLINKED")]
+
+ command.downgrade(cfg, "e7f8a9b0c1d2")
+ with engine.begin() as conn:
+ back = dict(conn.execute(text("SELECT id, pk_node_ed25519 FROM users")).fetchall())
+ assert back == {"u-linked": "PKLINKED", "u-none": None}
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
diff --git a/packages/meshbay-hub/tests/test_node_ws_auth.py b/packages/meshbay-hub/tests/test_node_ws_auth.py
index 7d96851..ca2cb25 100644
--- a/packages/meshbay-hub/tests/test_node_ws_auth.py
+++ b/packages/meshbay-hub/tests/test_node_ws_auth.py
@@ -44,6 +44,11 @@ async def _make_user(client, username: str) -> dict:
async def _announce_node(client, user: dict) -> str:
+ # A node only ever reaches the socket with its key linked to the account:
+ # `/v1/nodes/auth` issues no token otherwise.
+ r = await client.put("/v1/users/me/node_key", json={"pk_node_ed25519": user["pk_ed"]},
+ headers={"Authorization": f"Bearer {user['token']}"})
+ assert r.status_code == 200, r.text
# Announce now requires proof of possession of the node key (M8).
import time as _t
ts = int(_t.time())
@@ -62,8 +67,9 @@ async def _announce_node(client, user: dict) -> str:
def _node_token(user: dict) -> str:
+ """What `/v1/nodes/auth` issues: a node token naming the key that signed in."""
from meshbay_hub.auth import issue_access_token
- return issue_access_token(user["user_id"], scope="node")
+ return issue_access_token(user["user_id"], scope="node", node_key=user["pk_ed"])
@pytest.mark.asyncio
diff --git a/packages/meshbay-hub/tests/test_sidebar_node_section.py b/packages/meshbay-hub/tests/test_sidebar_node_section.py
index 57c9e9e..c8e44ca 100644
--- a/packages/meshbay-hub/tests/test_sidebar_node_section.py
+++ b/packages/meshbay-hub/tests/test_sidebar_node_section.py
@@ -44,7 +44,7 @@ def test_the_wizard_says_so_after_each_way_it_links_one():
def test_one_failed_read_does_not_hide_the_section_for_good():
refresh = _body(APP, "const refreshNodeKey = useCallback(", "}, [user]);")
- assert "/pubkeys" in refresh
+ assert "linkedNodeKeys(user.token)" in refresh
assert not re.search(r"\.catch\(\(\)\s*=>\s*\{\s*\}\)", refresh), (
"a swallowed failure leaves hasNodeKey false until a reload")
assert "setTimeout(" in refresh, "a failed read is tried again"
@@ -53,4 +53,4 @@ def test_one_failed_read_does_not_hide_the_section_for_good():
def test_the_session_effect_uses_the_same_read():
- assert APP.count("/pubkeys`") == 1, "one place decides hasNodeKey"
+ assert APP.count("linkedNodeKeys(") == 1, "one place decides hasNodeKey"