diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-19 14:01:38 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-19 14:01:38 +0200 |
| commit | d2495a2c4b89fbbfc18cefec83ae96cabdd745e2 (patch) | |
| tree | 463e4d11b1c742475aac07155818c9c64a1e43e5 /packages/meshbay-node/tests | |
| parent | f8223293a211a87c92b1fed80f5ca53660f6b26c (diff) | |
| parent | 933daccbcfde7705413d3a10db87d910c650ed42 (diff) | |
| download | meshbay-d2495a2c4b89fbbfc18cefec83ae96cabdd745e2.tar.gz | |
Merge origin/main: the operator's upload ceiling beside the disk-thread work
One conflict, in §15.3's open list, and it was two changes agreeing rather than
disagreeing: this side removed the rows for the third-party search bound and the
node-announcement bound because both are now built (AV27, AV28), while the other
side kept them and added a new one. Resolved by keeping what is genuinely still
open — per-device revocation having no CLI — and leaving the two closed.
`webrtc_server.py` merged without conflict but the two sides met inside one
function: `_upload_chunk` gained the operator's `max_upload_gb` ceiling from
there and the per-group lock and `off_disk` calls from here. Read back rather
than trusted: the operator's ceiling now sits inside the critical section that
keeps chunk ordering, and the unlink beside it goes to the disk thread with
everything else.
2893 passed. The twelve `test_sticky_header.py[firefox]` setup errors are the
open Firefox on this machine, as before.
Diffstat (limited to 'packages/meshbay-node/tests')
5 files changed, 247 insertions, 3 deletions
diff --git a/packages/meshbay-node/tests/test_chat_is_bounded.py b/packages/meshbay-node/tests/test_chat_is_bounded.py index 1af72b4..4e767bc 100644 --- a/packages/meshbay-node/tests/test_chat_is_bounded.py +++ b/packages/meshbay-node/tests/test_chat_is_bounded.py @@ -13,7 +13,7 @@ it in `chat.db` on the operator's disk, where nothing expires it — retention i a manual CLI command (§6.6) — relays it to every other connected member, and has the hub write a notification for every member of the group. Uploads, the other member-supplied write, have carried a filename allowlist, strict chunk -ordering, a no-overwrite rule and a 4 GB cap since C5a. Chat carried nothing: +ordering, a no-overwrite rule and a per-file size cap since C5a. Chat carried nothing: the only ceiling was the DataChannel frame, 64 MB once the handshake is done. One member in a loop filled the operator's disk and saturated everyone else's connection, and the node's own answer to each message was `ack`. diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py index 9bf43d4..ad60b91 100644 --- a/packages/meshbay-node/tests/test_cli_dispatch.py +++ b/packages/meshbay-node/tests/test_cli_dispatch.py @@ -51,6 +51,11 @@ VERBS = [ ["transfers", "set", "4", "2"], ["transfers", "set", "4"], # only one number: usage, then exit ["transfers", "set", "0", "2"], # zero is not "unlimited": refused + ["transfers", "max-size", "8"], + ["transfers", "max-size", "0.5"], # a fraction of a GB is legitimate + ["transfers", "max-size"], # no size: usage, then exit + ["transfers", "max-size", "0"], # zero is not "unlimited": refused + ["transfers", "max-size", "huge"], # not a number: refused ["transfers", "per-member", "4", "2"], ["transfers", "per-member", "4"], # only one number: usage, then exit ["transfers", "per-member", "0", "2"], # zero is refused here too diff --git a/packages/meshbay-node/tests/test_node_settings_defaults.py b/packages/meshbay-node/tests/test_node_settings_defaults.py new file mode 100644 index 0000000..f5d7856 --- /dev/null +++ b/packages/meshbay-node/tests/test_node_settings_defaults.py @@ -0,0 +1,85 @@ +""" +Three lists name the same node settings, and they must agree. + +The resolver (`Roster.node_settings`) answers for a set of keys; the reader +(`config.node_settings_defaults`) supplies what node.toml says for each; the +writer (`ops.NODE_SETTING_WRITERS`) says which may be set and how each is +validated. Nothing in the code path errors when the reader covers fewer keys +than the resolver answers for: the missing one resolves to None, is written +back onto the config, and the consumer falls through to its own default. So +node.toml is parsed, validated — and ignored. + +That is what happened to `max_concurrent_downloads` and +`max_concurrent_uploads`, and it was invisible because the value it fell back +to was the same 8 the file suggests. Only an operator who set something else +would ever have seen it, and then only as pools that did not match the file. +""" +import pytest +from meshbay_node.config import NodeConfig, load_config, node_settings_defaults +from meshbay_node.ops import NODE_SETTING_WRITERS +from meshbay_node.roster import Roster + + +def test_the_reader_covers_every_setting_the_resolver_answers_for(): + assert set(node_settings_defaults()) == set(Roster.node_setting_keys()) + + +def test_the_writer_covers_every_setting_the_resolver_answers_for(): + assert set(NODE_SETTING_WRITERS) == set(Roster.node_setting_keys()) + + +def test_no_default_is_none(): + """A None here is indistinguishable from a key that is missing.""" + missing = [k for k, v in node_settings_defaults().items() if v is None] + assert not missing + + +@pytest.mark.asyncio +async def test_node_toml_survives_startup_with_no_panel_override(tmp_path): + """The whole bug, at the level an operator meets it.""" + conf = tmp_path / "node.toml" + conf.write_text( + '[hub]\nurl = "https://example.invalid"\nusername = "op"\n\n' + '[node]\n' + 'max_concurrent_downloads = 3\n' + 'max_concurrent_uploads = 2\n' + 'max_concurrent_streams = 5\n' + 'max_upload_gb = 4\n', + encoding="utf-8") + nd = load_config(conf).node + + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + effective = await roster.node_settings(node_settings_defaults(nd)) + finally: + await roster.close() + + assert effective["max_concurrent_downloads"] == 3 + assert effective["max_concurrent_uploads"] == 2 + assert effective["max_concurrent_streams"] == 5 + assert effective["max_upload_gb"] == 4.0 + + +@pytest.mark.asyncio +async def test_a_panel_override_still_wins_over_the_file(tmp_path): + conf = tmp_path / "node.toml" + conf.write_text( + '[hub]\nurl = "https://example.invalid"\nusername = "op"\n\n' + '[node]\nmax_concurrent_downloads = 3\n', encoding="utf-8") + nd = load_config(conf).node + + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + await roster.set_node_setting(Roster.SETTING_MAX_DOWNLOADS, "6", "op") + effective = await roster.node_settings(node_settings_defaults(nd)) + finally: + await roster.close() + + assert effective["max_concurrent_downloads"] == 6 + + +def test_an_absent_config_falls_back_to_the_dataclass_not_to_literals(): + """`node_status` used to repeat the numbers by hand, and they went stale.""" + assert node_settings_defaults(None) == node_settings_defaults(NodeConfig()) diff --git a/packages/meshbay-node/tests/test_transfer_settings.py b/packages/meshbay-node/tests/test_transfer_settings.py index aa24782..bcfa6a8 100644 --- a/packages/meshbay-node/tests/test_transfer_settings.py +++ b/packages/meshbay-node/tests/test_transfer_settings.py @@ -139,15 +139,17 @@ async def test_the_node_wide_caps_round_trip_through_the_roster(roster): **{k: None for k in ("invite_ttl_hours", "pair_ttl_hours", "device_request_ttl_minutes", "max_concurrent_streams", - "transcode_incompatible_video")}, + "transcode_incompatible_video", + "max_upload_gb")}, "stun_servers": [], "ice_interfaces": [], } await roster.set_node_setting(roster.SETTING_MAX_DOWNLOADS, "3", "op") assert (await roster.node_settings(defaults))["max_concurrent_downloads"] == 3 -def test_node_toml_carries_both_keys(): +def test_node_toml_carries_the_upload_keys(): """The template is what an operator reads before they read any document.""" from meshbay_node.config import EXAMPLE_CONFIG as tpl assert "max_concurrent_downloads" in tpl assert "max_concurrent_uploads" in tpl + assert "max_upload_gb" in tpl diff --git a/packages/meshbay-node/tests/test_upload_size_cap.py b/packages/meshbay-node/tests/test_upload_size_cap.py new file mode 100644 index 0000000..dca6e15 --- /dev/null +++ b/packages/meshbay-node/tests/test_upload_size_cap.py @@ -0,0 +1,152 @@ +""" +How large a single upload may be, and who decides. + +The cap used to be a constant: 4 GB, in `webrtc_server.py`, the same on a Pi +with a 32 GB card and on a machine holding a film library. It is the operator's +disk that fills, so the number is theirs — `max_upload_gb` under [node] in +node.toml, the Node page, and `meshbay-node transfers max-size`, with the +constant as the default when they have said nothing. + +These follow the value along the whole path rather than checking that the field +parses, for the reason `test_stream_capacity_config.py` gives: every join in +such a path has been wrong at least once, and a ceiling read from the wrong +place fails only when somebody sends a large file. +""" + +import textwrap +from pathlib import Path + +import pytest + +from meshbay_node.config import load_config +from meshbay_node.roots import RootSet +from meshbay_node.transport.webrtc_server import ( + GB_BYTES, + MAX_UPLOAD_BYTES, + WebRTCPeerSession, + WebRTCTransport, +) + + +def _cfg(tmp_path: Path, body: str): + p = tmp_path / "node.toml" + p.write_text(textwrap.dedent(body)) + return load_config(p) + + +class _FakePC: + def on(self, *a, **k): + return lambda f: f + + +def _session(gb): + t = WebRTCTransport( + sk_node=None, hub_pk_pem=b"", gek=b"\0" * 32, + roots=RootSet(), index=None, max_upload_gb=gb) + return t, WebRTCPeerSession(_FakePC(), t._ctx, peer_id="p") + + +# ── What the operator writes ───────────────────────────────────────────────── + +def test_the_default_is_eight_gb(): + assert MAX_UPLOAD_BYTES == 8 * GB_BYTES + + +def test_the_operator_sets_it(tmp_path): + cfg = _cfg(tmp_path, """ + [node] + max_upload_gb = 20 + """) + assert cfg.node.max_upload_gb == 20 + + +def test_a_fraction_of_a_gigabyte_is_legitimate(tmp_path): + """Not a count, so it does not get `_positive`'s floor of one. + + A node on a small disk may well want to stop at half a gigabyte, and + rounding that to zero would refuse every upload. + """ + cfg = _cfg(tmp_path, """ + [node] + max_upload_gb = 0.5 + """) + assert cfg.node.max_upload_gb == 0.5 + _t, s = _session(0.5) + assert s._max_upload_bytes() == GB_BYTES // 2 + + +def test_saying_nothing_gets_the_default(tmp_path): + cfg = _cfg(tmp_path, """ + [node] + quic_port = 19010 + """) + assert cfg.node.max_upload_gb * GB_BYTES == MAX_UPLOAD_BYTES, ( + "the config default and the source default disagree, so the ceiling " + "depends on whether a node.toml happens to mention it") + + +@pytest.mark.parametrize("value", ["0", "-2", '"lots"', "true"]) +def test_a_value_that_would_refuse_every_upload_is_refused(tmp_path, value, caplog): + cfg = _cfg(tmp_path, f""" + [node] + max_upload_gb = {value} + """) + assert cfg.node.max_upload_gb * GB_BYTES == MAX_UPLOAD_BYTES + assert "max_upload_gb" in caplog.text, ( + "the value was silently discarded — the operator has no way to learn " + "their setting is not in effect") + + +# ── That the number reaches the check it governs ───────────────────────────── + +@pytest.mark.parametrize("gb,expect", [ + (None, MAX_UPLOAD_BYTES), + (2, 2 * GB_BYTES), + (16, 16 * GB_BYTES), +]) +def test_the_configured_size_is_what_the_handler_enforces(gb, expect): + _t, s = _session(gb) + assert s._max_upload_bytes() == expect + + +def test_the_handler_reads_it_rather_than_the_constant(): + """A ceiling captured once is one the operator cannot change. + + The check runs per chunk, so raising the cap has to reach an upload that + is already in flight — which it does only if the handler asks the context + each time instead of closing over the constant. + """ + src = (Path(__file__).resolve().parents[1] / "src" / "meshbay_node" + / "transport" / "webrtc_server.py").read_text(encoding="utf-8") + i = src.index("Upload exceeds size limit") + check = src[src.rindex("if state.bytes", 0, i):i] + assert "_max_upload_bytes()" in check, ( + "the upload check reads the module constant, so node.toml, the Node " + "page and the CLI are all read and then ignored") + + +def test_raising_it_reaches_an_upload_already_running(): + t, s = _session(2) + assert s._max_upload_bytes() == 2 * GB_BYTES + t.set_capacity(max_upload_gb=10) + assert s._max_upload_bytes() == 10 * GB_BYTES, ( + "the session kept the ceiling it started with, so the setting only " + "takes effect on a restart" + ) + + +def test_zero_is_refused_at_the_transport_too(): + t, _s = _session(4) + with pytest.raises(ValueError): + t.set_capacity(max_upload_gb=0) + + +def test_the_daemon_passes_it(): + """The join that syntax checking cannot see.""" + daemon = (Path(__file__).resolve().parents[1] / "src" / "meshbay_node" + / "daemon.py").read_text(encoding="utf-8") + i = daemon.index("WebRTCTransport(") + call = daemon[i:daemon.index(")", daemon.index("denylist=denylist", i))] + assert "max_upload_gb=self._config.node.max_upload_gb" in call, ( + "the daemon builds the transport without the operator's ceiling, so " + "node.toml is read and then ignored") |