Files
MeshTerm/tests/test_retired.py
T
Jean-Pierre MartineauandClaude Opus 5.5 1ceda7e22f Chat joins room servers, and reads each post under its author
A room server is a message board on a radio: it keeps its members' last
posts and sends each member the ones they missed. MeshTerm could see rooms
and run your own, but not join one. Posts that reached the radio were filed
as direct messages from the room, with their authors thrown away.

Chat now has a Rooms section between Channels and Direct. Opening a room
shows its board at once, each post under the member who wrote it. A post
carries the first four bytes of its author's key, so it is named by any
contact or overheard advert with that key and coloured by the key itself.
An author nothing names stands as the hash, in the unknown-node grey. The
first visit asks for the room's password over the board. After that,
opening a room logs in again only once it has been quiet for
room_relogin_minutes (a new preference, 30 by default), and ^L (F6 on the
handhelds) logs in on demand. The title says what the room allowed:
member, admin or read-only. A read-only member gets a line saying the room
drops their posts, in place of a compose box. A post goes out once, never
soft-retried, since a duplicate would reach every member.

Underneath: messages carry an author (schema 18), a room's board leaves
out its command replies, and a re-sent post is recorded once. Room posts
raise the unread badge and a room's command replies don't. The device
gains room_login, sharing admin_login's exchange and reading the access
the room granted. Room passwords are remembered in rooms.json; an admin
login is kept in admin.json, shared with Repeater admin. The simulator
has a room, Lakeside BBS, to join with "hello".

Two fixes the research turned up. An admin command took the first direct
message from anyone as its reply, so a room post or a companion's message
landing mid-command was read as the node's answer. It now listens before
sending, and only for a reply from that node that is not a post. And
Repeater admin names a room server's guest password "Room password", the
password members join with.

Refs: #24
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:21:34 -04:00

233 lines
9.5 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""A retired name is never reused.
A value can outlive its key: a file nobody has saved since the key left still holds it. If
the name came back with a new meaning — seconds become minutes, a range moves — an old
value that happens to pass the new spec would load cleanly and mean something else, and
no validation can catch that. So every name that leaves is recorded as retired, and these
tests fail the moment one comes back:
* the preference registry against :data:`meshterm.core.preferences.RETIRED`, and the
device-setting registry against :data:`meshterm.core.device_config.RETIRED`;
* every persisted record type — a dataclass in :mod:`meshterm.core` declaring its own
``RETIRED`` set — against its own fields. The record is the path: a name retired from
one record says nothing about another, where it may be exactly the right name.
"""
from __future__ import annotations
import dataclasses
import importlib
import json
import logging
import pkgutil
from pathlib import Path
import pytest
import meshterm.core
from meshterm.core import device_config, preferences
from meshterm.core.admin_store import AdminStore
from meshterm.core.models import Contact
from meshterm.core.preferences import (
PREFERENCES,
RETIRED,
PreferenceError,
Preferences,
report_dropped,
)
from meshterm.core.remote_store import RemoteStore
from meshterm.core.room_store import RoomStore
from meshterm.core.settings_store import SettingsStore
def _record_types() -> list[type]:
"""Every dataclass in meshterm.core that declares a ``RETIRED`` set of field names."""
found = []
for info in pkgutil.iter_modules(meshterm.core.__path__):
module = importlib.import_module(f"meshterm.core.{info.name}")
for value in vars(module).values():
if (
isinstance(value, type)
and value.__module__ == module.__name__
and dataclasses.is_dataclass(value)
and isinstance(getattr(value, "RETIRED", None), frozenset)
):
found.append(value)
return found
def test_no_live_preference_takes_a_retired_key() -> None:
"""A preference that returns changed returns under a new name."""
assert not {spec.key for spec in PREFERENCES} & RETIRED
def test_no_live_device_setting_takes_a_retired_key() -> None:
"""The settings store restores values onto the radio; a reused key would misplace one."""
assert not {spec.key for spec in device_config.DEVICE_SETTINGS} & device_config.RETIRED
#: Every persisted record type, by the module that writes it. A store that grows a record
#: adds it here, and the sweep below checks it without being told how.
_EXPECTED_RECORDS = {
"admin_store": {"AdminCredential"},
"advert_store": {"_Device", "_Document"},
"channel_store": {"RememberedChannel"},
"contact_store": {"RememberedContact"},
"courier_store": {"QueuedMessage"},
"device_store": {"RememberedDevice"},
"remote_store": {"CachedValue", "_RemoteNode"},
"room_store": {"RoomMembership"},
"watch_store": {"WatchedNode", "Alert", "_State"},
}
def test_the_record_walk_finds_every_store() -> None:
"""The sweep below is not vacuous: it sees each store's records."""
found = {(cls.__module__.rsplit(".", 1)[1], cls.__qualname__) for cls in _record_types()}
expected = {(module, name) for module, names in _EXPECTED_RECORDS.items() for name in names}
assert expected <= found
@pytest.mark.parametrize("record", _record_types(), ids=lambda cls: cls.__qualname__)
def test_no_record_takes_back_a_retired_field(record: type) -> None:
"""Each persisted record's fields are disjoint from the names it has retired."""
fields = {f.name for f in dataclasses.fields(record)}
assert not fields & record.RETIRED
def test_loading_refuses_a_retired_key(tmp_path: Path) -> None:
"""A retired key in the file is not applied, and is named as retired."""
path = tmp_path / "preferences.toml"
retired = sorted(RETIRED)[0]
path.write_text(f"{retired} = 3\ntrace_cooldown_s = 2.0\n", encoding="utf-8")
prefs = Preferences.load(path)
assert prefs.dropped == {retired: preferences.DROPPED_RETIRED}
assert prefs.trace_cooldown_s == 2.0
def test_the_next_save_leaves_out_everything_refused(tmp_path: Path) -> None:
"""Retired, unknown and invalid entries are all gone once the file is saved."""
path = tmp_path / "preferences.toml"
retired = sorted(RETIRED)[0]
path.write_text(
f"{retired} = 3\ntrace_cooldwn_s = 2.0\nhistory_days = -4\ntrace_cooldown_s = 2.0\n",
encoding="utf-8",
)
prefs = Preferences.load(path)
assert set(prefs.dropped) == {retired, "trace_cooldwn_s", "history_days"}
prefs.save()
text = path.read_text(encoding="utf-8")
for key in (retired, "trace_cooldwn_s", "history_days"):
assert f"{key} =" not in text
assert "trace_cooldown_s = 2.0" in text
def test_a_retired_key_is_named_as_such_when_set(tmp_path: Path) -> None:
"""`preferences set` on a retired key says it is no longer a preference."""
with pytest.raises(PreferenceError, match="no longer a preference"):
Preferences().set(sorted(RETIRED)[0], 1)
def test_drops_are_logged_by_kind(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""A retired key is expected (INFO); a typo or a bad value is a WARNING naming it."""
path = tmp_path / "preferences.toml"
retired = sorted(RETIRED)[0]
path.write_text(f"{retired} = 3\ntrace_cooldwn_s = 2.0\nhistory_days = -4\n", encoding="utf-8")
log = logging.getLogger("test_retired")
with caplog.at_level(logging.INFO, logger="test_retired"):
report_dropped(Preferences.load(path), log)
by_level = {(r.levelno, r.getMessage()) for r in caplog.records}
assert any(level == logging.INFO and retired in msg for level, msg in by_level)
assert any(level == logging.WARNING and "trace_cooldwn_s" in msg for level, msg in by_level)
assert any(level == logging.WARNING and "history_days" in msg for level, msg in by_level)
# -- the stores write only what they know ----------------------------------------------
_NODE = Contact(name="Hilltop-Repeater", public_key="cd" * 32, key_prefix="cd" * 6)
def test_the_admin_store_writes_only_known_fields(tmp_path: Path) -> None:
"""A stray field on a credential, or a stray record, is gone after the next write."""
path = tmp_path / "admin.json"
path.write_text(
json.dumps(
{
"cd" * 32: {"password": "pw", "label": "H", "last_used": "", "role": "x"},
"junk": {"no": "password"},
}
)
)
store = AdminStore(path)
assert store.get(_NODE) == "pw"
store.remember(Contact(name="Other", public_key="ef" * 32, key_prefix="ef" * 6), "pw2")
written = json.loads(path.read_text())
assert set(written) == {"cd" * 32, "ef" * 32}
assert written["cd" * 32].keys() == {"password", "label", "last_used"}
def test_the_room_store_writes_only_known_fields(tmp_path: Path) -> None:
"""A stray field on a membership, or a record with no access, is gone after a write."""
from meshterm.core.models import LoginResult, RoomAccess, RoomLogin
path = tmp_path / "rooms.json"
path.write_text(
json.dumps(
{
"cd" * 32: {
"password": "hello",
"access": "member",
"label": "H",
"last_login": "",
"role": "x",
},
"junk": {"password": "no access"},
}
)
)
store = RoomStore(path)
assert store.password(_NODE) == "hello"
other = Contact(name="Other", public_key="ef" * 32, key_prefix="ef" * 6, node_type=3)
store.record(other, "pw", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER))
written = json.loads(path.read_text())
assert set(written) == {"cd" * 32, "ef" * 32}
assert written["cd" * 32].keys() == {"password", "access", "label", "last_login"}
def test_the_remote_store_writes_only_known_fields(tmp_path: Path) -> None:
"""Unknown fields on a node or a cached value do not survive, and the spelling holds."""
path = tmp_path / "remote.json"
stamp = "2026-09-01T12:00:00+00:00"
path.write_text(
json.dumps(
{
"cd" * 32: {
"settings": {
"txdelay": {"value": "0.5", "read_at": stamp, "unit": "s"},
"bridge.delay": {"unsupported": True, "read_at": stamp},
},
"history": ["ver"],
"notes": "stray",
}
}
)
)
store = RemoteStore(path)
store.append_history(_NODE, "clock")
record = json.loads(path.read_text())["cd" * 32]
assert record.keys() == {"settings", "history"}
assert record["settings"]["txdelay"] == {"value": "0.5", "read_at": stamp}
assert record["settings"]["bridge.delay"] == {"unsupported": True, "read_at": stamp}
assert record["history"] == ["ver", "clock"]
def test_the_settings_store_keeps_only_registered_settings(tmp_path: Path) -> None:
"""A key that is not a device setting is never offered for restore, and leaves the file."""
path = tmp_path / "settings.json"
path.write_text(json.dumps({"devices": {"ab" * 32: {"name": "Node", "no_such_key": 3}}}))
store = SettingsStore(path)
assert store.settings("ab" * 32) == {"name": "Node"}
store.remember("ab" * 32, "tx_power", 14)
assert json.loads(path.read_text())["devices"]["ab" * 32] == {"name": "Node", "tx_power": 14}