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 | |
| 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')
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" |