aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/harness/search_fanout_harness.mjs5
-rw-r--r--packages/meshbay-hub/tests/test_availability_between_members.py67
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py11
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py6
-rw-r--r--packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py2
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py2
-rw-r--r--packages/meshbay-hub/tests/test_no_index_cache.py98
-rw-r--r--packages/meshbay-hub/tests/test_password_change.py2
-rw-r--r--packages/meshbay-hub/tests/test_password_reset.py2
-rw-r--r--packages/meshbay-hub/tests/test_recovery_email.py2
-rw-r--r--packages/meshbay-hub/tests/test_recovery_key.py2
-rw-r--r--packages/meshbay-hub/tests/test_rewrap_fanout.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_files_unmerged.py4
-rw-r--r--packages/meshbay-hub/tests/test_search_media_merge.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_source_merge.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_unlisted.py10
-rw-r--r--packages/meshbay-hub/tests/test_sticky_band_ring.py7
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py2
-rw-r--r--packages/meshbay-hub/tests/test_zip_size_limit.py2
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*