diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
19 files changed, 200 insertions, 30 deletions
diff --git a/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs b/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs index 74b4bb3..c05e23b 100644 --- a/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs +++ b/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs @@ -78,7 +78,6 @@ const shown = []; // [{ at, groups }] — one entry per render the reader gets const session = { bundleKey: 'k' }; const _loadBundleKey = async () => 'k'; -const cacheGroupIndex = () => {}; // One group's index, answered on the clock rather than over a network. const fetchGroupIndex = (groupId) => new Promise((resolve, reject) => { @@ -106,7 +105,7 @@ const lift = (signature) => { }; const make = new Function( - 'session', '_loadBundleKey', 'cacheGroupIndex', 'fetchGroupIndex', 'localStorage', + 'session', '_loadBundleKey', 'fetchGroupIndex', 'localStorage', `const MAX_IN_FLIGHT = ${ceiling[1]}; const DOWN_KEY = 'harness'; ${lift('function lastKnownDown(')} @@ -116,7 +115,7 @@ const make = new Function( return fetchAllIndexes;`, ); const fetchAllIndexes = make( - session, _loadBundleKey, cacheGroupIndex, fetchGroupIndex, globalThis.localStorage); + session, _loadBundleKey, fetchGroupIndex, globalThis.localStorage); // ── The scenario ───────────────────────────────────────────────────────────── diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py index d1a4dcb..2be7c03 100644 --- a/packages/meshbay-hub/tests/test_availability_between_members.py +++ b/packages/meshbay-hub/tests/test_availability_between_members.py @@ -718,3 +718,70 @@ async def test_a_private_groups_node_list_is_for_its_members(client): finally: rev._connected_nodes.pop(node_id, None) rev._node_groups.pop(node_id, None) + + +async def _announce_key(client, user: dict, sk) -> int: + """Announce a *distinct* node key, and return the status code.""" + from meshbay_common.crypto import pk_to_b64 + + pk = pk_to_b64(sk.public_key()) + ts = int(time.time()) + msg = f"meshbay:node_announce:{user['user_id']}:{pk}:{ts}".encode() + r = await client.post("/v1/nodes/announce", json={ + "pk_node": pk, "endpoint_hint": "test", "timestamp": ts, + "signature": base64.b64encode(sk.sign(msg)).decode(), + }, headers={"Authorization": f"Bearer {user['token']}"}) + return r.status_code + + +async def test_one_account_cannot_announce_unlimited_nodes(client, monkeypatch): + """ + Each new node key is a row in `nodes` and a row in the IP log, and the IP log + is kept for a year. Proof of possession (M8) settles *whose* key it is and + says nothing about how many: an account in a loop wrote a year of storage on + the operator's disk having paid only for signatures. + + Two accounts, because the ceiling has to be per account. One that is shared + would let a single member deny every other member the ability to bring a + machine online, which is the same defect with better manners. + """ + from meshbay_hub.api import nodes as nodes_api + + monkeypatch.setattr(nodes_api, "MAX_NODES_PER_ACCOUNT", 3) + alice = await _make_user(client, "av_nodecap_alice") + bob = await _make_user(client, "av_nodecap_bob") + + keys = [Ed25519PrivateKey.generate() for _ in range(4)] + for sk in keys[:3]: + assert await _announce_key(client, alice, sk) == 201 + + assert await _announce_key(client, alice, keys[3]) == 409, ( + "an account announced past the ceiling") + + # Bob has announced nothing and must be unaffected. + assert await _announce_key(client, bob, Ed25519PrivateKey.generate()) == 201, ( + "one account's ceiling was charged to another's" + ) + + +async def test_a_node_at_the_ceiling_can_still_refresh_its_address(client, monkeypatch): + """ + The ceiling counts rows, so it must be checked only where a row is added. + Applied to every announce, it would freeze the address of every node an + account already runs the moment it reached the limit — and a node that + cannot re-announce is a node nobody can reach after their ISP renumbers + them, which is an outage caused by the protection. + """ + from meshbay_hub.api import nodes as nodes_api + + monkeypatch.setattr(nodes_api, "MAX_NODES_PER_ACCOUNT", 2) + alice = await _make_user(client, "av_nodecap_refresh") + + keys = [Ed25519PrivateKey.generate() for _ in range(2)] + for sk in keys: + assert await _announce_key(client, alice, sk) == 201 + assert await _announce_key(client, alice, Ed25519PrivateKey.generate()) == 409 + + for sk in keys: + assert await _announce_key(client, alice, sk) == 201, ( + "a node already known could not re-announce at the ceiling") diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py index 5862ac3..fcf6c4d 100644 --- a/packages/meshbay-hub/tests/test_desktop_shell.py +++ b/packages/meshbay-hub/tests/test_desktop_shell.py @@ -8,8 +8,8 @@ the design depends on are present in the source, and it fails if one is removed — which is the same treatment `test_downloads.py` gives the three browser-specific save paths, for the same reason. -Every assertion here corresponds to a sentence in `docs/desktop-client-v1.md` -§3. Weak evidence, and the only evidence available without a packaged build; a +Every assertion here corresponds to a sentence in `docs/MESHBAY_DESIGN.md` +§8.2. Weak evidence, and the only evidence available without a packaged build; a person with an installed client is what confirms the rest. """ @@ -370,9 +370,10 @@ def test_plain_http_is_refused_except_to_loopback(): def test_the_interface_is_copied_not_forked(): """ - §2.7: the hub's static directory is the single source. A silent fork is the - only real way to end up maintaining the interface twice, so the copy is - generated and the generated tree is not committed. + docs/MESHBAY_DESIGN.md §8.3: the hub's static directory is the single + source. A silent fork is the only real way to end up maintaining the + interface twice, so the copy is generated and the generated tree is not + committed. """ sync = (CLIENT / "scripts" / "sync-ui.js").read_text(encoding="utf-8") assert "meshbay-hub" in sync and "static" in sync diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py index afb85d6..395053b 100644 --- a/packages/meshbay-hub/tests/test_downloads.py +++ b/packages/meshbay-hub/tests/test_downloads.py @@ -146,9 +146,9 @@ def test_a_length_is_only_promised_when_it_is_known(tmp_path): assert "if (entry.size > 0)" in src # The zip-directory download started in files-app.js (group-page refactor) - # and was lifted into file-utils.js's downloadDirectory (docs/photos.md - # §3) so photos-app.js's own "zip this album" button calls the same - # implementation rather than a second one. + # and was lifted into file-utils.js's downloadDirectory + # (docs/MESHBAY_DESIGN.md §9.9) so photos-app.js's own "zip this album" + # button calls the same implementation rather than a second one. # Anchored on the call, not on how its result is bound: the assignment # became a bare `target = ...` inside a try when _openDownloadTarget gained # the ability to refuse an oversized download (test_memory_ceiling.py). diff --git a/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py b/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py index a1baf92..451b57b 100644 --- a/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py +++ b/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py @@ -1,7 +1,7 @@ """ The reference application, and what it is for. -`docs/refactor-groups.md` claims that adding an application costs a registry +`docs/MESHBAY_DESIGN.md` §9.4 claims that adding an application costs a registry entry and the app's own files — no op, no MNP message, no route, no edit to the pages that render it. Every other test of that claim reads source for the *absence* of app names, which proves nobody wrote a special case for Videos. It diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py index 78561b6..3c82bb0 100644 --- a/packages/meshbay-hub/tests/test_hook_ordering.py +++ b/packages/meshbay-hub/tests/test_hook_ordering.py @@ -37,7 +37,7 @@ STATIC_FILES = [ "video-player.js", "video-app.js", "music-app.js", "music-player.js", "photos-app.js", "pager.js", "group-settings.js", - # The per-app settings architecture (docs/refactor-groups.md §3). Reached + # The per-app settings architecture (docs/MESHBAY_DESIGN.md §9.4). Reached # through the apps.js registry rather than imported by name, so a file # left out of this list is one nothing checks — the failure is silent. "settings-ui.js", "folder-tree.js", diff --git a/packages/meshbay-hub/tests/test_no_index_cache.py b/packages/meshbay-hub/tests/test_no_index_cache.py new file mode 100644 index 0000000..c39bf00 --- /dev/null +++ b/packages/meshbay-hub/tests/test_no_index_cache.py @@ -0,0 +1,98 @@ +""" +A group's index is never written to browser storage. + +`group_indexes` was an IndexedDB store holding a decrypted copy of every group's +index — each file's name, path, size, hash and uploader — written on every index +and every delta. The cross-group search of the time read it instead of dialling +anything, which is what it was for. + +Search has dialled the nodes since 2026-08-28. That change removed the reader and +kept the writers, so the browser went on building a cleartext file listing that +nothing consulted, that no sign-out removed (the key database is a different +one), and that grew with every group ever opened. **L7**, at rest. + +Showing a group's files while its node is unreachable is the only thing such a +cache buys, and it is not wanted: a listing you cannot open is worse than an +honest absence. So there is nothing left to read it with, and these tests keep it +that way — a writer reintroduced without a reader would be invisible again, and +the second time it would be invisible for the same reason as the first. +""" + +import re +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +HUB_CLIENT = STATIC / "hub-client.js" +APP = STATIC / "app.js" + +# The store name, read from the source rather than written down here: renaming it +# must not quietly take these tests out of the picture. +STORE = re.search(r"const IDB_STORE = '([^']+)';", + HUB_CLIENT.read_text(encoding="utf-8")).group(1) + + +def _functions(src: str) -> dict[str, str]: + """Every top-level function in a module, by name.""" + out = {} + starts = [(m.start(), m.group(1)) for m in + re.finditer(r"^(?:async )?function (\w+)\(", src, re.M)] + for i, (at, name) in enumerate(starts): + end = starts[i + 1][0] if i + 1 < len(starts) else len(src) + out[name] = src[at:end] + return out + + +def test_only_the_purge_touches_the_old_store(): + """ + Creating it and emptying it, and nothing else. + + `openDB` still creates the store because dropping it needs a version bump, + and a version bump is an upgrade another tab can block — which would take + playlists down with it, since they share this database. An empty store costs + nothing; the point is that nothing writes to it. + """ + fns = _functions(HUB_CLIENT.read_text(encoding="utf-8")) + touching = sorted(n for n, body in fns.items() if "IDB_STORE" in body) + assert touching == ["openDB", "purgeGroupIndexCache"], ( + f"{touching} touch the {STORE!r} store; only creating and emptying it " + "are allowed, and a write to it is a file listing kept on disk that " + "nothing will ever read") + + +def test_nothing_writes_a_group_index_to_the_store(): + """Stated on the operation rather than on the callers, so a new one is caught.""" + fns = _functions(HUB_CLIENT.read_text(encoding="utf-8")) + purge = fns["purgeGroupIndexCache"] + assert ".clear()" in purge + for write in (".put(", ".add(", ".putAll("): + assert write not in purge, f"the purge does a {write} — it must only clear" + + +def test_no_module_carries_a_cache_writer_any_more(): + """ + The functions are gone, so the way this comes back is a new one. Any export + of hub-client.js whose name is about caching an index is refused here rather + than discovered months later with a store full of filenames. + """ + src = HUB_CLIENT.read_text(encoding="utf-8") + for name in re.findall(r"^(?:async )?function (\w+)\(", src, re.M): + assert not re.search(r"cache.*index|index.*cache", name, re.I) \ + or name == "purgeGroupIndexCache", ( + f"{name} looks like an index cache again — the store it would write " + "to has no reader, and adding one was decided against") + + +def test_the_purge_is_actually_called(): + """ + The defect being cleaned up was a function nobody called. A purge nobody + calls is the same defect wearing the opposite hat: the data stays on every + machine that already has it, and nothing says so. + """ + app = APP.read_text(encoding="utf-8") + assert "purgeGroupIndexCache" in app, "app.js no longer imports the purge" + call = re.search(r"purgeGroupIndexCache\(\)", app) + assert call, "the purge is imported and never called" + mount = app.index("const mount = () => {") + assert "purgeOnce()" in app[mount:mount + 400], ( + "the purge is no longer run at start-up, so a browser that still holds " + "the old store keeps it") diff --git a/packages/meshbay-hub/tests/test_password_change.py b/packages/meshbay-hub/tests/test_password_change.py index 4d2f303..b2fe586 100644 --- a/packages/meshbay-hub/tests/test_password_change.py +++ b/packages/meshbay-hub/tests/test_password_change.py @@ -1,5 +1,5 @@ """ -Passphrase change — Flow A of docs/auth-confirm.md. +Passphrase change — Flow A of docs/MESHBAY_DESIGN.md §3.6. The hub's part is small: re-prove the current passphrase, swap the auth_key verifier, invalidate every other session, keep the caller's. The re-wrapping of diff --git a/packages/meshbay-hub/tests/test_password_reset.py b/packages/meshbay-hub/tests/test_password_reset.py index 1273315..823807c 100644 --- a/packages/meshbay-hub/tests/test_password_reset.py +++ b/packages/meshbay-hub/tests/test_password_reset.py @@ -1,5 +1,5 @@ """ -Passphrase reset by e-mail code — Flow B of docs/auth-confirm.md §4.2. +Passphrase reset by e-mail code — Flow B of docs/MESHBAY_DESIGN.md §3.6. The hub's part re-opens sign-in only: it swaps the auth_key verifier, kills every session, and drops every registered device key so a stored one cannot diff --git a/packages/meshbay-hub/tests/test_recovery_email.py b/packages/meshbay-hub/tests/test_recovery_email.py index 07880d0..96dde5d 100644 --- a/packages/meshbay-hub/tests/test_recovery_email.py +++ b/packages/meshbay-hub/tests/test_recovery_email.py @@ -1,5 +1,5 @@ """ -The recovery key in the registration e-mail (docs/auth-confirm.md §4.4). +The recovery key in the registration e-mail (docs/MESHBAY_DESIGN.md §3.6). When the client sends `recovery_key`, the hub appends it to the verification e-mail and stores it nowhere. When it does not, the e-mail carries only the diff --git a/packages/meshbay-hub/tests/test_recovery_key.py b/packages/meshbay-hub/tests/test_recovery_key.py index 378758a..54415f6 100644 --- a/packages/meshbay-hub/tests/test_recovery_key.py +++ b/packages/meshbay-hub/tests/test_recovery_key.py @@ -1,5 +1,5 @@ """ -The account recovery key (docs/auth-confirm.md §4.3). +The account recovery key (docs/MESHBAY_DESIGN.md §3.6). `generateRecoveryKey` / `deriveRecoveryKey` in keyderive.js are run here under node against the real WebCrypto, rather than reimplemented: the mnemonic has to diff --git a/packages/meshbay-hub/tests/test_rewrap_fanout.py b/packages/meshbay-hub/tests/test_rewrap_fanout.py index 03d24dc..54ef67a 100644 --- a/packages/meshbay-hub/tests/test_rewrap_fanout.py +++ b/packages/meshbay-hub/tests/test_rewrap_fanout.py @@ -1,6 +1,6 @@ """ `MeshBayTransport.rewrapAllNodes` — the passphrase-change / recovery fan-out -(docs/auth-confirm.md §3.2, §4.5). +(docs/MESHBAY_DESIGN.md §3.6). The real function is run under node with its two boundaries stubbed: the hub HTTP calls and the per-node `MeshBayTransport` handshake. What is exercised is diff --git a/packages/meshbay-hub/tests/test_search_files_unmerged.py b/packages/meshbay-hub/tests/test_search_files_unmerged.py index 6956dde..b84b25d 100644 --- a/packages/meshbay-hub/tests/test_search_files_unmerged.py +++ b/packages/meshbay-hub/tests/test_search_files_unmerged.py @@ -19,7 +19,7 @@ merge. Weak evidence, and the only kind available for the SPA — but the failur it guards against is a one-line edit, which is exactly what a source-reading test catches well. -See docs/refactoring-search.md §6.1. +See docs/MESHBAY_DESIGN.md §9.11. """ import re @@ -54,7 +54,7 @@ def test_the_files_list_is_not_merged(): "fileEntries now goes through the source merge. The Files explorer " "shows one folder per group and a member navigates into it; merging " "two groups' copies of a file would remove it from one of those " - "folders. See docs/refactoring-search.md §6.1") + "folders. See docs/MESHBAY_DESIGN.md §9.11") @pytest.mark.parametrize("name", ["videoEntries", "musicEntries", "photoEntries"]) diff --git a/packages/meshbay-hub/tests/test_search_media_merge.py b/packages/meshbay-hub/tests/test_search_media_merge.py index 65f85aa..3464902 100644 --- a/packages/meshbay-hub/tests/test_search_media_merge.py +++ b/packages/meshbay-hub/tests/test_search_media_merge.py @@ -23,7 +23,7 @@ with it — so what this counts is what the grid renders. `t()` is stubbed to return its key: `groupMusicEntries` uses it for the two placeholder album names, and a string is not what is under test here. -See docs/refactoring-search.md. +See docs/MESHBAY_DESIGN.md §9.11. """ import json diff --git a/packages/meshbay-hub/tests/test_search_source_merge.py b/packages/meshbay-hub/tests/test_search_source_merge.py index cadf095..b471891 100644 --- a/packages/meshbay-hub/tests/test_search_source_merge.py +++ b/packages/meshbay-hub/tests/test_search_source_merge.py @@ -20,7 +20,7 @@ it has no imports precisely so that it can be, and a copy of the picking rule in a test would keep agreeing with the original right up until one of them changed. -See docs/refactoring-search.md. +See docs/MESHBAY_DESIGN.md §9.11. """ import json diff --git a/packages/meshbay-hub/tests/test_search_unlisted.py b/packages/meshbay-hub/tests/test_search_unlisted.py index 67eb3e6..94f3aee 100644 --- a/packages/meshbay-hub/tests/test_search_unlisted.py +++ b/packages/meshbay-hub/tests/test_search_unlisted.py @@ -48,10 +48,14 @@ def test_an_unlisted_group_is_neither_indexed_nor_cached_nor_unreachable(): body = _function(SEARCH_PAGE.read_text(encoding="utf-8"), "fetchAllIndexes") branch = body[body.index("result.unlisted"):] branch = branch[:branch.index("} else if (result)")] - for forbidden in ("results.set", "cacheGroupIndex", "unreachable.push"): + # `cacheGroupIndex` used to be on this list. The store it wrote to is gone + # (hub-client.js `purgeGroupIndexCache`), so the way an unlisted group's + # index could now be kept is by being written anywhere at all — which is + # what test_no_group_index_is_written_to_storage guards, for every group. + for forbidden in ("results.set", "unreachable.push"): assert forbidden not in branch, ( - f"an unlisted group reaches `{forbidden}` — it would be shown, " - "cached, or reported as down") + f"an unlisted group reaches `{forbidden}` — it would be shown " + "or reported as down") def test_every_search_view_is_built_from_the_indexed_groups_only(): diff --git a/packages/meshbay-hub/tests/test_sticky_band_ring.py b/packages/meshbay-hub/tests/test_sticky_band_ring.py index 2d57822..e5ef860 100644 --- a/packages/meshbay-hub/tests/test_sticky_band_ring.py +++ b/packages/meshbay-hub/tests/test_sticky_band_ring.py @@ -28,9 +28,10 @@ before `test_sticky_header.py` measured it. Read out of the stylesheet rather than measured in a browser, deliberately. A browser shows the 4px at one width, in one of the states that happen to put something above a band; what has to hold is which bands are in which of two -lists, and that is a fact about the source. `docs/apps.md` sends the author of -a new application here to make its toolbar pin, and this is what says whether -the toolbar they add needs the gap — it does not, and it must not have it. +lists, and that is a fact about the source. `docs/MESHBAY_DESIGN.md` §9.2 sends +the author of a new application here to make its toolbar pin, and this is what +says whether the toolbar they add needs the gap — it does not, and it must not +have it. """ import re diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index 9c3a88b..3c2b0b6 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -141,7 +141,7 @@ def test_the_view_only_follows_new_messages_when_already_at_the_bottom(chat): def test_every_authorize_admin_op_call_is_registered_in_admin_op_types(transport): """ - Found live (docs/photos.md's photo_roots): `setPhotoRoots` called + Found live (docs/MESHBAY_DESIGN.md §9.9's photo_roots): `setPhotoRoots` called `_authorizeAdminOp(msg, 'photo_roots', ...)` like every other admin op, but `photo_roots` was never added to `ADMIN_OP_TYPES` — so its initial request was never keyed `admin:photo_roots`, the node's `admin_challenge` diff --git a/packages/meshbay-hub/tests/test_zip_size_limit.py b/packages/meshbay-hub/tests/test_zip_size_limit.py index 9471b8a..26c5552 100644 --- a/packages/meshbay-hub/tests/test_zip_size_limit.py +++ b/packages/meshbay-hub/tests/test_zip_size_limit.py @@ -3,7 +3,7 @@ The arbitrary ceiling on a directory zip. `downloadDirectory` is the one implementation behind every "download this folder as a zip" button — Files' single folder, Files' multi-folder selection, -and the Photos album button (docs/photos.md §3) — so the limit is checked +and the Photos album button (docs/MESHBAY_DESIGN.md §9.9) — so the limit is checked once, there, and holds for all of them. Three things are worth pinning. That an oversized folder is refused *before* |