mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
web,data: stop Reticulum destination placeholders from renaming (#968)
This commit is contained in:
7 files changed
+331
-43
No files matched your search
@@ -63,6 +63,7 @@
|
||||
* CI: the Python workflow tests Python 3.13 and 3.14; neither CI matrix fails fast (SPEC DP5)
|
||||
* Web: a MeshCore channel message heard by two ingestors that resolved its sender to different node ids is stored once (#880)
|
||||
* Web: the first boot after upgrading deletes duplicate MeshCore messages once; on instances with several MeshCore ingestors this can remove about a fifth of the stored MeshCore messages (#880)
|
||||
* Data/Web: a Reticulum destination's placeholder name never replaces its real name or names its node; a node with no announced name reads its own placeholder; a nameless destination is named from its own hash (SPEC RA10, RE10)
|
||||
* Web, bridge, app: Reticulum is named as its own protocol in the bridge tag (`[RT]`), the footer join strip (`RETICULUM_PRESET` and `RETICULUM_FREQ`), the `/nodes` page description and the client fallback label; the site description names no protocol; the app README states the app is Meshtastic only (SPEC RL8)
|
||||
* Docs: Reticulum is marked experimental and announce-only; the README says what the RNS stack transmits and that the channel filters ignore it (SPEC RD7/RN5)
|
||||
* Nix: the flake ingestor includes `rns`, so `PROTOCOL=reticulum` runs on NixOS; `flake.lock` moves nixpkgs to nixos-unstable of 2026-10-03, which brings Python 3.14 and Ruby 3.4.9 (SPEC DP8)
|
||||
|
||||
@@ -484,6 +484,13 @@ One row per announced destination, newest `last_heard` first.
|
||||
body the cursor produced.
|
||||
- Fields: `id` (destination hash, hex), `node_id`, `identity_hash`, `name`,
|
||||
`aspect`, `role`, `interface`, `first_heard`, `last_heard`, `protocol`.
|
||||
- `name` is the destination's announced display name, else `Reticulum` plus the
|
||||
upper-cased first four hex of its own `id`, the placeholder the ingestor sends
|
||||
for a nameless destination on the announce and host paths alike (SPEC RA10).
|
||||
A destination's placeholder is stored on a first sighting, never replaces a
|
||||
stored name and never becomes the node's `long_name`, which is the
|
||||
highest-ranked announced name, else a stored name that is not a placeholder,
|
||||
else the node's own placeholder (SPEC RE10).
|
||||
- `identity_hash` groups rows belonging to one peer; several rows share it when
|
||||
an identity announces on several aspects.
|
||||
- Written only by the node ingest route, from each node record's `destination`
|
||||
|
||||
@@ -600,9 +600,16 @@ def _announce_to_node_dict(
|
||||
return None
|
||||
hash_hex = _reticulum_hash_hex(dest_hash)
|
||||
display_name = _decode_display_name(app_data)
|
||||
# The web stores this name on the destination row, so a placeholder is
|
||||
# built from the destination's own hash, as host discovery builds one
|
||||
# (SPEC RA10(b)). The node id stands in only for an unusable hash, which
|
||||
# writes no destination row; the node's headline comes from RE10.
|
||||
placeholder_id = _reticulum_node_id(dest_hash) or node_id
|
||||
user: dict = {
|
||||
"longName": (
|
||||
display_name if display_name else _reticulum_placeholder_name(node_id)
|
||||
display_name
|
||||
if display_name
|
||||
else _reticulum_placeholder_name(placeholder_id)
|
||||
),
|
||||
"shortName": _reticulum_short_name(node_id),
|
||||
"publicKey": _identity_public_key_hex(identity),
|
||||
|
||||
@@ -460,22 +460,24 @@ def test_fallback_name_matches_the_web_placeholder_for_every_id_shape():
|
||||
mismatch that breaks ~85% of the id space — which is exactly how the first
|
||||
attempt at this fix passed its own test while still clobbering names.
|
||||
"""
|
||||
for node_id, expected in (
|
||||
identity = _FakeIdentity(bytes.fromhex(_FIELD_PRIMARY))
|
||||
for dest_ref, expected in (
|
||||
("!0001beef", "Reticulum 0001"), # digits only - matches even lower-cased
|
||||
("!c0ffee00", "Reticulum C0FF"),
|
||||
("!beefcafe", "Reticulum BEEF"),
|
||||
("!deadbeef", "Reticulum DEAD"),
|
||||
("!0000000a", "Reticulum 0000"),
|
||||
):
|
||||
# The placeholder names the row, and the row is keyed on the
|
||||
# identity — so walk identity hashes, not destinations. The expected
|
||||
# The placeholder names the row it lands on, the destination (SPEC
|
||||
# RA10(b)), so walk destination hashes under one identity. The expected
|
||||
# strings below are the *web tier's* rule (placeholder_short_id in
|
||||
# identity.rb): first four hex, upper-cased, for reticulum. If the two
|
||||
# sides drift, the web upsert stops recognising these as placeholders
|
||||
# and they start overwriting real display names.
|
||||
idn = _FakeIdentity(bytes.fromhex(node_id[1:] + "11" * 12))
|
||||
node = _announce_to_node_dict(_DEST_HASH, None, identity=idn, last_heard=1)
|
||||
assert node["user"]["longName"] == expected, node_id
|
||||
# identity.rb, which upsert_destination applies to "!" plus the first
|
||||
# eight hex of the destination): first four hex, upper-cased, for
|
||||
# reticulum. If the two sides drift, the web upsert stops recognising
|
||||
# these as placeholders and they start overwriting real display names.
|
||||
dest = bytes.fromhex(dest_ref[1:] + "11" * 12)
|
||||
node = _announce_to_node_dict(dest, None, identity=identity, last_heard=1)
|
||||
assert node["user"]["longName"] == expected, dest_ref
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -742,8 +744,11 @@ def test_host_destinations_carry_their_interface_and_announced_name(monkeypatch)
|
||||
def test_host_destination_falls_back_to_the_placeholder_without_a_name(monkeypatch):
|
||||
"""A destination the stack has never heard a name for keeps the placeholder.
|
||||
|
||||
The generic name is wanted -- it is the web upsert's recognised fallback
|
||||
form -- but only where there is no real one to prefer.
|
||||
The generic name is wanted, but only where there is no real one to prefer.
|
||||
The web upsert recognises both placeholder forms, the node id's head and the
|
||||
destination's own head, so it stores this one on a first sighting, never
|
||||
lets it replace a real name, and never lets it head the node, which keeps
|
||||
its own ``Reticulum 2771`` (web/spec/reticulum_spec.rb pins the same string).
|
||||
"""
|
||||
_local_stack(monkeypatch, {_FIELD_LXMF: _FIELD_PRIMARY})
|
||||
record = ReticulumProvider().host_destination_nodes()[0]
|
||||
@@ -754,6 +759,28 @@ def test_host_destination_falls_back_to_the_placeholder_without_a_name(monkeypat
|
||||
assert "interface" not in record
|
||||
|
||||
|
||||
def test_peer_destination_placeholder_names_its_own_hash():
|
||||
"""A nameless peer announce names its destination from that destination.
|
||||
|
||||
SPEC RA10(b): each row is named from its own hash. The host path above
|
||||
does this; the announce path built the fallback from the identity's node
|
||||
id, so the field peer's ``lxmf.delivery`` destination ``4cf985bf...`` was
|
||||
stored as ``Reticulum 2771``, the node's name, instead of its own
|
||||
``Reticulum 4CF9``. The node keeps its own short name (RA10(a)).
|
||||
"""
|
||||
node = _announce_to_node_dict(
|
||||
bytes.fromhex(_FIELD_LXMF),
|
||||
None,
|
||||
identity=_FakeIdentity(bytes.fromhex(_FIELD_PRIMARY)),
|
||||
aspect="lxmf.delivery",
|
||||
last_heard=1,
|
||||
)
|
||||
assert node["nodeId"] == "!27716218"
|
||||
assert node["user"]["shortName"] == "2771"
|
||||
assert node["destination"]["id"] == _FIELD_LXMF
|
||||
assert node["user"]["longName"] == "Reticulum 4CF9"
|
||||
|
||||
|
||||
def test_transport_aspect_is_gated_on_transport_enabled(monkeypatch):
|
||||
"""TRANSPORT is reported only when the stack actually relays traffic.
|
||||
|
||||
@@ -886,16 +913,23 @@ def test_announce_to_node_dict_public_key_none_when_unreadable():
|
||||
assert node["user"]["publicKey"] is None
|
||||
|
||||
|
||||
def test_announce_to_node_dict_long_name_falls_back_to_the_node_placeholder():
|
||||
"""Undecodable app_data falls back to a placeholder naming the *node*.
|
||||
def test_announce_to_node_dict_long_name_falls_back_to_its_destination_placeholder():
|
||||
"""Undecodable app_data falls back to a placeholder naming the destination.
|
||||
|
||||
Not the destination: the row is keyed on the identity (SPEC RE7), and the
|
||||
web upsert only yields to the "<Label> <short id>" form it recognises.
|
||||
The web stores this name on the destination row, so it is built from the
|
||||
destination's own hash (SPEC RA10(b)); the node keeps its own headline
|
||||
(RE10). Only an unusable destination hash, which writes no destination
|
||||
row, falls back to the node id's placeholder.
|
||||
"""
|
||||
node = _announce_to_node_dict(
|
||||
_DEST_HASH, b"\xff\xfe", identity=_FakeIdentity(), last_heard=1
|
||||
)
|
||||
assert node["user"]["longName"] == "Reticulum BEEF"
|
||||
assert node["user"]["longName"] == "Reticulum AABB"
|
||||
salvaged = _announce_to_node_dict(
|
||||
b"\xaa", b"\xff\xfe", identity=_FakeIdentity(), last_heard=1
|
||||
)
|
||||
assert "destination" not in salvaged
|
||||
assert salvaged["user"]["longName"] == "Reticulum BEEF"
|
||||
|
||||
|
||||
def test_announce_to_node_dict_omits_hops_when_unknown():
|
||||
@@ -1407,9 +1441,10 @@ def test_node_snapshot_items_returns_heard_announces(monkeypatch):
|
||||
as_dict = dict(items)
|
||||
assert set(as_dict) == {"!beef0001", "!c0ffee00"}
|
||||
assert as_dict["!beef0001"]["user"]["longName"] == "Alice"
|
||||
# Name-less announce falls back to a placeholder built from its own node
|
||||
# id, so it can never carry another destination's hex.
|
||||
assert as_dict["!c0ffee00"]["user"]["longName"] == "Reticulum C0FF"
|
||||
# Name-less announce falls back to a placeholder built from the
|
||||
# destination it arrived on (SPEC RA10(b)), never from another
|
||||
# destination's hex.
|
||||
assert as_dict["!c0ffee00"]["user"]["longName"] == "Reticulum 1122"
|
||||
|
||||
|
||||
def test_update_node_ignores_a_falsy_node_id():
|
||||
|
||||
@@ -183,7 +183,10 @@ module PotatoMesh
|
||||
#
|
||||
# Must stay in lockstep with the ingestor's own placeholder: this method
|
||||
# is what recognises a placeholder so a real name is never overwritten by
|
||||
# one, and a mismatch would silently break that guard.
|
||||
# one, and a mismatch would silently break that guard. The ingestor
|
||||
# builds a nameless Reticulum destination's placeholder from the head of
|
||||
# the destination's own hash, and older ingestors built a peer's from the
|
||||
# head of the node id, so {#reticulum_placeholder_name?} checks both forms.
|
||||
#
|
||||
# @param node_id [String, nil] canonical node identifier.
|
||||
# @param protocol [String, nil] protocol the placeholder belongs to.
|
||||
@@ -198,6 +201,59 @@ module PotatoMesh
|
||||
hex.length >= 4 ? hex[0, 4].upcase : parts[2]
|
||||
end
|
||||
|
||||
# Whether +name+ is a generic Reticulum placeholder for a destination of
|
||||
# a node (SPEC RA10).
|
||||
#
|
||||
# Two forms are generic: the head of the destination's own hash,
|
||||
# canonicalised like a node id (+!+ plus its first eight hex digits),
|
||||
# which the ingestor builds for every nameless destination, and the head
|
||||
# of the node id, which older ingestors built for a peer's. Either one is
|
||||
# a stand-in, not an announced name: a destination's placeholder never
|
||||
# replaces its stored name and never names its node, which with no
|
||||
# announced name and no stored real name reads its own placeholder
|
||||
# ({#reticulum_headline_name}).
|
||||
#
|
||||
# @param name [String, nil] candidate name.
|
||||
# @param node_id [String] canonical id of the node owning the destination.
|
||||
# @param destination_id [String, nil] destination hash, hex; +nil+ (no
|
||||
# usable destination) checks the node id's form only.
|
||||
# @return [Boolean] true when +name+ is either placeholder.
|
||||
def reticulum_placeholder_name?(name, node_id, destination_id)
|
||||
generic_fallback_name?(name, node_id, "reticulum") ||
|
||||
generic_fallback_name?(name, "!#{destination_id.to_s[0, 8]}", "reticulum")
|
||||
end
|
||||
|
||||
# The headline name of a Reticulum node (SPEC RE10 as amended).
|
||||
#
|
||||
# The first announced name among the node's destinations, best ranked
|
||||
# first. A placeholder is not a name, so a nameless +NODE+ aspect cannot
|
||||
# rename a peer whose +PEER+ aspect announced one. With no announced name
|
||||
# a stored headline that is not a placeholder stands; otherwise the node
|
||||
# takes its own placeholder (SPEC RA10(a)): +!27716218+ badges +2771+ and
|
||||
# reads +Reticulum 2771+, never a destination's +Reticulum 9C59+.
|
||||
#
|
||||
# @param node_id [String] canonical node id.
|
||||
# @param destinations [Array<Array(String, String)>] +[id, name]+ pairs of
|
||||
# the node's destinations, best ranked first.
|
||||
# @param stored [String, nil] the node's current +long_name+.
|
||||
# @return [String, nil] the headline, or nil to leave +long_name+ as it is.
|
||||
def reticulum_headline_name(node_id, destinations, stored)
|
||||
announced = destinations.find do |id, name|
|
||||
string_or_nil(name) && !reticulum_placeholder_name?(name, node_id, id)
|
||||
end
|
||||
return announced.last if announced
|
||||
|
||||
stored = string_or_nil(stored)
|
||||
# A placeholder of the node or of any of its destinations yields; a
|
||||
# real name kept from a record with no usable destination stands.
|
||||
generic = stored.nil? || generic_fallback_name?(stored, node_id, "reticulum") ||
|
||||
destinations.any? { |id, _name| reticulum_placeholder_name?(stored, node_id, id) }
|
||||
return stored unless generic
|
||||
|
||||
short_id = placeholder_short_id(node_id, "reticulum")
|
||||
short_id ? "#{protocol_display_label("reticulum")} #{short_id}" : stored
|
||||
end
|
||||
|
||||
# Resolve a raw node reference to its canonical row in the +nodes+ table.
|
||||
#
|
||||
# @param db [SQLite3::Database] open database handle.
|
||||
|
||||
@@ -264,8 +264,12 @@ module PotatoMesh
|
||||
# @param destination [Object] +destination+ block from the payload.
|
||||
# @param identity_hash [String, nil] identity the destination belongs to.
|
||||
# @param name [String, nil] display name announced on this destination;
|
||||
# a generic +Reticulum <SHORT>+ placeholder is stored as +nil+ so it
|
||||
# cannot displace a real one.
|
||||
# a generic +Reticulum <SHORT>+ placeholder, built from this
|
||||
# destination's own hash or, by an older ingestor, from the node id, is
|
||||
# stored on a first sighting but never replaces a stored name and never
|
||||
# names the node; with no announced name the node keeps a stored name
|
||||
# that is not a placeholder, else reads its own placeholder
|
||||
# ({#reticulum_placeholder_name?}, {#reticulum_headline_name}).
|
||||
# @param interface [String, nil] interface the announce was heard on.
|
||||
# @param heard [Integer] unix seconds of receipt.
|
||||
# @return [void]
|
||||
@@ -277,13 +281,14 @@ module PotatoMesh
|
||||
|
||||
# A generic "Reticulum <SHORT>" placeholder is still worth storing on a
|
||||
# first sighting -- it is what the reader sees until a real name turns
|
||||
# up -- but it must never *replace* one a real announce supplied. The
|
||||
# host's own destinations are discovered rather than announced, so they
|
||||
# fall back to the placeholder whenever the stack remembers no app_data,
|
||||
# and since RE10 derives the node headline from these rows, letting that
|
||||
# land on top of a real name would rename the node too.
|
||||
# up -- but it must never *replace* one a real announce supplied. Every
|
||||
# nameless destination falls back to one, the host's own whenever the
|
||||
# stack remembers no app_data, and letting that land on top of a real
|
||||
# name would erase it; if RE10 had picked that name and another aspect
|
||||
# announced one, the headline would move to that one. Both placeholder
|
||||
# forms count (SPEC RA10).
|
||||
stored_name = string_or_nil(name)
|
||||
update_name = generic_fallback_name?(stored_name, node_id, "reticulum") ? nil : stored_name
|
||||
update_name = reticulum_placeholder_name?(stored_name, node_id, id) ? nil : stored_name
|
||||
|
||||
params = [
|
||||
id,
|
||||
@@ -350,8 +355,11 @@ module PotatoMesh
|
||||
#
|
||||
# +name+ and +role+ are resolved independently: an aspect can carry a role
|
||||
# while announcing no display name, and taking both from one row would let
|
||||
# a nameless top-ranked aspect blank the headline. Ties break on the more
|
||||
# recently heard destination.
|
||||
# a nameless top-ranked aspect blank the headline. A placeholder is not a
|
||||
# name either (SPEC RE10 as amended): the name is the best-ranked
|
||||
# *announced* one, else a stored name that is not a placeholder, else the
|
||||
# node's own placeholder ({#reticulum_headline_name}). Ties break on the
|
||||
# more recently heard destination.
|
||||
#
|
||||
# @param db [SQLite3::Database] open database handle.
|
||||
# @param node_id [String] canonical id of the node to refresh.
|
||||
@@ -359,13 +367,15 @@ module PotatoMesh
|
||||
def refresh_node_identity_from_destinations(db, node_id)
|
||||
rank = DESTINATION_ROLE_RANK_SQL
|
||||
with_busy_retry do
|
||||
db.execute(<<~SQL, [node_id, node_id, node_id])
|
||||
destinations = db.execute(<<~SQL, [node_id]).map { |row| row.is_a?(Hash) ? row.values_at("id", "name") : row }
|
||||
SELECT id, name FROM destinations WHERE node_id = ?
|
||||
ORDER BY #{rank}, COALESCE(last_heard, 0) DESC
|
||||
SQL
|
||||
stored = db.get_first_value("SELECT long_name FROM nodes WHERE node_id = ?", [node_id])
|
||||
headline = reticulum_headline_name(node_id, destinations, stored)
|
||||
db.execute(<<~SQL, [headline, node_id, node_id])
|
||||
UPDATE nodes SET
|
||||
long_name = COALESCE((
|
||||
SELECT name FROM destinations
|
||||
WHERE node_id = ? AND name IS NOT NULL AND TRIM(name) <> ''
|
||||
ORDER BY #{rank}, COALESCE(last_heard, 0) DESC LIMIT 1
|
||||
), long_name),
|
||||
long_name = COALESCE(?, long_name),
|
||||
role = COALESCE((
|
||||
SELECT role FROM destinations
|
||||
WHERE node_id = ? AND role IS NOT NULL
|
||||
@@ -488,8 +498,17 @@ module PotatoMesh
|
||||
# If the incoming long name is a generic placeholder, prefer any real
|
||||
# name already on record so we never stomp known data with fallback
|
||||
# text. For new nodes there is nothing to preserve, so the generic
|
||||
# name is still written via the INSERT VALUES path.
|
||||
long_name_conflict_sql = if generic_fallback_name?(long_name, node_id, protocol)
|
||||
# name is still written via the INSERT VALUES path. A Reticulum
|
||||
# record's name lands on its destination, so its placeholder may be
|
||||
# built from the destination's own hash rather than the node id
|
||||
# (SPEC RA10); both forms yield.
|
||||
destination = n["destination"]
|
||||
generic_long_name = if protocol == "reticulum"
|
||||
reticulum_placeholder_name?(long_name, node_id, destination.is_a?(Hash) ? destination["id"] : nil)
|
||||
else
|
||||
generic_fallback_name?(long_name, node_id, protocol)
|
||||
end
|
||||
long_name_conflict_sql = if generic_long_name
|
||||
# Generic placeholder: keep any real name already on record.
|
||||
# COALESCE returns nodes.long_name when non-null, otherwise falls
|
||||
# back to the incoming generic — so brand-new nodes still get it.
|
||||
@@ -619,8 +638,9 @@ module PotatoMesh
|
||||
heard: lh,
|
||||
)
|
||||
# The headline fields follow the ranked aspect, not the announce
|
||||
# that happened to arrive last (SPEC RE10).
|
||||
refresh_node_identity_from_destinations(db, node_id)
|
||||
# that happened to arrive last (SPEC RE10). Only a Reticulum node
|
||||
# has destinations, so no other protocol pays for the lookups.
|
||||
refresh_node_identity_from_destinations(db, node_id) if protocol == "reticulum"
|
||||
end
|
||||
|
||||
# Keyed-evidence stamp (SPEC MR1). Deliberately a separate,
|
||||
|
||||
+164
-2
@@ -278,8 +278,9 @@ RSpec.describe "Reticulum protocol support" do
|
||||
it "keeps a real destination name when a placeholder arrives later" do
|
||||
# Discovery re-emits the host's aspects on every snapshot, falling back to
|
||||
# "Reticulum <SHORT>" when the stack remembers no app_data. Arriving after
|
||||
# a real announce, that must not overwrite the stored name -- which would
|
||||
# rename the node too, since RE10 derives the headline from these rows.
|
||||
# a real announce, that must not overwrite the stored name: the
|
||||
# destination row would lose it, though the node keeps its stored
|
||||
# headline (RE10).
|
||||
row = post_two_aspects(
|
||||
{ aspect: "nomadnetwork.node", dest: RETICULUM_DEST_HASH,
|
||||
role: "NODE", name: "Department of Decentralization" },
|
||||
@@ -323,6 +324,167 @@ RSpec.describe "Reticulum protocol support" do
|
||||
expect(row["role"]).to eq("NODE")
|
||||
expect(row["long_name"]).to eq("Afri Nomad Orion")
|
||||
end
|
||||
|
||||
it "does not let a placeholder on a higher aspect rename the node" do
|
||||
# The example above sends name: nil, which no ingestor path sends: a
|
||||
# nameless aspect arrives as a placeholder, built from its own hash
|
||||
# (Reticulum 00FF) or, by an older ingestor, from the node id
|
||||
# (Reticulum A1B2). Neither is a name (SPEC RE10 as amended).
|
||||
observed = ["Reticulum 00FF", "Reticulum A1B2"].to_h do |placeholder|
|
||||
clear_tables
|
||||
row = post_two_aspects(
|
||||
{ aspect: "lxmf.delivery", dest: RETICULUM_DEST_HASH,
|
||||
role: "PEER", name: "Afri Nomad Orion" },
|
||||
{ aspect: "nomadnetwork.node", dest: RETICULUM_DEST_HASH2,
|
||||
role: "NODE", name: placeholder },
|
||||
)
|
||||
[placeholder, [row["long_name"], row["role"]]]
|
||||
end
|
||||
expect(observed).to eq(
|
||||
"Reticulum 00FF" => ["Afri Nomad Orion", "NODE"],
|
||||
"Reticulum A1B2" => ["Afri Nomad Orion", "NODE"],
|
||||
)
|
||||
end
|
||||
|
||||
it "names a node with no announced name from its own id, not a destination's" do
|
||||
# SPEC RA10(a): the node badges a1b2, so its headline reads Reticulum A1B2
|
||||
# while the destination row keeps its own Reticulum 00FF (RA10(b)). The
|
||||
# aspect is posted twice, as a later snapshot would repeat it.
|
||||
nameless = { aspect: "nomadnetwork.node", dest: RETICULUM_DEST_HASH2,
|
||||
role: "NODE", name: "Reticulum 00FF" }
|
||||
row = post_two_aspects(nameless, nameless)
|
||||
with_db(readonly: true) do |db|
|
||||
destination = db.get_first_value(
|
||||
"SELECT name FROM destinations WHERE id = ?", [RETICULUM_DEST_HASH2]
|
||||
)
|
||||
expect([destination, row["long_name"]]).to eq(["Reticulum 00FF", "Reticulum A1B2"])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "headline name fallback (SPEC RE10, RA10)" do
|
||||
it "keeps a real name the node carries when a nameless destination arrives" do
|
||||
# A record with an unusable destination hash names the node but writes no
|
||||
# destination row; a later nameless destination must not erase that name.
|
||||
register_reticulum_ingestor
|
||||
named = reticulum_node_fixture(last_heard: now - 30).except("destination")
|
||||
nameless = reticulum_node_fixture(last_heard: now - 29, aspect: "nomadnetwork.node",
|
||||
dest_id: RETICULUM_DEST_HASH2, role: "NODE")
|
||||
nameless["user"]["longName"] = "Reticulum 00FF"
|
||||
[named, nameless].each do |record|
|
||||
payload = { RETICULUM_NODE_ID => record, "ingestor" => RETICULUM_INGESTOR_ID, "protocol" => "reticulum" }
|
||||
post "/api/nodes", payload.to_json, auth_headers
|
||||
expect(last_response.status).to eq(201)
|
||||
end
|
||||
with_db(readonly: true) do |db|
|
||||
expect(
|
||||
db.get_first_value("SELECT long_name FROM nodes WHERE node_id = ?", [RETICULUM_NODE_ID]),
|
||||
).to eq("Argos Station")
|
||||
end
|
||||
end
|
||||
|
||||
it "gives a node with no name at all its own placeholder" do
|
||||
expect(reticulum_headline_name(RETICULUM_NODE_ID, [], nil)).to eq("Reticulum A1B2")
|
||||
end
|
||||
|
||||
it "leaves the name alone for an id it cannot read" do
|
||||
expect(reticulum_headline_name("not-a-node-id", [], nil)).to be_nil
|
||||
end
|
||||
|
||||
it "reads destination rows from a hash-results handle" do
|
||||
register_reticulum_ingestor
|
||||
post_reticulum_nodes
|
||||
with_db do |db|
|
||||
db.execute("UPDATE nodes SET long_name = 'Reticulum A1B2' WHERE node_id = ?", [RETICULUM_NODE_ID])
|
||||
refresh_node_identity_from_destinations(db, RETICULUM_NODE_ID)
|
||||
expect(
|
||||
db.get_first_value("SELECT long_name FROM nodes WHERE node_id = ?", [RETICULUM_NODE_ID]),
|
||||
).to eq("Argos Station")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "host destination placeholders (SPEC RA10)" do
|
||||
# The field host: node !27716218 (RETICULUM_IDENTITY_HASH) and two of the
|
||||
# destinations RNS derives from that identity, so neither shares the node's
|
||||
# head. The ingestor names a nameless destination from its own hash
|
||||
# (RA10): "Reticulum 4CF9" and "Reticulum 9C59", not the node's
|
||||
# "Reticulum 2771". tests/test_reticulum_unit.py pins the same string for
|
||||
# the same pair. RETICULUM_DEST_HASH starts like RETICULUM_NODE_ID and
|
||||
# cannot tell the two forms apart.
|
||||
RETICULUM_HOST_NODE_ID = "!27716218".freeze
|
||||
RETICULUM_HOST_LXMF = {
|
||||
"id" => "4cf985bf933c21b1aa8dabd407d4ef69", "aspect" => "lxmf.delivery", "role" => "PEER",
|
||||
}.freeze
|
||||
RETICULUM_HOST_NOMADNET = {
|
||||
"id" => "9c59da5e1516745d74cc908243e0ba2b", "aspect" => "nomadnetwork.node", "role" => "NODE",
|
||||
}.freeze
|
||||
|
||||
# POST host-destination records one at a time, each shaped as
|
||||
# _host_destination_nodes builds it (no publicKey, no hopsAway), and read
|
||||
# back what they left behind.
|
||||
#
|
||||
# @param records [Array<Array(Hash, String)>] destination block and
|
||||
# user.longName pairs, oldest first.
|
||||
# @return [Array(Hash{String => String}, String)] the stored destination
|
||||
# names keyed by destination id, and the node's long_name.
|
||||
def post_host_destinations(*records)
|
||||
register_reticulum_ingestor
|
||||
records.each_with_index do |(destination, name), index|
|
||||
payload = {
|
||||
RETICULUM_HOST_NODE_ID => {
|
||||
"nodeId" => RETICULUM_HOST_NODE_ID,
|
||||
"lastHeard" => now - 30 + index,
|
||||
"protocol" => "reticulum",
|
||||
"identityHash" => RETICULUM_IDENTITY_HASH,
|
||||
"destination" => destination,
|
||||
"user" => { "shortName" => "2771", "longName" => name, "role" => destination["role"] },
|
||||
},
|
||||
"ingestor" => RETICULUM_INGESTOR_ID,
|
||||
"protocol" => "reticulum",
|
||||
}
|
||||
post "/api/nodes", payload.to_json, auth_headers
|
||||
expect(last_response.status).to eq(201)
|
||||
end
|
||||
with_db(readonly: true) do |db|
|
||||
names = db.execute("SELECT id, name FROM destinations").to_h { |r| [r["id"], r["name"]] }
|
||||
headline = db.get_first_value(
|
||||
"SELECT long_name FROM nodes WHERE node_id = ?", [RETICULUM_HOST_NODE_ID]
|
||||
)
|
||||
[names, headline]
|
||||
end
|
||||
end
|
||||
|
||||
it "never lets a destination-derived placeholder replace a real name" do
|
||||
# The second record is a later snapshot whose stack remembers no app_data
|
||||
# for the destination.
|
||||
names, headline = post_host_destinations(
|
||||
[RETICULUM_HOST_LXMF, "Afri Nomad Orion"],
|
||||
[RETICULUM_HOST_LXMF, "Reticulum 4CF9"],
|
||||
)
|
||||
expect([names[RETICULUM_HOST_LXMF["id"]], headline]).to eq(["Afri Nomad Orion", "Afri Nomad Orion"])
|
||||
end
|
||||
|
||||
it "keeps the ranked headline when its aspect falls back to the placeholder" do
|
||||
# NODE outranks PEER (RE10); without the guard a placeholder landing on
|
||||
# the NODE row erases its real name and the headline moves to PEER's.
|
||||
names, headline = post_host_destinations(
|
||||
[RETICULUM_HOST_NOMADNET, "Department of Decentralization"],
|
||||
[RETICULUM_HOST_LXMF, "Afri Nomad Orion"],
|
||||
[RETICULUM_HOST_NOMADNET, "Reticulum 9C59"],
|
||||
)
|
||||
expect([names[RETICULUM_HOST_NOMADNET["id"]], headline]).to eq(
|
||||
["Department of Decentralization", "Department of Decentralization"],
|
||||
)
|
||||
end
|
||||
|
||||
it "stores a destination-derived placeholder on a first sighting" do
|
||||
# The generic name is wanted where there is no real one to prefer, on the
|
||||
# destination row only: the node keeps its own placeholder, which its
|
||||
# badge 2771 matches (RA10(a), RE10).
|
||||
names, headline = post_host_destinations([RETICULUM_HOST_LXMF, "Reticulum 4CF9"])
|
||||
expect([names[RETICULUM_HOST_LXMF["id"]], headline]).to eq(["Reticulum 4CF9", "Reticulum 2771"])
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /api/destinations" do
|
||||
|
||||
Reference in new issue
Block a user