Files
l5y 512b4f157b Fix regression where Meshcore chat senders show as Meshtastic (#794)
* Fix regression where Meshcore chat senders show as Meshtastic

* Address review feedback for protocol misclassification fix

- ingest.rb: exclude wrapper ``protocol`` key from /api/nodes batch-limit
  count so the documented 1000-node maximum still applies after the
  Python ingestor started stamping protocol at the wrapper level.
- Drop plan-file references from production and test comments per the
  repo guidelines; the why is already explained inline.

* Address protocol-fallback review feedback

- Neighbor placeholder now inherits the source node's protocol from the
  surrounding /api/neighbors entry, so the badge tracks the radio the
  peer lives on instead of collapsing to the neutral "Unknown" label
  (review item #1).
- resolve_record_protocol logs one warn_log line when an explicit
  protocol stamp is rejected as malformed, making misbehaving custom
  protocol adapters visible in the operator log instead of silently
  falling back (review item #3).

* Extract buildNodePlaceholder helper for testability

The neighbor placeholder logic in main.js lives inside an untested
closure, so codecov reported the protocol-propagation lines as
uncovered.  Extract the small placeholder builder into long-link-router
so it can be unit tested directly; the closure-internal call site stays
trivial (one factory call + one fallback call).
2026-05-24 09:49:45 +02:00

657 lines
23 KiB
Ruby

# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "spec_helper"
require "json"
require "time"
RSpec.describe "Multi-protocol support" do
let(:app) { Sinatra::Application }
let(:api_token) { "test-token" }
let(:auth_headers) do
{
"CONTENT_TYPE" => "application/json",
"HTTP_AUTHORIZATION" => "Bearer #{api_token}",
}
end
let(:now) { Time.now.to_i }
MESHCORE_INGESTOR_ID = "!11223344".freeze
ALT_NODE_ID = "!aabbccdd".freeze
ALT_NODE_ID2 = "!ccddee00".freeze
MESH_NODE_ID = "!mesh0001".freeze
CORE_NODE_ID = "!core0001".freeze
MESH_INGESTOR_ID = "!mesh9999".freeze
SELECT_INGESTOR_PROTOCOL_SQL = "SELECT protocol FROM ingestors WHERE node_id = ?".freeze
before do
@original_token = ENV.fetch("API_TOKEN", nil)
ENV["API_TOKEN"] = api_token
clear_tables
end
after do
ENV["API_TOKEN"] = @original_token
clear_tables
end
# Open a database connection for direct inspection.
#
# @param readonly [Boolean] whether to open in read-only mode.
# @yieldparam db [SQLite3::Database] open database handle.
# @return [void]
def with_db(readonly: false)
db = PotatoMesh::Application.open_database(readonly: readonly)
db.results_as_hash = true
yield db
ensure
db&.close
end
# Remove all rows from tables exercised by these tests.
#
# @return [void]
def clear_tables
with_db do |db|
db.execute("DELETE FROM trace_hops")
db.execute("DELETE FROM traces")
db.execute("DELETE FROM neighbors")
db.execute("DELETE FROM messages")
db.execute("DELETE FROM positions")
db.execute("DELETE FROM telemetry")
db.execute("DELETE FROM nodes")
db.execute("DELETE FROM ingestors")
end
end
# Register an ingestor via the API and return the response.
#
# @param node_id [String] canonical ingestor node identifier.
# @param protocol [String, nil] mesh protocol string; omit to test default.
# @return [Rack::MockResponse] the POST response.
def register_ingestor(node_id, protocol: nil)
payload = {
node_id: node_id,
start_time: now - 60,
last_seen_time: now,
version: "0.5.12",
}
payload[:protocol] = protocol if protocol
post "/api/ingestors", payload.to_json, auth_headers
last_response
end
describe "POST /api/ingestors" do
it "stores protocol when provided" do
register_ingestor(MESHCORE_INGESTOR_ID, protocol: "meshcore")
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row(SELECT_INGESTOR_PROTOCOL_SQL, [MESHCORE_INGESTOR_ID])
expect(row["protocol"]).to eq("meshcore")
end
end
it "defaults protocol to meshtastic when field is absent" do
register_ingestor("!aabbccdd")
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row(SELECT_INGESTOR_PROTOCOL_SQL, ["!aabbccdd"])
expect(row["protocol"]).to eq("meshtastic")
end
end
it "updates protocol on re-registration" do
register_ingestor(MESHCORE_INGESTOR_ID, protocol: "meshtastic")
register_ingestor(MESHCORE_INGESTOR_ID, protocol: "meshcore")
with_db(readonly: true) do |db|
row = db.get_first_row(SELECT_INGESTOR_PROTOCOL_SQL, [MESHCORE_INGESTOR_ID])
expect(row["protocol"]).to eq("meshcore")
end
end
end
describe "protocol propagation to event tables" do
before do
register_ingestor(MESHCORE_INGESTOR_ID, protocol: "meshcore")
end
it "writes meshcore protocol to messages that reference a meshcore ingestor" do
msg = {
id: 42,
rx_time: now - 10,
rx_iso: Time.at(now - 10).utc.iso8601,
text: "hello from meshcore",
ingestor: MESHCORE_INGESTOR_ID,
}
post "/api/messages", [msg].to_json, auth_headers
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [42])
expect(row["protocol"]).to eq("meshcore")
end
end
it "writes meshcore protocol to positions that reference a meshcore ingestor" do
pos = {
id: 100,
rx_time: now - 5,
rx_iso: Time.at(now - 5).utc.iso8601,
node_id: ALT_NODE_ID,
latitude: 1.0,
longitude: 2.0,
ingestor: MESHCORE_INGESTOR_ID,
}
post "/api/positions", [pos].to_json, auth_headers
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM positions WHERE id = ?", [100])
expect(row["protocol"]).to eq("meshcore")
end
end
it "writes meshcore protocol to telemetry that references a meshcore ingestor" do
tel = {
id: 200,
rx_time: now - 5,
rx_iso: Time.at(now - 5).utc.iso8601,
node_id: ALT_NODE_ID,
battery_level: 80,
ingestor: MESHCORE_INGESTOR_ID,
}
post "/api/telemetry", [tel].to_json, auth_headers
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM telemetry WHERE id = ?", [200])
expect(row["protocol"]).to eq("meshcore")
end
end
it "writes meshcore protocol to traces that reference a meshcore ingestor" do
trace = {
id: 300,
src: 0x11223344,
dest: 0xaabbccdd,
rx_time: now - 5,
rx_iso: Time.at(now - 5).utc.iso8601,
hops: [],
ingestor: MESHCORE_INGESTOR_ID,
}
post "/api/traces", [trace].to_json, auth_headers
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM traces WHERE id = ?", [300])
expect(row["protocol"]).to eq("meshcore")
end
end
it "uses protocol-derived long_name for auto-created placeholder nodes" do
msg = {
id: 43,
rx_time: now - 10,
rx_iso: Time.at(now - 10).utc.iso8601,
from_id: "!11223300",
text: "unknown sender",
ingestor: MESHCORE_INGESTOR_ID,
}
post "/api/messages", [msg].to_json, auth_headers
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT long_name FROM nodes WHERE node_id = ?", ["!11223300"])
expect(row["long_name"]).to eq("Meshcore 3300")
end
end
it "does not merge a message update from a different protocol" do
msg = {
id: 500,
rx_time: now - 10,
rx_iso: Time.at(now - 10).utc.iso8601,
text: "meshcore original",
ingestor: MESHCORE_INGESTOR_ID,
}
post "/api/messages", [msg].to_json, auth_headers
expect(last_response.status).to eq(200)
# Meshtastic ingestor posts same ID — should be ignored
meshtastic_msg = {
id: 500,
rx_time: now - 5,
rx_iso: Time.at(now - 5).utc.iso8601,
text: "meshtastic impostor",
}
post "/api/messages", [meshtastic_msg].to_json, auth_headers
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT text, protocol FROM messages WHERE id = ?", [500])
expect(row["text"]).to eq("meshcore original")
expect(row["protocol"]).to eq("meshcore")
end
end
it "does not overwrite a meshcore message via the constraint-fallback path" do
# Seed the message directly in the DB so the first INSERT triggers a
# constraint exception, exercising the rescue SQLite3::ConstraintException
# fallback path rather than the primary update branch.
with_db do |db|
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, text, protocol) VALUES(?,?,?,?,?)",
[501, now - 20, Time.at(now - 20).utc.iso8601, "meshcore seeded", "meshcore"],
)
end
# A Meshtastic payload arrives with the same packet ID and new text.
# The fallback path must not overwrite the existing meshcore record.
meshtastic_msg = {
id: 501,
rx_time: now - 5,
rx_iso: Time.at(now - 5).utc.iso8601,
text: "meshtastic fallback attempt",
}
post "/api/messages", [meshtastic_msg].to_json, auth_headers
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT text, protocol FROM messages WHERE id = ?", [501])
expect(row["text"]).to eq("meshcore seeded")
expect(row["protocol"]).to eq("meshcore")
end
end
end
describe "POST /api/nodes with ingestor key" do
it "inherits protocol from registered ingestor" do
register_ingestor(MESHCORE_INGESTOR_ID, protocol: "meshcore")
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, last_heard, first_heard) VALUES(?,?,?,?)",
[ALT_NODE_ID, 0xaabbccdd, now - 100, now - 200],
)
end
payload = {
ALT_NODE_ID => { "num" => 0xaabbccdd, "lastHeard" => now - 10 },
"ingestor" => MESHCORE_INGESTOR_ID,
}
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", [ALT_NODE_ID])
expect(row["protocol"]).to eq("meshcore")
end
end
it "defaults to meshtastic when ingestor key is absent" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, last_heard, first_heard) VALUES(?,?,?,?)",
[ALT_NODE_ID2, 0xccddee00, now - 100, now - 200],
)
end
payload = { ALT_NODE_ID2 => { "num" => 0xccddee00, "lastHeard" => now - 10 } }
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", [ALT_NODE_ID2])
expect(row["protocol"]).to eq("meshtastic")
end
end
it "does not count the ingestor key against the node batch limit" do
# Build exactly 1000 node entries plus the ingestor key — should succeed
nodes = (1..1000).each_with_object({}) do |i, h|
h[format("!%08x", i)] = { "num" => i, "lastHeard" => now - 1 }
end
nodes["ingestor"] = MESHCORE_INGESTOR_ID
post "/api/nodes", nodes.to_json, auth_headers
expect(last_response.status).to eq(200)
end
it "does not count the wrapper-level protocol key against the node batch limit" do
# 1000 nodes + ingestor + protocol = 1002 wrapper keys but only 1000
# actual nodes — the limit check must skip both metadata keys.
nodes = (1..1000).each_with_object({}) do |i, h|
h[format("!%08x", i)] = { "num" => i, "lastHeard" => now - 1 }
end
nodes["ingestor"] = MESHCORE_INGESTOR_ID
nodes["protocol"] = "meshcore"
post "/api/nodes", nodes.to_json, auth_headers
expect(last_response.status).to eq(200)
end
end
describe "GET ?protocol= filter" do
before do
register_ingestor(MESHCORE_INGESTOR_ID, protocol: "meshcore")
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, last_heard, first_heard, protocol) VALUES(?,?,?,?,?)",
[MESH_NODE_ID, 1, now - 10, now - 20, "meshtastic"],
)
db.execute(
"INSERT INTO nodes(node_id, num, last_heard, first_heard, protocol) VALUES(?,?,?,?,?)",
[CORE_NODE_ID, 2, now - 10, now - 20, "meshcore"],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, text, protocol) VALUES(?,?,?,?,?)",
[1001, now - 5, Time.at(now - 5).utc.iso8601, "meshtastic msg", "meshtastic"],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, text, protocol) VALUES(?,?,?,?,?)",
[1002, now - 5, Time.at(now - 5).utc.iso8601, "meshcore msg", "meshcore"],
)
db.execute(
"INSERT INTO positions(id, rx_time, rx_iso, node_id, protocol) VALUES(?,?,?,?,?)",
[2001, now - 5, Time.at(now - 5).utc.iso8601, MESH_NODE_ID, "meshtastic"],
)
db.execute(
"INSERT INTO positions(id, rx_time, rx_iso, node_id, protocol) VALUES(?,?,?,?,?)",
[2002, now - 5, Time.at(now - 5).utc.iso8601, CORE_NODE_ID, "meshcore"],
)
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, rx_time, protocol) VALUES(?,?,?,?)",
[MESH_NODE_ID, CORE_NODE_ID, now - 5, "meshtastic"],
)
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, rx_time, protocol) VALUES(?,?,?,?)",
[CORE_NODE_ID, MESH_NODE_ID, now - 5, "meshcore"],
)
db.execute(
"INSERT INTO telemetry(id, rx_time, rx_iso, node_id, protocol) VALUES(?,?,?,?,?)",
[3001, now - 5, Time.at(now - 5).utc.iso8601, MESH_NODE_ID, "meshtastic"],
)
db.execute(
"INSERT INTO telemetry(id, rx_time, rx_iso, node_id, protocol) VALUES(?,?,?,?,?)",
[3002, now - 5, Time.at(now - 5).utc.iso8601, CORE_NODE_ID, "meshcore"],
)
db.execute(
"INSERT INTO traces(id, rx_time, rx_iso, protocol) VALUES(?,?,?,?)",
[4001, now - 5, Time.at(now - 5).utc.iso8601, "meshtastic"],
)
db.execute(
"INSERT INTO traces(id, rx_time, rx_iso, protocol) VALUES(?,?,?,?)",
[4002, now - 5, Time.at(now - 5).utc.iso8601, "meshcore"],
)
end
end
it "filters /api/nodes by protocol" do
get "/api/nodes?protocol=meshcore", {}, auth_headers
expect(last_response.status).to eq(200)
ids = JSON.parse(last_response.body).map { |r| r["node_id"] }
expect(ids).to include(CORE_NODE_ID)
expect(ids).not_to include(MESH_NODE_ID)
end
it "filters /api/messages by protocol" do
get "/api/messages?protocol=meshcore", {}, auth_headers
expect(last_response.status).to eq(200)
texts = JSON.parse(last_response.body).map { |r| r["text"] }
expect(texts).to include("meshcore msg")
expect(texts).not_to include("meshtastic msg")
end
it "filters /api/positions by protocol" do
get "/api/positions?protocol=meshcore", {}, auth_headers
expect(last_response.status).to eq(200)
ids = JSON.parse(last_response.body).map { |r| r["id"] }
expect(ids).to include(2002)
expect(ids).not_to include(2001)
end
it "filters /api/neighbors by protocol" do
get "/api/neighbors?protocol=meshcore", {}, auth_headers
expect(last_response.status).to eq(200)
rows = JSON.parse(last_response.body)
expect(rows.any? { |r| r["node_id"] == CORE_NODE_ID }).to be(true)
expect(rows.none? { |r| r["node_id"] == MESH_NODE_ID }).to be(true)
end
it "filters /api/telemetry by protocol" do
get "/api/telemetry?protocol=meshcore", {}, auth_headers
expect(last_response.status).to eq(200)
ids = JSON.parse(last_response.body).map { |r| r["id"] }
expect(ids).to include(3002)
expect(ids).not_to include(3001)
end
it "filters /api/traces by protocol" do
get "/api/traces?protocol=meshcore", {}, auth_headers
expect(last_response.status).to eq(200)
ids = JSON.parse(last_response.body).map { |r| r["id"] }
expect(ids).to include(4002)
expect(ids).not_to include(4001)
end
it "filters /api/ingestors by protocol" do
with_db do |db|
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version, protocol) VALUES(?,?,?,?,?)",
[MESH_INGESTOR_ID, now - 60, now, "0.5.12", "meshtastic"],
)
end
get "/api/ingestors?protocol=meshcore", {}, auth_headers
expect(last_response.status).to eq(200)
ids = JSON.parse(last_response.body).map { |r| r["node_id"] }
expect(ids).to include(MESHCORE_INGESTOR_ID)
expect(ids).not_to include(MESH_INGESTOR_ID)
end
it "returns all records when protocol param is absent" do
get "/api/nodes", {}, auth_headers
expect(last_response.status).to eq(200)
ids = JSON.parse(last_response.body).map { |r| r["node_id"] }
expect(ids).to include(MESH_NODE_ID)
expect(ids).to include(CORE_NODE_ID)
end
it "includes protocol field in GET /api/messages responses" do
get "/api/messages", {}, auth_headers
expect(last_response.status).to eq(200)
rows = JSON.parse(last_response.body)
expect(rows.all? { |r| r.key?("protocol") }).to be(true)
end
it "includes protocol field in GET /api/nodes responses" do
get "/api/nodes", {}, auth_headers
expect(last_response.status).to eq(200)
rows = JSON.parse(last_response.body)
expect(rows.all? { |r| r.key?("protocol") }).to be(true)
end
end
describe "backward compatibility" do
it "existing payloads without protocol field default to meshtastic" do
msg = {
id: 999,
rx_time: now - 10,
rx_iso: Time.at(now - 10).utc.iso8601,
text: "legacy message",
}
post "/api/messages", [msg].to_json, auth_headers
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [999])
expect(row["protocol"]).to eq("meshtastic")
end
end
it "existing ingestor registrations without protocol default to meshtastic in GET responses" do
with_db do |db|
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version, protocol) VALUES(?,?,?,?,?)",
["!legacy00", now - 120, now - 10, "0.5.0", "meshtastic"],
)
end
get "/api/ingestors", {}, auth_headers
expect(last_response.status).to eq(200)
entry = JSON.parse(last_response.body).find { |r| r["node_id"] == "!legacy00" }
expect(entry["protocol"]).to eq("meshtastic")
end
end
# Coverage for the per-record protocol stamp that closes the startup race
# where the web app processes a MeshCore message before the corresponding
# ingestor heartbeat has registered a protocol mapping — see CONTRACTS.md.
describe "per-record protocol override" do
it "honors explicit message[\"protocol\"] when ingestor is unregistered" do
msg = {
id: 6001,
rx_time: now - 10,
rx_iso: Time.at(now - 10).utc.iso8601,
from_id: "!aabbcc01",
text: "explicit meshcore stamp",
ingestor: "!unregistered000",
protocol: "meshcore",
}
post "/api/messages", [msg].to_json, auth_headers
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
message_row = db.get_first_row(
"SELECT protocol FROM messages WHERE id = ?",
[6001],
)
expect(message_row["protocol"]).to eq("meshcore")
node_row = db.get_first_row(
"SELECT long_name, role, protocol FROM nodes WHERE node_id = ?",
["!aabbcc01"],
)
# ``canonical_node_parts`` uppercases hex letters in the short_id; digit-only
# short_ids (like the ``3300`` case earlier in this file) are unaffected.
expect(node_row["long_name"]).to eq("Meshcore CC01")
expect(node_row["role"]).to eq("COMPANION")
expect(node_row["protocol"]).to eq("meshcore")
end
end
it "honors explicit protocol on /api/nodes wrapper when ingestor is unregistered" do
payload = {
"!11aabbcc" => { "num" => 0x11aabbcc, "lastHeard" => now - 5 },
"ingestor" => "!unregistered000",
"protocol" => "meshcore",
}
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", ["!11aabbcc"])
expect(row["protocol"]).to eq("meshcore")
end
end
it "lets per-node protocol override wrapper batch protocol" do
payload = {
"!22aabbcc" => {
"num" => 0x22aabbcc,
"lastHeard" => now - 5,
"protocol" => "meshcore",
},
"ingestor" => "!unregistered000",
"protocol" => "meshtastic",
}
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", ["!22aabbcc"])
expect(row["protocol"]).to eq("meshcore")
end
end
it "falls back to ingestor lookup when explicit protocol is malformed" do
register_ingestor(MESHCORE_INGESTOR_ID, protocol: "meshcore")
msg = {
id: 6002,
rx_time: now - 10,
rx_iso: Time.at(now - 10).utc.iso8601,
text: "garbage protocol value",
ingestor: MESHCORE_INGESTOR_ID,
protocol: "reticulum",
}
post "/api/messages", [msg].to_json, auth_headers
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [6002])
expect(row["protocol"]).to eq("meshcore")
end
end
it "falls back to meshtastic when no explicit protocol and ingestor is unregistered" do
msg = {
id: 6003,
rx_time: now - 10,
rx_iso: Time.at(now - 10).utc.iso8601,
text: "no stamp, no ingestor",
ingestor: "!unregistered000",
}
post "/api/messages", [msg].to_json, auth_headers
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [6003])
expect(row["protocol"]).to eq("meshtastic")
end
end
it "normalises mixed-case and whitespace in protocol stamp" do
msg = {
id: 6004,
rx_time: now - 10,
rx_iso: Time.at(now - 10).utc.iso8601,
text: "case normalisation",
ingestor: "!unregistered000",
protocol: " MeshCore ",
}
post "/api/messages", [msg].to_json, auth_headers
expect(last_response.status).to eq(200)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [6004])
expect(row["protocol"]).to eq("meshcore")
end
end
end
end